pax_global_header00006660000000000000000000000064151777615020014525gustar00rootroot0000000000000052 comment=543de05d46f878ad50ec6897da775c30d841e547 terminaltexteffects-release-0.15.0/000077500000000000000000000000001517776150200173065ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/.gitattributes000066400000000000000000000000301517776150200221720ustar00rootroot00000000000000docs/img/* export-ignoreterminaltexteffects-release-0.15.0/.gitignore000066400000000000000000000062741517776150200213070ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ .tte_venv # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ notes.txt terminaltexteffects/test_tte.py */*.stats *.prof github_resources/* *.gv effect_test.py effect_canvas_test. .vscode/* lib_tests/* dev_notes/* 38_venv/* .DS_Storeterminaltexteffects-release-0.15.0/CHANGELOG.md000066400000000000000000001717671517776150200211420ustar00rootroot00000000000000# Change Log --- ## 0.15.0 --- ### New Features (0.15.0) --- * Added effect discovery support for user provided effects. Effects should be Python files located at XDG_CONFIG_HOME/terminaltexteffects/effects. (e.g. /home/me/.config/terminaltexteffects/effects/effect_custom.py) * Added shell completion generation for `bash` and `zsh` via `tte --print-completion {bash,zsh}`. #### Application Changes (0.15.0) --- * The CLI parser is now built through a shared parser-construction path so runtime argument parsing, help text, and generated shell completions all use the same discovered effect and option definitions. * Generated shell completions now include user-provided effects discovered from `XDG_CONFIG_HOME/terminaltexteffects/effects` at completion-generation time. * The `zsh` completion script now self-initializes `compinit` and `bashcompinit` when needed so `eval "$(tte --print-completion zsh)"` works with less shell setup friction. * The CLI version flag now falls back to the local `pyproject.toml` version when running from a source checkout without installed package metadata. #### Engine Changes (0.15.0) --- * Added shared gradient argument helpers (`FinalGradientStopsArg`, `FinalGradientStepsArg`, `FinalGradientFramesArg`, `FinalGradientDirectionArg`) for effect configs to standardize CLI defaults and parsing. * Updated all effects to use gradient argument helpers. * Updated all effects parser epilog example help text to reflect up to date defaults. * `SpanningTreeGenerator.get_neighbors()` now honors `unlinked_only=False` by returning linked and unlinked neighbors, matching the documented API. * Added focused unit test coverage for spanning-tree generators, including `SpanningTreeGenerator`, `RecursiveBacktracker`, `PrimsSimple`, `PrimsWeighted`, `BreadthFirst`, and `AldousBroder`. * Clarified `RecursiveBacktracker` documentation to describe the depth-first spanning-tree behavior, initialized state, and delayed completion semantics accurately. * `PrimsSimple` now applies `limit_to_text_boundary` consistently when deciding whether a newly linked character remains in the edge set, and its documentation now reflects the generator's actual state transitions. * `PrimsWeighted` now limits random starting-character selection to the text boundary when requested, and its documentation now explains the per-character weighting model and completion behavior more accurately. * `BreadthFirst` now initializes correctly when `starting_char` is omitted, records each discovered character only once with the correct parent frontier node, and has updated traversal documentation. * `AldousBroder` now returns immediately once all characters are linked, and its documentation now matches the generator's state transitions and project docstring style. * `existing_color_handling="always"` is now applied consistently by the animation engine for input-derived characters, including when the parsed input has no colors. Helper and fill characters remain effect-colored instead of being cleared by the engine. * Terminal input preprocessing now supports common fetch-application layout sequences by interpreting cursor movement CSI sequences (`A`, `B`, `C`, `D`, `E`, `F`, `G`, `H`, and `f`) into TTE's virtual input canvas instead of rendering the escape sequences as text. * Terminal input preprocessing now accepts common fetch-application DEC private mode toggles for cursor visibility and line wrapping (`?25h`, `?25l`, `?7h`, and `?7l`) as no-op input state while continuing to reject unsupported terminal control sequences. * Existing ANSI color parsing now supports 3-bit and 4-bit SGR foreground/background colors, reset sequences, and mixed style/color SGR sequences used by applications such as `neofetch` and `fastfetch`. * Parsed input bold state is now preserved for `existing_color_handling="always"`, and bold standard ANSI foreground colors now resolve to their bright color equivalents for closer visual parity with normal terminal-rendered fetch output. * Added manual visual sequence tests for mixed ANSI color input and fetch-style layout/style input across `existing_color_handling` modes `dynamic`, `always`, and `ignore`, with printed parameter headers to make captured visual output easier to identify. * Updated `engine.motion` documentation to accurately describe waypoint fields, bezier control handling, path runtime state, activation-side path mutation, looping behavior, and documented exceptions. * Updated `engine.animation` documentation to accurately describe scene lookup behavior, scene sync and completion semantics, frame/color override rules, activation/reset expectations, and current TODO-backed gaps around dim formatting, duplicate scene IDs, and unused scene-step tracking. * Updated `engine.terminal` documentation to better describe canvas sizing and anchoring, input preprocessing, character/fill tracking, rendering state, cursor management, and sort/lookup behavior, and hardened `enforce_framerate()` so a configured frame rate of `0` safely disables frame limiting even when the method is called directly. * Updated `engine.base_effect` documentation to clarify iterator creation semantics, frame retrieval and active-character lifecycle behavior, preexisting input-color detection, and terminal-output restoration behavior, plus fixed a small module docstring typo. * `motion.deactivate_path()` now accepts a `Path`, a path ID string, or no argument, with the no-argument form deactivating the current active path when present. * `EventHandler.Action.DEACTIVATE_PATH` event registrations now support a `Path`, a path ID string, or `None`, and focused engine tests cover the expanded deactivation behavior. * `animation.deactivate_scene()` now accepts a `Scene`, a scene ID string, or no argument, with the no-argument form deactivating the current active scene when present. * `EventHandler.Action.DEACTIVATE_SCENE` event registrations now support a `Scene`, a scene ID string, or `None`, and focused engine tests cover the expanded deactivation behavior. * Updated `engine.base_character` documentation to reflect object-based event registration, expanded path and scene deactivation behavior, `EffectCharacter` runtime state, and the current ordering and reset semantics used during character updates and event handling. * Updated `engine.base_config` documentation to clarify `parser_spec` requirements, `ArgSpec`-based config construction, and `_build_config()` fallback and error behavior. * Updated `utils.argutils` documentation to remove stale module references and better describe parser specs, argument specs, tuple normalization, range validators, symbol validation, canvas dimensions, and easing parser return values. * Updated `utils.ansitools` documentation to clarify ANSI color parsing normalization and cursor movement indexing and relative movement behavior. * Updated `utils.colorterm` documentation to clarify RGB tuple conversion, ANSI selector meanings, and accepted XTerm and hex color inputs. * Updated `utils.easing` documentation to remove stale motion-specific wording and better describe sequence-prefix easing, clamp behavior, and stepwise added/removed slice tracking. * Updated `utils.geometry` documentation to clarify zero-size rectangle behavior, ray extrapolation wording, sampled bezier-length approximation, and the module's public helper list. * Updated `utils.graphics` documentation to align `Color`, `ColorPair`, and `Gradient` docs with stored `Color` objects, discrete spectrum lookup behavior, color normalization, and color-shift interpolation and extrapolation semantics. * Updated `utils.hexterm` documentation to clarify accepted color types, nearest-color matching behavior, return types, and the module's mapping-focused summary. * `Color.__str__()` and `ColorPair.__str__()` now correctly include valid XTerm color `0` in their output, and focused utility tests cover the regression. * `shift_color_towards()` now uses an LRU cache to avoid recomputing repeated color transitions during effect rendering. * Updated the `terminaltexteffects.__main__` entry-point documentation to describe effect discovery, plugin loading, duplicate-command validation, and the CLI's input and exit behavior more accurately. #### Effects Changes (0.15.0) * BouncyBalls - Added `existing_color_handling="dynamic"` support. Balls still fall using the effect's ball colors, and on settle they now transition to the input symbol plus any parsed input ANSI fg/bg colors. Characters without parsed input colors settle with no explicit final color so they render using the terminal default color. * Beams - Fixed `existing_color_handling="dynamic"` final-state handling so characters with parsed input ANSI fg/bg colors settle back to those input colors, while characters without parsed input colors now finish with no explicit color and render using the terminal default color. * BinaryPath - Fixed `existing_color_handling="dynamic"` final-state handling so characters now settle back to any parsed input ANSI fg/bg colors on a per-channel basis, and characters without parsed input colors finish with no explicit color and render using the terminal default color. * Bubbles - Added `existing_color_handling="dynamic"` support. Bubbles still float and pop using the effect's own colors, and after popping characters now transition to the input symbol plus any parsed input ANSI fg/bg colors. Characters without parsed input colors settle with no explicit final color so they render using the terminal default color. * Burn - Added `existing_color_handling="dynamic"` support. Characters still ignite and burn using the effect's own colors, and after the burn scene completes they now transition to the input symbol plus any parsed input ANSI fg/bg colors. Characters without parsed input colors settle with no explicit final color so they render using the terminal default color. Burn now processes input spaces that carry parsed ANSI colors in `dynamic` and `always` modes, preserving background-colored swatch cells. * ColorShift - Added `existing_color_handling="dynamic"` support for the final settle transition. Characters still play the effect's shifting gradient animation, and when the final gradient runs they now transition to the input symbol plus any parsed input ANSI fg/bg colors. Characters without parsed input colors settle with no explicit final color so they render using the terminal default color. * Crumble - Added `existing_color_handling="dynamic"` support for the faded intro and post-flash settle scenes. Characters with parsed input ANSI fg/bg colors now crumble using faded versions of those colors and settle back to their input colors, while characters without parsed input colors start from a neutral gray and settle with no explicit final color. * Decrypt - Added `existing_color_handling="dynamic"` support in the discovered scene. Characters keep the effect's ciphertext colors during typing and decryption, then transition to any parsed input ANSI fg/bg colors when discovered. Characters without parsed input colors finish with no explicit color so they render using the terminal default color. * ErrorCorrect - Added `existing_color_handling="dynamic"` support. Characters that are never swapped now use parsed input ANSI fg/bg colors from the start, while swapped characters keep the effect's error/correction colors until the final block wipe and settle scene, where they now resolve to their input colors or no explicit color if none were parsed. * Expand - Added `existing_color_handling="dynamic"` support in the synced final gradient scene, so characters now expand outward using the effect's starting settle color and resolve to any parsed input ANSI fg/bg colors or no explicit color if none were parsed. The effect's `--final-gradient-frames` option was removed because the synced scene now progresses by path distance rather than frame duration. * Fireworks - Added `existing_color_handling="dynamic"` support in `fall_scn`. Firework launch and bloom phases remain effect-colored, and when characters fall into place they now transition to any parsed input ANSI fg/bg colors or to no explicit color if none were parsed. * Highlight - Added `existing_color_handling="dynamic"` support based on parsed input foreground colors. Characters with input fg colors now use that color as the highlight base and return color, while characters without input fg colors remain in terminal default color and receive no visible highlight effect. Highlight now preserves parsed input background colors on base and highlight frames, including bg-only input spaces used by the visual swatches. * LaserEtch - Added `existing_color_handling="dynamic"` support in the etched character cooling scene. Characters with parsed input ANSI fg/bg colors now cool directly from the effect's heat colors into those input colors, while characters without parsed input colors cool to white and then finish with no explicit color so they render using the terminal default color. LaserEtch now etches input spaces that carry parsed ANSI colors instead of skipping them, preserving background-colored swatch cells. * Matrix - Added `existing_color_handling="dynamic"` support in the `resolve` scene. Matrix rain, fill, highlight, and symbol-swapping phases remain effect-colored, and when characters resolve they now transition to any parsed input ANSI fg/bg colors or to no explicit color if none were parsed. Matrix now resolves input spaces that carry parsed ANSI colors instead of hiding them, preserving background-colored swatch cells. * MiddleOut - Added `existing_color_handling="dynamic"` support in the `full` scene after the center phase completes. Characters still begin from the effect's `starting_color`, then transition to any parsed input ANSI fg/bg colors or to no explicit color if none were parsed. * OrbittingVolley - Added `existing_color_handling="dynamic"` support for launched input characters. The orbiting launcher visuals remain effect-colored, while launched characters now use any parsed input ANSI fg/bg colors immediately or no explicit color if none were parsed. * Pour - Added `existing_color_handling="dynamic"` support in the pour gradient scene. Characters still begin from the effect's `starting_color`, then transition to any parsed input ANSI fg/bg colors or to no explicit color if none were parsed. * Print - Added `existing_color_handling="dynamic"` support in the typed animation scene. Characters still show the print-head block symbols and color while being typed, then resolve to any parsed input ANSI fg/bg colors or to no explicit color if none were parsed. * Rain - Added `existing_color_handling="dynamic"` support in `fade_scn`. Characters still fall as rain-colored rain symbols, and when they reach their input coordinates they now fade into any parsed input ANSI fg/bg colors or to no explicit color if none were parsed. * RandomSequence - Added `existing_color_handling="dynamic"` support in the fade-in scene. Characters with parsed input ANSI fg/bg colors now fade in to those input colors, while uncolored characters fade through neutral gray and then finish with no explicit color applied. * Rings - Added `existing_color_handling="dynamic"` support that uses parsed input ANSI fg/bg colors or no color throughout the entire effect. In dynamic mode, the start, ring, and disperse phases no longer apply effect-owned ring colors. * Scattered - Added `existing_color_handling="dynamic"` support that uses parsed input ANSI fg/bg colors or no color for the entire synced movement scene. In dynamic mode, no effect-owned gradient colors are applied during the effect. * Slice - Added `existing_color_handling="dynamic"` support that uses parsed input ANSI fg/bg colors or no color for the entire effect. In dynamic mode, no effect-owned final gradient colors are applied while the sliced groups move into place. * Slide - Added `existing_color_handling="dynamic"` support that uses parsed input ANSI fg/bg colors or no color for the entire effect. In dynamic mode, no effect-owned gradient colors are applied while character groups slide into place. * Smoke - Added `existing_color_handling="dynamic"` support that starts characters in black, then reveals them using parsed input ANSI fg/bg colors or no color. In dynamic mode, the smoke and paint phases no longer use effect-owned colors. * Spotlights - Completed `existing_color_handling="dynamic"` support with per-channel bright and dim input-color handling. Colored characters now start as faded input colors, uncolored characters start as faded neutral gray, and the final spotlight expand clears temporary fallback foreground color so bg-only or uncolored characters finish without an unintended fg color. Spotlights now illuminates input spaces that carry parsed ANSI colors, preserving background-colored swatch cells during illumination and final expand. * Spray - Added `existing_color_handling="dynamic"` support that uses parsed input ANSI fg/bg colors or no color for the entire effect. In dynamic mode, the spray scene no longer applies effect-owned gradient colors. * Swarm - Added `existing_color_handling="dynamic"` support in the landing `input_scn`. Swarm and flash motion remain effect-colored, and settling characters now transition to parsed input ANSI fg/bg colors or to no explicit color if none were parsed. * Sweep - Added `existing_color_handling="dynamic"` support in the second sweep scene. The first sweep remains unchanged, the second sweep shimmer now draws from parsed input ANSI fg/bg colors when available, and characters finish in their input colors or with no explicit color if none were parsed. * SynthGrid - Added `existing_color_handling="dynamic"` support at the end of `dissolve_scn`. The dissolve shimmer remains effect-colored, and the final dissolve frame now resolves to parsed input ANSI fg/bg colors or to no explicit color if none were parsed. SynthGrid now applies final input colors to input spaces as well as visible symbols, preserving background-colored swatch cells. * Thunderstorm - Added `existing_color_handling="dynamic"` support from the beginning of the effect. Text characters now start from parsed input ANSI colors or neutral gray, fade down for the storm, still use the normal lightning flash/glow behavior, and return after the storm to normal input brightness or to no explicit color for originally uncolored text. * Unstable - Added `existing_color_handling="dynamic"` support from the start of the effect. Characters now begin in parsed input ANSI fg/bg colors or neutral gray, still shift toward the effect's unstable color during `rumble`, and then coalesce back to their input colors or to no explicit color if none were parsed. * VHSTape - Added `existing_color_handling="dynamic"` support for the stable text phases and final redraw. Characters now rest in parsed input ANSI fg/bg colors or neutral gray, effect-colored glitch and noise scenes remain unchanged, and the final redraw resolves to input colors or to no explicit color if none were parsed. * Waves - Added `existing_color_handling="dynamic"` support for the steady state and final settle. Characters now start and end in parsed input ANSI fg/bg colors or with no explicit color if none were parsed, while the animated wave itself continues to use the effect's own symbols and colors unchanged. * Wipe - Added `existing_color_handling="dynamic"` support for the entire wipe scene. In dynamic mode, all wipe frames now use parsed input ANSI fg/bg colors or no explicit color, and the effect-owned wipe gradient is no longer applied. * Overflow - Fixed `existing_color_handling="dynamic"` final-row handling so uncolored characters no longer pick up the final gradient color and instead remain uncolored. Final rows with parsed input ANSI fg/bg colors continue to preserve those input colors. ## 0.14.2 --- ### Bug Fixes (0.14.2) * Removed mistakenly added in-development effect. ## 0.14.1 --- ### Bug Fixes (0.14.1) * Removed duplicate keyword arg `action` in `effect_template` `ArgSpec`. ## 0.14.0 --- ### New Features (0.14.0) --- #### New Application Features (0.14.0) * `random_effect` is now specified as `--random-effect` and supports `--include-effects` or `--exclude-effects` for limiting which effects are available. #### Application Changes (0.14.0) * `--version` switch now pull the package version from the package metadata instead of the package `__init__.py` --- ### Changes (0.14.0) --- #### Engine Changes (0.14.0) * Added `EasingTracker`, a reusable helper that tracks eased progress, deltas, and completion state for any easing function. * Replaced `eased_step_function` closure with the new `SequenceEaser`, enabling eased iteration over arbitrary sequences while reporting added, removed, and total elements for each step. * Renamed `CharacterGroup` center related groupings to `CENTER_TO_OUTSIDE` / `OUTSIDE_TO_CENTER`. * `CharacterGroup`, `CharacterSort`, and `ColorSort` themselves were relocated from the `Terminal` module into `terminaltexteffects.utils.argutils`, and the terminal now imports them from there so both the CLI and the engine share a single definition of the enums. * `terminaltexteffects.utils.argutils` introduces dedicated argument-type helpers for `CharacterGroup`, `CharacterSort`, and `ColorSort`. * `Canvas` now exposes a `text_center` `Coord` computed from `text_center_row`/`text_center_column`, eliminating redundant per-call calculations when effects or sort helpers need the true center of the anchored text. * Center-to-outside/Outside-to-center `CharacterGroup` calculations within `Terminal` now measure distance from the text center instead of the canvas center, so middle-out and outside-in sorts stay aligned with the rendered text even when it is offset on the canvas. #### Effects Changes (0.14.0) * Highlight - Simplified effect logic by offloading to `SequenceEaser`. * Sweep - Simplified effect logic by offloading to `SequenceEaser`. * Wipe - Simplified effect logic by offloading to `SequenceEaser`. * Wipe - Changed default `--wipe-ease` to `IN_OUT_CIRC`. * Wipe - Removed `--wipe-ease-stepsize` CLI arg. * Colorshift - `--travel` renamed `--no-travel`. The default behavior is to travel radially. * Colorshift - Default `--travel-direction` changed from horizontal to radial. --- ### Bug Fixes (0.14.0) --- #### Effect Fixes (0.14.0) * Sweep - Fixed bug when second sweep direction is a grouping of a different length from the first direction. * Removed mistakenly added effect dev_worm. #### Application Fixes (0.14.0) * CLI now exits with a non-zero status when input files are missing, no input is provided, or no effect is specified. * CLI detects duplicate effect command registrations. ## 0.13.0 --- ### New Features (0.13.0) --- #### New Effects (0.13.0) * Thunderstorm - Rain falls across the canvas. Lightning strikes randomly around the canvas. Lightning flashes after reaching the bottom of the canvas, lighting up the text characters. Sparks explode from lightning impact. Text characters glow when lightning travels through them. * Smoke - Smoke floods the canvas, colorizing any text it passes over. --- #### New Engine Features (0.13.0) * Added `geometry.find_coords_on_rect()`, which returns coordinates along the perimeter of a rectangle given a center `Coord`, width, and height. Results are cached for performance. * Added `--terminal-background-color` to the `TerminalConfig` parser. This will enable terminal themes with background other than black to better display effects with fade in/out components. * Spanning-tree and search algorithms have been added. * PrimsSimple - Unweighted Prims * PrimsWeighted * RecursiveBacktracker * Breadthfirst * `EffectCharacter` has a new attribute `links` to support creating trees using spanning-tree algorithms. --- #### New Application Features (0.13.0) * Support for random effect selection from the command-line. Use effect named `random_effect`. Global configuration options will apply. * Support for canvas re-use. Use tte option `--reuse-canvas` to restore the cursor to the position of the prior effect canvas. * Added `terminaltexteffects` entry point. * `--no-eol` command-line option. Suppress the trailing newline character after an effect. * `--no-restore-cursor` command-line option. Do not restore cursor visibility after an effect ends. --- ### Changes (0.13.0) --- #### Effects Changes (0.13.0) * Blackhole - Initial consumption motion modified to create the appearance of an gravitational-wave propagating across the canvas. * Laseretch - New etch-pattern `algorithm` uses the link-order of a text-boundary-bound recursive backtracker algorithm. * Burn - Character ignite order is based on the link-order of a text-boundary-bound prims simple algorithm. * Pour - Changed `--movement-speed` to `--movement-speed-range` to add some variation in character falling speed. * All effects have been adjusted for visual parity at 60 fps. * All effects are up-imported into `terminaltexteffects.effects` to simplify importing to `from terminaltexteffects.effects import Burn`. --- #### Engine Changes (0.13.0) * `animation.set_appearance()` `symbol` argument signature changed from `str` to `str | None`, defaulting to the character's `input_symbol` if not provided. * `Coord` objects can be unpacked into `(column, row)` tuples for multiple assignment. * `motion.activate_path()` and `animation.activate_scene()` accept `path_id`/`scene_id` strings OR `Path`/`Scene` instances. The `Path`/`Scene` corresponding to the provided `path_id`/`scene_id` must exist or a `SceneNotFoundError`/`PathNotFoundError` will be raised. * `motion.query_path()` accepts an argument directing the action to take if a path with the given `path_id` cannot be found. The default action is to raise a `PathNotFoundError`, but this behavior can be changed to return `None`. * `animation.query_scene()` accepts an argument directing the action to take if a scene with the given `scene_id` cannot be found. The default action is to raise a `SceneNotFoundError`, but this behavior can be changed to return `None`. * Events can be registered using `path_id`/`scene_id` in place of the `Path`/`Scene` for `target` and `caller` arguments. * Frame rate reduced from 100 fps to 60 fps. * Typed argument parsing and related configuration utilities and classes have been rewritten. * Terminal distance calculations take into account the cell height/width ratio. * Completely rewrote modules and classes related to argument parsing and effect/terminal configuration handling. This eliminates the design which forced building multiple configuration objects depending on how the effect was run, and also enabled the random effect option. ### Bug Fixes (0.13.0) --- #### Engine Fixes (0.13.0) * Fixed duplicate event registrations by adding prevention logic to the EventHandler. The `register_event` method now raises a `DuplicateEventRegistrationError` when attempting to register the same event-caller-action-target combination. * Improved the `_handle_event` method docstring with comprehensive documentation. * `Scene.reset_scene()` now sets `easing_current_step` to `0`. --- #### Effect Fixes (0.13.0) * Unstable - Effect properly uses config values for reassembly/explosion speed. These were not referenced previously. --- ## 0.12.2 --- ### New Features (0.12.2) --- #### New Engine Features (0.12.2) * `--no-eol` option prevents the newline from printing after an effect has ended. --- ## 0.12.1 --- ### Bug Fixes (0.12.1) --- * Fixed bug in ArgField caused by Field init signature change in Python 3.14. This class and parent module will be removed in 0.13.0. ## 0.12.0 --- ### New Features (0.12.0) --- #### New Effects (0.12.0) * Highlight - Run a specular highlight across the text. Highlight direction, brightness, and width can be specified. * Laseretch - A laser travels across the terminal, etching characters and emitting sparks. * Sweep - Sweep across the canvas to reveal uncolored text, reverse sweep to color the text. #### New Engine Features (0.12.0) * Background color specification is supported throughout the engine. Methods which accept Color arguments expect a `ColorPair` object to specify both the foreground and background color. * New `EventHandler.Action`: `Action.RESET_APPEARANCE` will reset the character appearance to the input character with no modifications. This eliminates the need to make a `Scene` for this purpose. * Existing 8/24 bit color sequences in the input data are parsed and handled by the engine. A new `TerminalConfig` option `--existing-color-handling` is used to control how these sequences are handled. * `easing.eased_step_function()` allows easing functions to be used generically by returning a closure that produces an eased value based on the easing function and step size provided when called. * A new easing function has been added which returns a custom easing function based on cubic bezier controls. * Added custom exceptions. ### Changes (0.12.0) --- #### Effects Changes (0.12.0) * Spotlights - The maximum size of the beam is limited to the smaller of the two canvas dimensions and the minimum size is limited to 1. * Spray - Argument spray_volume is limited to 0 < n <= 1. * Colorshift - `--loop` has been renamed `--no-loop`. Looping the gradient is now default. * All effects which apply a gradient across the text build the gradient mapping based on the text dimensions regardless of the canvas size. This fixes truncated gradients where parts of the gradient map were assigned to empty coordinates. * Some effects support dynamic handling of color sequences in the input data. * Blackhole - Star characters changed to ASCII only to improve supported fonts. #### Engine Changes (0.12.0) * Frame rate timing is enforced within the `BaseEffectIterator` when accessing the `frame` property, rather than within the `Terminal` on calls to `print()`. This enables frame timing when iterating without requiring the use of the `terminal_output()` context manager. * The frame rate can be set to `0` to run without a limit. * Removed unused method Segment.get_coord_on_segment(). * Activating a Path with no segments will raise a ValueError. * `base_effect.active_characters` was refactored from a list to a set. * Bezier curves are no longer limited to two control points. Any number of control points can be specified in calls to `Path.new_waypoint()`, however, performance may suffer with large numbers of control points along unique paths. * Caching has been implemented for all geometry functions significantly improving performance in cases where many characters are traveling along the same Path. * Reorganized the most common API class imports up to the package level. * Moved the SyncMetric Enum from the Animation module top level into the Scene class. * `Scene.apply_gradient_symbols()` accepts two gradients, one for the foreground and one for the background. ### Bug Fixes (0.12.0) --- #### Effects Fixes (0.12.0) * VHSTape - Fixed glitch wave lines not appearing for some canvas/input_text size ratios. * Fireworks - Fixed launch_delay set to 0 causing an infinite loop. * Spotlights - Fixed infinite loop caused by very small beam_width_ratio values. * Overflow - Fixed effect ignoring `--final-gradient-direction` argument. #### Engine Fixes (0.12.0) * Fixed Color() objects not treating rgb colors initialized with/without the hash as equal. Ex: Color('#ffffff') and Color('ffffff') * Gradients initialized with a tuple of steps including the value 0 will raise a ValueError as expected. Ex: Gradient(Color('ff0000'), Color('00ff00'), Color('0000ff'), steps=(4,0)) * Fixed infinite loop when a new scene is created without an id and a scene has been deleted resulting in the length of the scenes dict corresponding to an existing scene id. * Fixed `Canvas` center calculations being off by one for odd widths/heights due to floor division. * Fixed `Gradient.get_color_at_fraction` rounding resulting in over-representing colors in the middle of the spectrum. * `Gradient.build_coordinate_color_mapping` signature changed to required full bounding box specification. This allows the effect to selectively build based on the text/canvas/terminal dimensions and reduces build time by by reducing the map size when possible. * Adds a call to `ansitools.dec_save_cursor_position` after each call to `ansitools.dec_restore_cursor_position` to address some terminals clearing the saved data after the restore. #### Other (0.12.0) * Fixed Canvas width/height docstrings and help output to correctly indicate 0/-1 matching terminal device/input text. --- ## 0.11.0 --- ### New Features (0.11.0) --- #### New Effects (0.11.0) * Matrix effect. Matrix digital rain effect with that ends with a final curtain and character resolve phase. #### New Engine Features (0.11.0) * Canvas is now arbitrarily sizeable. (`-1` matches the input text dimension, `0` matches the terminal dimension) * Canvas can be anchored around the terminal. * Text can be anchored around the Canvas. * Canvas new attributes `text_[left/right/top/bottom]` and `text_width/height` and `text_center_row/center_column`. * Version switch (--version, -v) ### Changes (0.11.0) --- #### Effects Changes (0.11.0) * Slice effect calculates the center of the text absolutely rather than by average line length. * Print effect no longer moves the print head to the start of each line, only back to the first character on the next line. * Many effects were updated to support anchoring within the Canvas. #### Engine Changes (0.11.0) * Performance improvements to geometry functions related to circles. (10.0.1) * Gradient's support indexing and slicing. * EffectCharacter objects no longer have a `symbol` attribute. Instead, the `Animation` class has a new attribute `current_character_visual` which provides access to a `symbol` and `color` attribute reflecting the character's current symbol and color. The prior `EffectCharacter.symbol` attribute was unreliable and represented both a formatted and unformatted symbol depending on when it was accessed. In addition, the `color` attribute is now a `Color` object and the color code has been moved into the `_color_code` attribute. * EffectCharacter objects have a new attribute `is_fill_character: bool`. --- #### Effects Fixes (0.11.0) * Fixed swarm effect not handling the first swarm (bottom right characters) resulting in missing characters in the output. (10.0.1) #### Other (0.11.0) * Keyboard Interrupts are handled gracefully while effects are animating. --- ## 0.10.1 --- ### Changes (0.10.1) --- #### Engine Changes (0.10.1) * Performance improvements to geometry functions related to circles. ### Bug Fixes (0.10.1) * Fixed swarm effect not handling the first swarm (bottom right characters) resulting in missing characters in the output. --- ## 0.10.0 --- ### New Features (0.10.0) --- #### New Effects (0.10.0) * ColorShift: Display a gradient that shifts colors across the terminal. Supports standing and traveling gradients in the following directions: vertical, horizontal, diagonal, radial. The final gradient appearance is optional using the --skips-final-gradient argument. This effect supports infinite looping when imported by setting ColorShiftConfig.cycles to 0. This functionality is not available when run from the TTE application. #### New Engine Features (0.10.0) * File input: Use the `--input-file` or `-i` option to pass a file as input. ### Changes (0.10.0) --- #### Effects Changes (0.10.0) * Added `--wave-direction` config to Waves effect. * Added additional directions to `--wipe-direction` config in Wipe effect. * VerticalSlice is now Slice and supports vertical, horizontal, and diagonal slice directions. #### Engine Changes (0.10.0) * Increased compatibility with Python versions from >=3.10 to >=3.8 * Updated type information for gradient step variables to accept a single int as well as tuple[int, ...]. * Color TypeAlias replaced with Color class. Color objects are used throughout the engine. * Renamed OutputArea to Canvas. * Changed center gradient direction to radial. ### Bug Fixes (0.10.0) --- #### Engine Fixes (0.10.0) * Characters created as `fill_characters` now adhere to `--no-color` and `--xterm-colors`. #### Other (0.10.0) * Added cookbook to the documentation and animated prompt example. * Added printing `Color` and `Gradient` objects examples to docs. --- ## 0.9.3 --- ### New Features (0.9.3) --- #### New Engine Features (0.9.3) * Added argument to the `BaseEffect.terminal_output()` context manager. `end_symbol` (default `\n`) is used to specify the symbol that will be printed after the effect completes. Set to `''` or `' '` to enable animated prompts. ### Changes (0.9.3) --- #### Engine Changes (0.9.3) * Removed unnecessary write calls for cursor positioning on every frame. * Separated functionality related to cursor positioning and frame timing out of `Terminal.print()` and into `Terminal.enforce_framerate()`, `Terminal.prep_canvas()` and `Terminal.move_cursor_to_top()`. ### Bug Fixes (0.9.3) --- #### Engine Fixes (0.9.3) * Fixed the canvas of an effect being 1 row less than specified via the `Terminal.terminal_height` attribute. This was caused by mixing use of `print()` and `sys.stdout.write()`. --- ## 0.9.1 --- ### New Features (0.9.1) --- #### New Engine Features (0.9.1) * Terminal dimension auto-detection supports automatically detecting a single dimensions. ### Changes (0.9.1) --- #### Effects Changes (0.9.1) * All effects have been updated to use the new `update()` method and `frame` property of `base_effect.BaseEffectIterator`. See Engine Changes for more info. #### Engine Changes (0.9.1) * `base_effect.BaseEffectIterator` now has an `update()` method which calls the `tick()` method of all active characters and manages the `active_characters` list. * `base_effect.BaseEffectIterator` has a `frame` property which calls `Terminal.get_formatted_output_string()` and returns the string. * `TerminalConfig.terminal_dimensions` has been split into `TerminalConfig.terminal_width` and `TerminalConfig.terminal_height` to simplify the command line argument for dimensions and make it more obvious which dimension is being specified when interacting with `effect.terminal_config`. #### Other Changes (0.9.1) ### Bug Fixes (0.9.1) --- #### Engine Fixes (0.9.1) * Fixed division by zero error when the terminal height was set to 1. ## 0.9.0 --- ### New Features (0.9.0) --- #### New Engine Features (0.9.0) * Linear easing function added. ### Changes (0.9.0) --- #### Other Changes (0.9.0) * Major re-organization of the codebase and significant documentation changes and additions. ## 0.8.0 --- ### New Features (0.8.0) --- #### New Engine Features (0.8.0) * Library support: TTE effects are now importable. All effects are iterators that return strings for each frame of the output. See README for more information. * Terminal: New terminal argument (--terminal-dimensions) allows specification of the terminal dimensions without relying on auto-detection. Especially useful in cases where TTE is being used as a library in non-terminal or TUI contexts. * Terminal: New terminal argument (--ignore-terminal-dimensions) causes the canvas dimensions to match the input data dimensions without regard to the terminal. ### Changes (0.8.0) --- #### Effects Changes (0.8.0) * Scattered. Holds scrambled text at the start for a few frames. * Scattered. Lowered default movement-speed from 0.5 to 0.3. #### Engine Changes (0.8.0) * graphics.Gradient ```__iter___()``` refactored to return a generator. No longer improperly implements the iterator protocol by resetting index in ```___iter__()```. * Terminal: Argument --animation-rate is now --frame-rate and is specified as a target frames per second. * Terminal: Argument --no-wrap is now --wrap-text and defaults to False. * Terminal: If a terminal object is instantiated without a TerminalConfig passed, it will instantiate a new TerminalConfig. * Terminal: Terminal.get_formatted_output_string() will return a string representing the current frame. * Terminal: Terminal.print() will print the frame to the terminal and handle cursor position. The optional argument (enforce_frame_rate: bool = True) determines if the frame rate set at Terminal.config.frame_rate is enforced. If set to False, the print will occur without delay. * New argument validator for terminal dimensions (argvalidators.TerminalDimensions). * New module base_effect.py: * base_effect.BaseEffect: * This is an abstract class which forms the base iterable for all effects and provides the terminal_output() context manager. * base_effect.BaseEffectIterator: * This is an abstract class which provides the functionality to enable iteration over effects. ### Bug Fixes (0.8.0) --- #### Engine Fixes (0.8.0) * Fixed Argfield nargs type from str to str | int. * Implemented custom formatter into argsdataclass.py argument parsing. ## 0.7.0 --- ### New Features #### New Effects (0.7.0) * Beams. Light beams travel across the canvas and illuminate the characters behind them. * Overflow. The input text is scrambled by row and repeated randomly, scrolling up the terminal, before eventually displaying in the correct order. * OrbitingVolley. Characters fire from launcher which orbit canvas. * Spotlights. Spotlights search the text area, illuminating characters, before converging in the center and expanding. #### New Engine Features (0.7.0) * Gradients now support multiple step specification to control the distance between each stop pair. For example: graphics.Gradient(RED, BLUE, YELLOW, steps=(2,5)) results in a spectrum of RED -> (1 step) -> BLUE -> (4 steps) -> YELLOW * graphics.Gradient.get_color_at_fraction(fraction: float) will return a color at the given fraction of the spectrum when provided a float between 0 and 1, inclusive. This can be used to match the color to a ratio/ For example, the character height in the terminal. * graphics.Gradient.build_coordinate_color_mapping() will map gradient colors to coordinates in the terminal and supports a Gradient.Direction argument to enable gradients in the following directions: horizontal, vertical, diagonal, center * graphics.Gradient, if printed, will show a colored spectrum and the description of its stops and steps. * The Scene class has a new method: apply_gradient_to_symbols(). This method will iterate over a list of symbols and apply the colors from a gradient to the symbols. A frame with the symbol will be added for each color starting from the last color used in the previous symbol, up to the the index determined by the ratio of the current symbol's index in the symbols list to the total length of the list. This method allows scenes to automatically create frames from a list of symbols and gradient of arbitrary length while ensuring every symbol and color is displayed. * On instantiation, Terminal creates EffectCharacters for every coordinate in the canvas that does not have an input character. These EffectCharacters have the symbol " " and are stored in Terminal._fill_characters as well as added to Terminal.character_by_input_coord. * argvalidators.IntRange will validate a range specified as "int-int" and return a tuple[int,int]. * argvalidators.FloatRange will validate a range of floats specified as "float-float" and return a tuple[float, float]. * character.animation.set_appearance(symbol, color) will set the character symbol and color directly. If a Scene is active, the appearance will be overwritten with the Scene frame on the next call to step_animation(). This method is intended for the occasion where a full scene isn't needed, or the appearance needs to be set based on conditions not compatible with Scenes or the EventHandler. For example, setting the color based on the terminal row. * Terminal.CharacterSort enums moved to Terminal.CharacterGroup, Terminal.CharacterSort is now used for sorting and return a flat list of characters. * Terminal.CharacterSort has new sort methods, TOP_TO_BOTTOM_LEFT_TO_RIGHT, TOP_TO_BOTTOM_RIGHT_TO_LEFT, BOTTOM_TO_TOP_LEFT_TO_RIGHT, BOTTOM_TO_TOP_RIGHT_TO_LEFT, OUTSIDE_ROW_TO_MIDDLE, MIDDLE_ROW_TO_OUTSIDE * New Terminal.CharacterGroup options, CENTER_TO_OUTSIDE_DIAMONDS and OUTSIDE_TO_CENTER_DIAMONDS * graphics.Animation.adjust_color_brightness(color: graphics.Color, brightness: float) will convert the color to HSL, adjust the brightness to the given level, and return an RGB hex string. * CTRL-C keyboard interrupt during a running effect will exit gracefully. * geometry.find_coords_in_circle() has been rewritten to find all coords which fall in an ellipse. The result is a circle due to the height/width ratio of terminal cells. This function now finds all terminal coordinates within the 'circle' rather than an arbitrary subset. * All command line arguments are typed allowing for more easily defined and tested effect args. ### Changes (0.7.0) #### Effects Changes (0.7.0) * All effects have been updated to use the latest API calls for improved performance. * All effects support gradients for the final appearance. * All effects support gradient direction. * All effects have had their default colors refreshed. * ErrorCorrect swap-delay lowered and error-pairs specification changed to percent float. * Rain effect supports character specification for rain drops and movement speed range for the rain drop falling speed. * Print effect uses the row final gradient color for the print head color. * RandomSequence effect accepts a starting color and a speed. * Rings effect prepares faster. Ring colors are set in order of appearance in the ring-colors argument. Ring spin speed is configurable. Rings with less than 25% visible characters based on radius are no longer generated. Ring gap is set as a percent of the smallest canvas dimension. * Scattered effect gradient progresses from the first color to the row color. * Spray effect spray-volume is specified as a percent of the total number of characters and movement speed is a range. * Swarm effect swarm focus points algorithm changed to reduce long distances between points. * Decrypt effect supports gradient specification for plaintext and multiple color specification for ciphertext. * Decrypt effect has a --typing-speed arg to increase the speed of the initial text typing effect. * Decrypt effect has had the decrypting speed increased. * Beams effect uses Animation.adjust_color_brightness() to lower the background character brightness and shows the lighter color when the beam passes by. * Crumble effect uses Animation.adjust_color_brightness() to set the weak and dust colors based on the final gradient. * Fireworks effect launch_delay argument has a +/- 0-50% randomness applied. * Bubbles effect --no-rainbow changed to --rainbow and default set to False. * Bubbles effect --bubble-color changed to --bubble-colors. Bubble color is randomly chosen from the colors unless --rainbow is used. * Burn effect burns faster with some randomness in speed. * Burn effect final color fades in from the burned color. * Burn effect characters are shown prior to burning using a starting_color arg. * Pour effect has a --pour-speed argument. #### Engine Changes (0.7.0) * Geometry related methods have been removed from the motion class. They are now located at terminaltexteffects.utils.geometry as separate functions. * The Coord() object definition has been moved from the motion module to the geometry module. * Terminal.add_character() takes a geometry.Coord() argument to set the character's input_coordinate. * EffectCharacters have a unique ID set by the Terminal on instantiation. As a result, all EffectCharacters should be created using Terminal.add_character(). * EffectCharacters added by the effect are stored in Terminal._added_characters. * Retrieving EffectCharacters from the terminal should no longer be done via accessing the lists of characters [_added_characters, _fill_characters, _input_characters], but should be retrieved via Terminal.get_characters() and Terminal.get_characters_sorted(). * Setting EffectCharacter visibility is now done via Terminal.set_character_visibility(). This enables the terminal to keep track of all visible characters without needing to iterate over all characters on every call to _update_terminal_state(). * EventHandler.Action.SET_CHARACTER_VISIBILITY_STATE has been removed as visibility state is handled by the Terminal. To enable visibility state changes through the event system, use a CALLBACK action with target EventHandler.Callback(terminal.set_character_visibility, True/False). * geometry.find_coords_on_circle() num_points arg renamed to points_limit and new arg unique: bool, added to remove any duplicate Coords. * The animation rate argument (-a, --animation-rate) has been removed from all effects and is handled as a terminal argument specified prior to the effect name. * argtypes.py has been renamed argvalidators.py and all functions have been refactored into classes with a METAVAR class member and a type_parser method. * easing.EasingFunction type alias used anywhere an easing function is accepted. * Exceptions raised are no longer caught in a except clause. Only a finally clause is used to restore the cursor. Tracebacks are useful. #### Other Changes (0.7.0) * More tests have been added. ### Bug Fixes (0.7.0) #### Effects Fixes (0.7.0) * All effects with command line options that accept variable length arguments which require at least 1 argument will present an error message when the option is called with 0 arguments. #### Engine Fixes (0.7.0) * Fixed division by zero error in geometry.find_coord_at_distance() when the origin coord and the target coord are the same. * Fixed gradient generating an extra color in the spectrum when the initial color pair was repeated. Ex: Gradient('ffffff','000000','ffffff','000000, steps=5) would result in the third color 'ffffff' being added to the spectrum when it was already present as the end of the generation from '000000'->'ffffff'. ## 0.6.0 ### New Features (0.6.0) #### New Effects (0.6.0) * Print. Lines are printed one at a time following a print head. Print head performs line feed, carriage return. * BinaryPath. Characters are converted into their binary representation. These binary groups travel to their input coordinate and collapse into the original character symbol. * Wipe. Performs directional wipes with an optional trailing gradient. * Slide. Slides characters into position from outside the terminal view. Characters can be grouped by column, row, or diagonal. Groups can be merged from opposite directions or slide from the same direction. * SynthGrid. Creates a gradient colored grid in which blocks of characters dissolve into the input text. #### New Engine Features (0.6.0) * Terminal.get_character() method accepts a Terminal.CharacterSort argument to easily retrieve the input characters in groups sorted by various directions, ex: Terminal.CharacterSort.COLUMN_LEFT_TO_RIGHT * Terminal.add_character() method allows adding characters to the effect that are not part of the input text. These characters are added to a separate list (Terminal.non_input_characters) in terminal to allow for iteration over Terminal.characters and adding new characters based on the input characters without modifying the Terminal.characters list during iteration. The added characters are handled the same as input characters by the Terminal. * New EventHandler Action, Callback. The Action target can be any callable and will pass the character as the first argument, followed by any additional arguments provided. Uses new EventHandler.Callback type with signature EventHandler.Callback(typing.Callable, *args) * graphics.Gradient() objects specified with a single color will create a list of the single color with length *steps*. This enables gradients to be specified via command line arguments while supporting an arbitrary number of colors > 0, without needing to perform any checking in the effect logic. ### Changes (0.6.0) ### Effects Changes (0.6.0) * Rowslide, Columnslide, and Rowmerge have been replaced with a single effect, Slide. * Many classic effects now support gradient specification which includes stops, steps, and frames to enable greater customization. * Randomsequence effect supports gradient specification. * Scattered effect supports gradient specification. * Expand effect supports gradient specification. * Pour effect now has a back and forth pouring animation and supports gradient specification. #### Engine Changes (0.6.0) * Terminal._update_terminal_state() refactored for improved performance. * EffectCharacter.tick() will progress motion and animation by one step. This solves the problem of running Animation.step_animation() before Motion.move() and desyncing Path synced animations. * EffectCharacter.is_active has been renamed to EffectCharacter.is_visible. * EffectCharacter.is_active() can be used to check if motion/animation is in progress. * graphics.Animation.new_scene(), motion.Motion.new_path(), and Path.new_waypoint() all support automatic IDs. If no ID is provided a unique ID is automatically generated. ### Bug Fixes (0.6.0) * Fixed rare division by zero error in Path.step() when the final segment has a distance of zero and the distance to travel exceeds the total distance of the Path. * Fixed effects not respecting --no-color argument. ## 0.5.0 ### New Features (0.5.0) * New effect, Vhstape. Lines of characters glitch left and right and lose detail like an old VHS tape. * New effect, Crumble. Characters lose color and fall as dust before being vacuumed up and rebuilt. * New effect, Rings. Characters are dispersed throughout the canvas and form into spinning rings. * motion.Motion.chain_paths(list[Paths]) will automatically register Paths with the EventHandler to create a chain of paths. Looping is supported. * motion.Motion.find_coords_in_rect() will return a random selection of coordinates within a rectangular area. This is faster than using find_coords_in_circle() and should be used when the shape of the search area isn't important. * Terminal.Canvas.coord_in_canvas() can be used to determine if a Coord is in the canvas. * Paths have replaced Waypoints as the motion target specification object. Paths group Waypoints together and allow for easing motion and animations across an arbitrary number of Waypoints. Single Waypoint Paths are supported and function the same as Waypoints did previously. Paths can be looped with the loop argument. * Quadratic and Cubic bezier curves are supported. Control points are specified in the Waypoint object signature. When a control point is specified, motion will be curved from the prior Waypoint to the Waypoint with the control point, using the control point to determine the curve. Curves are supported within Paths. * New EventHandler.Event PATH_HOLDING is triggered when a Path enters the holding state. * New EventHandler.Action SET_CHARACTER_ACTIVATION_STATE can be used to modify the character activation state based on events. * New EventHandler.Action SET_COORDINATE can be used to set the character's current_coordinate attribute. * Paths have a layer attribute that can be used to automatically adjust the character's layer when the Path is activated. Has no effect when Path.layer is None, defaults to None. * New EventHandler.Events SEGMENT_ENTERED and SEGMENT_EXITED. These events are triggered when a character enters or exits a segment in a Path. The segment is specified using the end Waypoint of the segment. These events will only be called one time for each run through the Path. Looping Paths will reset these events to be called again. ### Changes (0.5.0) * graphics.Animation.random_color() is now a static method. * motion.Motion.find_coords_in_circle() now generates 7*radius coords in each inner-circle. * BlackholeEffect uses chain_paths() and benefits from better circle support for a much improved blackhole animation. * BlackholeEffect singularity Paths are curved towards center lines. * EventHandler.Event.WAYPOINT_REACHED removed and split into two events, PATH_HOLDING and PATH_COMPLETE. * EventHandler.Event.PATH_COMPLETE is triggered when the final Path Waypoint is reached AND holding time reaches 0. * Fireworks effect uses Paths and curves to create a more realistic firework explosion. * Crumble effect uses control points to create a curved vacuuming phase. * graphics.Gradient accepts an arbitrary number of color stops. The number of steps applies between each color stop. * motion.find_coords_in_circle() and motion.find_coords_in_rect() no longer take a num_points argument. All points in the area are returned. ### Bug Fixes (0.5.0) * Fixed looping animations when synced to Path not resetting properly. ## 0.4.3 ### Changes (0.4.3) * blackhole radius is based on the canvas size, not the input text size. ## 0.4.2 ### Changes (0.4.2) * motion.Motion.find_points_on_circle and motion.Motion.find_points_in_circle now account for the terminal character height/width ratio to return points that more closely approximate a circle. All effects which use these functions have been updated to account for this change. ## 0.4.1 ### Changes (0.4.1) * Updated documentation ## 0.4.0 ### New Features (0.4.0) * Waves effect. A wave animation is played over the characters. Wave colors and final colors are configurable. * Blackhole effect. Characters spawn scattered as a field of stars. A blackhole forms and consumes the stars then explodes the characters across the screen. Characters then 'cool' and ease into position. * Swarm effect. Characters a separated into swarms and fly around the canvas before landing in position. * Animations support easing functions. Easing functions are applied to Scenes using Scene.ease = easing_function. * Canvas has a center attribute that is the center Coord of the canvas. * Terminal has a random_coord() method which returns a random coordinate. Can specify outside the canvas. ### Changes (0.4.0) * Animation and Motion have been refactored to use direct Scene and Waypoint object references instead of string IDs. * base_character.EventHandler uses Scene and Waypoint objects instead of string IDs. * graphics.GraphicalEffect renamed to CharacterVisual * graphics.Sequence renamed to Frame * Animation methods for created Scenes and adding frames to scenes have been refactored to return Scene objects and expose terminal modes, respectively. * Easing function api has been simplified. Easing function callables are used directly rather than Enums and function maps. * Layer is set on the EffectCharacter object instead of the motion object. The layer is modified through the EventHandler to allow finer control over the layer. * Animations not longer sync to specific waypoints, rather, they sync to the progress of the character towards the active waypoint. * Animations synced to waypoint progress can now sync to either the distance progression or the step progression. * Motion methods which utilize coordinates now use Coord objects rather than tuples. * Motion has methods for finding coordinates on a circle and in a circle. ### Bug Fixes (0.4.0) * Fixed Gradient creating two more steps than specified. * Fixed waypoint synced animation index out of range error. ## 0.3.1 ### New Features (0.3.1) * Bouncyballs effect. Balls drop from the top of the canvas and bounce before settling into position. A gradient is used to transition to the final color after the ball has landed. Random colors are used for balls unless specified. * Unstable effect. Spawn characters jumbled, explode to the edge of the canvas, then reassemble them in the correct layout. * Bubble effect. Characters are formed into bubbles and fall down the screen before popping. * Middleout effect. Characters start as a single character in the center of the canvas. A row or column is expanded in the center of the screen, then the entire output is expanded from this row/column. Expansion from row/column is determined by the --expand-direction argument. * Errorcorrect effect. Some characters spawn with their location swapped with another character. The characters then move, in pairs, to their correct location following an animation. * --no-wrap argument prevents line wrapping. * --tab-width argument can be used to specify the number of spaces used in place of tab characters. Defaults to 4. * New Events for WaypointActivated and SceneActivated. * New Event Actions for DeactivateWaypoint and DeactivateScene. * Scenes can be synced to Waypoint progress. The scene will progress in-line with the character's steps towards the waypoint. * Waypoints now have a layer attribute. Characters are drawn in ascending layer order. While a character has a waypoint active, that waypoint's layer is used. Otherwise, the character is drawn in layer 0. ### Changes (0.3.1) * Added Easing Functions help output for fireworks effect. * Updated spray effect help output. * Removed shootingstar effect. It was not particularly interesting. * Coord type is now hashable and frozen. * Waypoints are hashable. Can be compared for equality based on row, col pair. * Scenes can be compared for equality based on id. * Terminal maintains an input_coord tuple[row, col] -> EffectCharacter map called character_by_input_coord. * The terminal cursor is now hidden during the effect. * The find_points_on_circle method in the motion module is now a static method. * Terminal.Canvas has center_row and center_column attributes. * Added layers to effects. ### Bug Fixes (0.3.1) * Fixed animating_chars filter in effect_template to properly remove completed characters. * Initial symbol assignment when activating a scene no longer increases played_frames count. * Waypoints and Animations completed are deactivated to prevent repeated event triggering. * Fixed step_animation in graphics module handling of looping animations. It will no longer deactivate the animation. ## 0.2.1 ### New Features (0.2.1) * Added explode distance argument to fireworks effect * Added random_color function to graphics module ### Changes (0.2.1) ### Bug Fixes (0.2.1) * Fixed inactive characters in expand effect. terminaltexteffects-release-0.15.0/LICENSE000066400000000000000000000020601517776150200203110ustar00rootroot00000000000000MIT License Copyright (c) [2023] [ChrisBuilds] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. terminaltexteffects-release-0.15.0/README.md000066400000000000000000000365321517776150200205760ustar00rootroot00000000000000

TTE

Terminal Text Effects

Inline Visual Effects in the Terminal

[![PyPI - Version](https://img.shields.io/pypi/v/terminaltexteffects?style=flat&color=green)](http://pypi.org/project/terminaltexteffects/ "![PyPI - Version](https://img.shields.io/pypi/v/terminaltexteffects?style=flat&color=green)") ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/terminaltexteffects) [![Python Bytes](https://img.shields.io/badge/Python_Bytes-377-D7F9FF?logo=applepodcasts&labelColor=blue)](https://youtu.be/eWnYlxOREu4?t=1549) ![License](https://img.shields.io/github/license/ChrisBuilds/terminaltexteffects) ## Table Of Contents * [About](#tte) * [Requirements](#requirements) * [Installation](#installation) * [Usage (Application)](#application-quickstart) * [Usage (Library)](#library-quickstart) * [Effect Showcase](#effect-showcase) * [Latest Release Notes](#latest-release-notes) * [License](#license) ## TTE ![thunderstorm_demo](https://github.com/user-attachments/assets/7678e1d2-df49-497e-bccd-87b933ece981) TerminalTextEffects (TTE) is a terminal visual effects engine. TTE can be installed as a system application to produce effects in your terminal, or as a Python library to enable effects within your Python scripts/applications. TTE includes a growing library of built-in effects which showcase the engine's features. These features include: * Xterm 256 / RGB hex color support * Complex character movement via Paths, Waypoints, and motion easing, with support for bezier curves. * Complex animations via Scenes with symbol/color changes, layers, easing, and Path synced progression. * Variable stop/step color gradient generation. * Event handling for Path/Scene state changes with custom callback support and many pre-defined actions. * Effect customization exposed through a typed effect configuration dataclass that is automatically handled as CLI arguments. * Runs inline, preserving terminal state and workflow. ## Requirements TerminalTextEffects is written in Python and does not require any 3rd party modules. Terminal interactions use standard ANSI terminal sequences and should work in most modern terminals. ## Installation
UV Install Tool Run ```uv tool run terminaltexteffects -h``` Application Install ```uv tool install terminaltexteffects``` Library Install ```uv add terminaltexteffects```
Pip Install Application Install ```pipx install terminaltexteffects``` Library Install ```pip install terminaltexteffects```
Nix (flakes) Add it as an input to a flake: ```nix inputs = { terminaltexteffects.url = "github:ChrisBuilds/terminaltexteffects/" } ```` Create a shell with it: ```nix nix shell github:ChrisBuilds/terminaltexteffects/ ``` Or run it directly: ```nix echo 'terminaltexteffects is awesome' | nix run github:ChrisBuilds/terminaltexteffects/ -- beams ```
Nix (classic) Fetch the source and add it to, e.g. your shell: ```nix let pkgs = import {}; tte = pkgs.callPackage (pkgs.fetchFromGitHub { owner = "ChrisBuilds"; repo = "terminaltexteffects"; rev = ""; hash = ""; # Build first, put proper hash in place }) {}; in pkgs.mkShell { packages = [tte]; } ```
## Usage View the [Documentation](https://chrisbuilds.github.io/terminaltexteffects/) for a full installation and usage guide. ### Application Quickstart #### Options
TTE Command Line Options ```markdown options: -h, --help show this help message and exit --input-file, -i INPUT_FILE File to read input from --version, -v show program's version number and exit --print-completion {bash,zsh} Print a shell completion script for the requested shell and exit. --random-effect, -R Randomly select an effect to apply --seed SEED Seed to use for random effect selection --include-effects INCLUDE_EFFECTS [INCLUDE_EFFECTS ...] Space-separated list of Effects to include when randomly selecting an effect --exclude-effects EXCLUDE_EFFECTS [EXCLUDE_EFFECTS ...] Space-separated list of Effects to exclude when randomly selecting an effect --tab-width (int > 0) Number of spaces to use for a tab character. --xterm-colors Convert any colors specified in 24-bit RGB hex to the closest 8-bit XTerm-256 color. --no-color Disable all colors in the effect. --terminal-background-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) The background color of your terminal. Used to determine the appropriate color for fade-in/out within effects. --existing-color-handling {always,dynamic,ignore} Specify handling of existing ANSI SGR color sequences in the input data. Supported input colors include 3-bit, 4-bit, 8-bit, and 24-bit foreground/background sequences. 'always' will always use the input colors, ignoring any effect specific colors. 'dynamic' will leave it to the effect implementation to apply input colors. 'ignore' will ignore the colors in the input data. Default is 'ignore'. --wrap-text Wrap text wider than the canvas width. --frame-rate FRAME_RATE Target frame rate for the animation in frames per second. Set to 0 to disable frame rate limiting. Defaults to 60. --canvas-width int >= -1 Canvas width, set to an integer > 0 to use a specific dimension, use 0 to match the terminal width, or use -1 to match the input text width. Defaults to -1. --canvas-height int >= -1 Canvas height, set to an integer > 0 to use a specific dimension, use 0 to match the terminal height, or use -1 to match the input text height. Defaults to -1. --anchor-canvas {sw,s,se,e,ne,n,nw,w,c} Anchor point for the canvas. The canvas will be anchored in the terminal to the location corresponding to the cardinal/diagonal direction. Defaults to 'sw'. --anchor-text {n,ne,e,se,s,sw,w,nw,c} Anchor point for the text within the Canvas. Input text will be anchored in the Canvas to the location corresponding to the cardinal/diagonal direction. Defaults to 'sw'. --ignore-terminal-dimensions Ignore the terminal dimensions and utilize the full Canvas beyond the extents of the terminal. Useful for sending frames to another output handler. --reuse-canvas Do not create new rows at the start of the effect. The cursor will be moved up the number of rows present in the input text in an attempt to re-use the canvas. This option works best when used in a shell script. If used interactively with prompts between runs, the result is unpredictable. --no-eol Suppress the trailing newline emitted when an effect animation completes. --no-restore-cursor Do not restore cursor visibility after the effect. 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} 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. blackhole Characters are consumed by a black hole and explode outwards. bouncyballs Characters are bouncy balls falling from the top of the canvas. bubbles Characters are formed into bubbles that float down and pop. burn Burns vertically in the canvas. colorshift Display a gradient that shifts colors across the terminal. crumble Characters lose color and crumble into dust, vacuumed up, and reformed. 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. 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. matrix Matrix digital rain effect. middleout Text expands in a single row or column in the middle of the canvas then out. orbittingvolley Four launchers orbit the canvas firing volleys of characters inward to build the input text from the center out. overflow Input text overflows and scrolls the terminal in a random order until eventually appearing ordered. pour Pours the characters into position from the given direction. print Lines are printed one at a time following a print head. Print head performs line feed, carriage return. rain Rain characters from the top of the canvas. randomsequence Prints the input data in a random sequence. rings Characters are dispersed and form into spinning rings. scattered Text is scattered across the canvas and moves into position. slice Slices the input in half and slides it into place from opposite directions. slide Slide characters into view from outside the terminal. smoke Smoke floods the canvas colorizing any characters it crosses. spotlights Spotlights search the text area, illuminating characters, before converging in the center and expanding. spray Draws the characters spawning at varying rates from a single point. swarm Characters are grouped into swarms and move around the terminal before settling into position. sweep Sweep across the canvas to reveal uncolored text, reverse sweep to color the text. synthgrid Create a grid which fills with characters dissolving into the final text. thunderstorm Create a thunderstorm in the terminal. unstable Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them in the correct layout. vhstape Lines of characters glitch left and right and lose detail like an old VHS tape. waves Waves travel across the terminal leaving behind the characters. wipe Wipes the text across the terminal to reveal characters. Ex: ls -a | tte decrypt --typing-speed 2 --ciphertext-colors 008000 00cb00 00ff00 --final-gradient-stops eda000 --final-gradient-steps 12 --final-gradient-direction vertical ```
```cat your_text | tte [options]``` OR ```cat your_text | python -m terminaltexteffects [options]``` * Use ``` -h``` to view options for a specific effect, such as color or movement direction. * Ex: ```tte decrypt -h``` * Randomly select an effect with `--random-effect`/`-R`. * Use `--seed` to make the random choice repeatable. * Use `--include-effects` or `--exclude-effects` to limit the random selection pool. * Generate shell completions with `tte --print-completion bash` or `tte --print-completion zsh`. * Bash: `eval "$(tte --print-completion bash)"` * Zsh: `eval "$(tte --print-completion zsh)"` * To enable completions for future shells, add the relevant command above to your shell startup file such as `~/.bashrc` or `~/.zshrc`. * If you add or remove custom effect plugins from `~/.config/terminaltexteffects/effects`, regenerate the completion script so the effect list stays current. * Add custom effect modules to `${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. * TTE is not a full terminal emulator, but it parses common fetch-style input including SGR foreground/background colors, cursor movement CSI sequences, carriage returns, and selected DEC private mode toggles. * Unsupported control sequences fail fast with an error so they do not leak into the rendered animation. For more information, view the [Application Usage Guide](https://chrisbuilds.github.io/terminaltexteffects/appguide/). ### Library Quickstart All effects are iterators which return a string representing the current frame. Basic usage is as simple as importing the effect, instantiating it with the input text, and iterating over the effect. ```python from terminaltexteffects.effects import Rain effect = Rain("your text here") for frame in effect: # do something with the string ... ``` In the event you want to allow TTE to handle the terminal setup/teardown, cursor positioning, and animation frame rate, a terminal_output() context manager is available. ```python from terminaltexteffects.effects import Rain effect = Rain("your text here") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` For more information, view the [Library Usage Guide](https://chrisbuilds.github.io/terminaltexteffects/libguide/). ### Effect Showcase Note: Below you'll find a subset of the built-in effects. View all of the effects and related information in the [Effects Showroom](https://chrisbuilds.github.io/terminaltexteffects/showroom/).
Effects Demos #### Beams ![beams_demo](https://github.com/ChrisBuilds/terminaltexteffects/assets/57874186/6bb98dac-688e-43c9-96aa-1a45f451d4cb) #### Burn ![burn_demo](https://github.com/user-attachments/assets/b2e6ad48-15f7-4363-b281-d165b91403c4) #### Decrypt ![decrypt_demo](https://github.com/ChrisBuilds/terminaltexteffects/assets/57874186/36c23e70-065d-4316-a09e-c2761882cbb3) #### LaserEtch ![laseretch_demo](https://github.com/user-attachments/assets/b65b57b6-3a02-411e-b9f2-23ec4572c328) #### Matrix ![matrix_demo](https://github.com/ChrisBuilds/terminaltexteffects/assets/57874186/0f6ddfd9-5e78-4de2-a187-7950b1e5b9d0) #### Spotlights ![spotlights_demo](https://github.com/ChrisBuilds/terminaltexteffects/assets/57874186/4ab93725-0c8a-4bdf-af91-057338f4e007) #### VHSTape ![vhstape_demo](https://github.com/ChrisBuilds/terminaltexteffects/assets/57874186/720abbf4-f97d-4ce9-96ee-15ef973488d2)
## Latest Release Notes Visit the [ChangeBlog](https://chrisbuilds.github.io/terminaltexteffects/changeblog/changeblog/) for release write-ups and the [CHANGELOG](./CHANGELOG.md) for the current full release history. ## License Distributed under the MIT License. See [LICENSE](https://github.com/ChrisBuilds/terminaltexteffects/blob/main/LICENSE.md) for more information. terminaltexteffects-release-0.15.0/default.nix000066400000000000000000000011771517776150200214600ustar00rootroot00000000000000{ lib, python312Packages, }: let hatchlingDef = with builtins; (fromTOML (readFile ./pyproject.toml)).project; name = hatchlingDef.name; in python312Packages.buildPythonApplication { pname = name; inherit (hatchlingDef) version; src = builtins.path { path = ./.; name = name; }; pyproject = true; nativeBuildInputs = [ python312Packages.hatchling ]; meta = { inherit (hatchlingDef) description; maintainers = hatchlingDef.authors; homepage = "https://github.com/ChrisBuilds/${name}"; license = lib.licenses.mit; mainProgram = "tte"; }; } terminaltexteffects-release-0.15.0/docs/000077500000000000000000000000001517776150200202365ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/appguide.md000066400000000000000000000063311517776150200223610ustar00rootroot00000000000000# Application Guide When used as a system application, TerminalTextEffects will produce animations on text passed to stdin or through the `-i` argument. Passing data via STDIN to TTE occurs via pipes or redirection. ## Invocation Examples === "Piping" ```bash title="Piping directory listing output through TTE" ls -latr | tte slide ``` === "Redirection" ```bash title="Redirecting a file through TTE" tte slide < your_file ``` === "File Input" ```bash title="Passing a file argument to TTE" tte -i path/to/file slide ``` ## Configuration TTE has many global terminal configuration options as well as effect-specific configuration options available via command-line arguments. Terminal configuration options should be specified prior to providing the effect name. The basic format is as follows: ```bash title="TTE usage syntax" tte [global_options] [effect_options] ``` Using the `-h` argument in place of the global_options or effect_options will produce either the global or effect help output, respectively. Shell completions are also available for bash and zsh: ```bash title="Generate shell completions" eval "$(tte --print-completion bash)" ``` ```bash title="Generate zsh completions" eval "$(tte --print-completion zsh)" ``` To enable completions for future shells, add the relevant command to your `~/.bashrc` or `~/.zshrc`. If you add or remove custom effect plugins from `~/.config/terminaltexteffects/effects`, regenerate the completion script so the available effect names stay in sync. TTE can randomly select an effect with `--random-effect`/`-R`. Use `--seed` to make that selection repeatable, or limit the pool with `--include-effects` and `--exclude-effects`: ```bash title="Random effect selection" 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. The example below will pass the output of the `ls` command to TTE with the following options: * *Global* options: - Text will be wrapped if wider than the terminal. - Tabs will be replaced with 4 spaces. * *Effect* options: - Use the [slide](./effects/slide.md) effect. - Merge the groups. - Set movement-speed to 2. - Group by column. ```bash title="TTE argument specification example" ls | tte --wrap-text --tab-width 4 slide --merge --movement-speed 2 --grouping column ``` ## Example Usage Animate fetch output on shell launch using screenfetch: ```bash title="Shell Fetch" screenfetch -N | tte slide --merge ``` ![fetch_demo](./img/application_demos/fetch_example.gif) !!! note TTE is not a full terminal emulator, but it does parse common fetch-style input. Supported input includes SGR foreground/background colors, common cursor movement CSI sequences, carriage returns, and selected DEC private mode toggles for cursor visibility and line wrapping. Unsupported control sequences still fail fast with an error so they do not leak into the rendered animation. terminaltexteffects-release-0.15.0/docs/changeblog/000077500000000000000000000000001517776150200223275ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/changeblog/changeblog.md000066400000000000000000000012151517776150200247410ustar00rootroot00000000000000# ChangeBlog Home The ChangeBlog features explanatory documentation for each release. Blog entries will explain the major features of a given release and provide in-depth explanations of TTE Engine features. ## Release Entries * [0.15.0 - Quality of Life Catch-Up](./changeblog_0.15.0.md) * [0.14.0 - Easier Easing](./changeblog_0.14.0.md) * [0.13.0 - Still Alive](./changeblog_0.13.0.md) * [0.12.0 - Color Parsing, Background Color, more Easing](./changeblog_0.12.0.md) * [0.11.0 - Matrix Effect and Canvas+Text Anchoring](./changeblog_0.11.0.md) * [0.10.0 - ColorShift, Canvas, Better Compatibility, and more Customization](./changeblog_0.10.0.md) terminaltexteffects-release-0.15.0/docs/changeblog/changeblog_0.10.0.md000066400000000000000000000131431517776150200255400ustar00rootroot00000000000000# 0.10.0 (ColorShift) ## Release 0.10.0 ### ColorShift, Canvas, Better Compatibility, and more Customization This release comes with a shiny new effect [ColorShift](../showroom.md#colorshift), increased customization for [Waves](../showroom.md#waves) and [Wipe](../showroom.md#wipe) as well as renaming the VerticalSlice effect to [Slice](../showroom.md#slice) with added slice directions. Engine changes includes renaming the OutputArea class to [Canvas](../engine/terminal/canvas.md) and building out the [Color](../engine/utils/color.md) class from what was previously a TypeAlias. In addition, to increase compatibility with older version of Python (back to 3.8), a few minor changes were implemented including using annotations and removing the occasional use of modern syntax where unnecessary. Additionally, input can be passed to TTE using the new `-i` argument. Finally, a few new pages were added to the docs. Check out the [Cookbook](../cookbook.md) for interesting examples using the TTE library and the [ChangeBlog](changeblog.md) (you're already here) for a more friendly breakdown of each release. ### ColorShift The new [ColorShift](../showroom.md#colorshift) effect is largely in response to a [Feature Request issue](https://github.com/ChrisBuilds/terminaltexteffects/issues/9) involving looping effects and the desire for a simple RGB gradient effect. TTE supports [Gradients](../engine/utils/gradient.md) with an arbitrary number of stops and steps. This means you can create gradients that transition through as many colors as you want, with as many steps between colors. Fewer steps results in a more abrupt transition. As a part of the 0.10.0 update, I've added the `loop` argument which causes the final color stop to blend back to the first. Here's an example: #### Not Looped ```python from terminaltexteffects.utils.graphics import Color, Gradient g = Gradient(Color("#ff0000"), Color("#00ff00"), Color("#0000ff"), steps=10, loop=False) print(g) ``` ![not_looped_gradient](../img/changeblog_media/0.10.0/not_looped_gradient_printed.png) #### Looped ```python from terminaltexteffects.utils.graphics import Color, Gradient g = Gradient(Color("#ff0000"), Color("#00ff00"), Color("#0000ff"), steps=10, loop=True) print(g) ``` ![looped_gradient](../img/changeblog_media/0.10.0/looped_gradient_printed.png) Notice how the looped [Gradient](../engine/utils/gradient.md) transitions from blue, the final color stop, back to red, the first color stop. That allows the smooth looped gradient animations seen in the [ColorShift](../showroom.md#colorshift) effect. #### Traveling [ColorShift](../showroom.md#colorshift) supports standing Gradients and traveling Gradients in the following directions: * vertical * horizontal * diagonal * radial The travel direction can be reversed (think, center -> outside and outside -> center) using the `--reverse-travel-direction` argument. #### Never-Ending Effects [ColorShift](../showroom.md#colorshift) marks the first effect in the TTE library to support infinite looping. This feature is set by setting the [ColorShiftConfig](../effects/colorshift.md#terminaltexteffects.effects.effect_colorshift.ColorShiftConfig) `cycles` attribute to `0`. This will only work when using the effect in your code. The command line argument validator for `cycles` will not allow values below `1`. This may change in the future, however at this time I'm not sold on having effects that have to be killed when run as an application. [ColorShiftConfig](../effects/colorshift.md#terminaltexteffects.effects.effect_colorshift.ColorShiftConfig) `skip-final-gradient` will cause the effect to end when the last gradient cycle concludes. Otherwise, the `final_gradient_`* configuration will be used to transition to a final state. ### OutputArea is now Canvas On the slow but steady journey to documenting the engine from the perspective of writing effects, I realized `OutputArea` is not a particularly good class name to describe the area of the terminal in which the effect is being drawn. In addition, setting the terminal dimensions in the [TerminalConfig](../engine/terminal/terminalconfig.md) was not intuitive when you imagine the terminal dimensions are referring to your actual terminal device. To remedy this, `OutputArea` was renamed [Canvas](../engine/terminal/canvas.md) and the `TerminalConfig.terminal_height`/`TerminalConfig.terminal_width` are now `TerminalConfig.canvas_height`/`TerminalConfig.canvas_width`. So what is the [Canvas](../engine/terminal/canvas.md)? It's the space in the terminal where the effect is actually being rendered. When set automatically, it is determined by the bounding box that contains all of your text when it is passed to TTE. So if your input text is 5 lines high and 30 characters wide, the [Canvas](../engine/terminal/canvas.md) is 5x30. This is independent of your terminal device dimensions. Of course, if your text extends beyond the terminal device dimensions, it may be wrapped (if `TerminalConfig.wrap_text` is `True`) which will result in different [Canvas](../engine/terminal/canvas.md) dimensions. ### 0.10.0 Demos Speaking of [ColorShift](../showroom.md#colorshift), check it out. ![ColorShift Demo](../img/effects_demos/colorshift_demo.gif) Here's one of the new directions for [Waves](../showroom.md#waves), `center_to_outside`. ![Waves Demo](../img/changeblog_media/0.10.0/waves_center_out_changeblog_0_10_0.gif) Here's [Wipe](../showroom.md#wipe) showing one of the new wipe directions, `outside_to_center`. ![Wipe Demo](../img/changeblog_media/0.10.0/wipe_changeblog_0_10_0.gif) --- ### Plain Old Changelog [0.10.0](https://github.com/ChrisBuilds/terminaltexteffects/blob/main/CHANGELOG.md) terminaltexteffects-release-0.15.0/docs/changeblog/changeblog_0.11.0.md000066400000000000000000000432421517776150200255440ustar00rootroot00000000000000# 0.11.0 (Enter the Matrix) ## Release 0.11.0 ### Release Summary (Matrix Effect, Anchoring, and Canvas Sizing) This release features the oft requested [Matrix](../showroom.md#matrix) effect and improvements to [Slice](../showroom.md#slice) and [Print](../showroom.md#print) as well as updates to many effects to support the new [Canvas Overhaul](#canvas-overhaul). The Engine underwent major changes to the [Canvas](../engine/terminal/canvas.md) class to enable arbitrary resizing as requested [in an issue](https://github.com/ChrisBuilds/terminaltexteffects/issues/14) and support for anchoring the Canvas within the terminal, and anchoring the text within the Canvas. Minor changes include the addition of a `--version` switch, better handling of `ctrl-c` interrupts during effect animations and a new [BaseCharacter](../engine/basecharacter.md) attribute `is_fill_character`. ### Housekeeping (We'll do it live.) In a surprise turn of events, TTE was shared on [HackerNews](https://news.ycombinator.com/item?id=40503202) a few weeks ago. I hadn't intended to share the project widely as, up to that point, it had only been used and tested by a small number of people and I assumed the result would be a deluge of Issues that would be better handled slow and steady. I only noticed it had been shared after seeing the repo star count jump from ~200 to ~500 in the span of an hour. A quick google query for "TerminalTextEffects" presented the source of this attention. Not only was TTE on the front page of HN, it was in the number one spot, where it stayed for the entire day. [Hacker News 2024-05-28](https://news.ycombinator.com/front?day=2024-05-28) Even more surprising, the comments were overwhelmingly positive. And yet more surprising still, the number of issues reported was quite low and easily managed. By the time it was all over, TTE made the top spot in the HN best-of and held it until it aged off three days later. Overall, an excellent, if unplanned, "launch". Since then, TTE has been featured in various blogs, Python news articles, the [VSCode YouTube Channel](https://www.youtube.com/shorts/E3VP5g3oXX0), and many other place around the web. I'm glad to have more people checking out this ridiculous project. Now, on to the release info. ## Matrix Effect --- When people initially encounter TTE their first thought is something like, "Neat.", and their second thought is probably, "Why?". Shortly after that, the obvious next thought is, "There must be a Matrix effect.". This led to disappointment and bewilderment. Who would make a terminal visual effects library and not include the most famous terminal visual effect in cinema history? Nobody. That would be crazy. ![Matrix Demo](../img/effects_demos/matrix_demo.gif) *I don't even see the code...* *All I see is set_appearance(), set_coordinate(), set_visibility()...* When I set out to replicate the Matrix digital rain effect, I thought, "surely this will only take a few hours". I mean, it's basically characters dropping from the top of the terminal and occasionally changing colors between shades of green, and swapping symbols. Easy. I was incorrect. After reviewing clips from the first film, and frame stepping through to learn all the secrets, I have discovered that The Matrix digital rain effect has a surprising amount of complexity. Here is the result of my analysis. ???+ Abstract "What Is The Matrix?" `Characters are not falling, they're activating in sequence, at varying speeds` : Yep, the perception that the characters are falling down the screen is a false memory (with one exception). The reality is that the cells are being activated in sequence from top to bottom in a given column. The delay between the activations is consistent within a column for the duration of a streak, but changes for each new streak. `Symbols consist of numbers, punctuation, Katakana, and a few others` : You'll need a good font to represent all of these characters, and the effect config will need an option to override and use a custom collection of characters. `Symbols and Colors in a given cell change randomly and separately` : The changes to symbol and color for a given character cell are not linked and must be calculated separately. `The number of visible characters in a column varies from just a few, to the entire column` : Sometimes a streak is only three or four characters, other times it's the entire column. `Streak lengths are consistent during a streak, but vary between streaks` : The number of visible characters remains the same during a streak, once the number has been reached. As new character cells are activated, old cells are deactivated. `Only a single streak is active in a column` : A new streak will not start until the previous streak has completely fallen out of view. `Full column streaks are held in place for a random amount of time` : On the occasion that a streak is long enough to cover the entire column, it is held (no characters are removed) for a random duration. `Characters are a brighter color when added and the last few characters fade off` : The 'front' character in a streak is a near-white color and loses that color when it is no longer the front. The end of the streak fades toward black, but not evenly so. `Once a streak reaches the bottom of the terminal, the entire column may begin dropping` : Here's the exception to the "characters aren't falling" thing. When the front of a column reaches the bottom of the terminal, the entire column will sometimes drop together. Every character will actually shift down a single row. This happens at random intervals and is separate from the tail fade off that happens for every streak. After discovering all of the above, I realized building this effect was going to take a little longer than I had imagined. I wanted the TTE implementation of the Matrix effect to be as accurate as possible within the limits of the medium. The actual movie effect has sub-character color variation and glow, which isn't a thing in a real terminal. But pretty much everything else was doable. ### Matrix Effect Implementation, In Stages To demonstrate the iterative process of building an effect, I recorded the effect output at each stage of completion. Check out each section below to see the effect come together. ??? example "0 - Falling" Characters are activated in sequence with a random delay between activations, consistent within the streak life. ![rain](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_0_character_rain.gif) ??? example "1 - Kata Symbols" Symbols are changed to katakana, punctuation, etc. and empty spaces in the canvas are filled with characters. ![symbols](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_1_kata_symbols.gif) ??? example "2 - Color/Symbol Shifting" Colors and Symbols are changed separately and randomly. In addition, the leading character is a brighter color. ![shifting](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_2_color_symbol_changes.gif) ??? example "3 - Column Lengths" Streak columns have varying lengths. ![column lengths](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_3_column_length.gif) ??? example "4 - Columns Fall Off and Recycle" Once reaching the bottom of the terminal, columns continue to drop characters off the back until completely gone. After which, it begins again with a new configuration. ![fall off](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_4_columns_fall_off_recycle.gif) ??? example "5 - Head/Tail Colors" The characters at the head of the streak are bright and the characters near the tail fade off. ![head tail colors](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_5_colors_front_back.gif) ??? example "6 - Columns Drop" Once reaching the bottom of the terminal, sometimes the column will drop all characters by a row at random intervals. In this example, the columns shift to red when dropping for demonstration purposes. ![column drop](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_6_column_drop.gif) ??? example "7 - Filling" The effect conclusion in the Matrix film title sequence doesn't practically work on multi-line text as it would require a unique column for every character in every row. This would take a long time to complete. So the TTE implementation concludes by filling the screen with rain, then dissolving into the input text. ![filling](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_7_filling.gif) ??? example "8 - Dissolving" The final dissolve into the input text. ![dissolve](../img/changeblog_media/0.11.0/matrix_effect_dev_snapshot_8_resolving.gif) ## Canvas Overhaul *18x24, pre-stretched, double primed...* --- The [Canvas](../engine/terminal/canvas.md) has received a lot of love in this release. An issue was [raised](https://github.com/ChrisBuilds/terminaltexteffects/issues/14) which asked for an obvious capability. Allow the Canvas to exceed the size of the input text. This made sense, so here you go. ### Canvas Dimensions? Terminal Dimensions? Input Dimensions? #### TTE Output 101 To make the most of the possibilities for *where* you output an effect, you need to understand the difference between the Canvas, the Terminal, and the Input Text dimensions. ![canvas_centered_with_terminal](../img/changeblog_media/0.11.0/canvas_centered_terminal_edges.png) The image above helps to explain each component. To achieve this, the following settings were used: - Canvas height set to match terminal height. - Canvas width set to an exact value which is smaller than the terminal width. - Canvas anchored to the center of the terminal. - Text anchored to the center of the canvas. Let's break this down. ##### Terminal The [Terminal](../engine/terminal/terminal.md) class performs many functions, but as it pertains to this discussion, we are only interested in the terminal dimensions. `Terminal Dimensions` : The terminal dimensions are based on your terminal emulator. TTE discovers the dimensions of your terminal emulator by calling `shutil.get_terminal_size()`. Should there an issue occur that prevents terminal dimension discovery, a fallback of `(80, 24)` is used. : In the image above, you can see the terminal as the black area on the left and right edges. This indicates the Canvas width is less than the terminal width, and the Canvas was anchored to the center. ##### Canvas The [Canvas](../engine/terminal/canvas.md) is the space within the terminal where the effect takes place. From the perspective of an effect, the Canvas is the entire world. Effects *should* be designed to reference the Canvas for all requests for Coordinates to ensure Coordinates are anchored appropriately and within the correct space within the terminal. `Canvas Dimensions` : The Canvas dimensions are determined in one of three ways. : - Specified exactly via the `--canvas-width` and `--canvas-height` options (or related TerminalConfig attributes). : - Set to match the discovered Terminal dimensions by setting `--canvas-height` or `--canvas-width` to `0`. : - Set to match the dimensions of the input text by setting `--canvas-height` or `--canvas-width` to `-1`. : Note that the separate dimensions, width and height, can be specified through any of these methods independently. : In the image above, you can see the canvas as the grey area surrounding the inner-text, with solid borders. In the image, the Canvas is set to match the Terminal height (so there is no exposed terminal above or below) but the width is less than the Terminal width. By anchoring the Canvas to the center, there is visible unused terminal space on either side. ##### Input Text The input text is the data passed to TTE on which the effect operates. TTE attempts to preserve this text in it's original form unless the `--wrap-text` option has been passed. If the Canvas is set to match the input text dimensions, the Canvas will be sized to the minimum bounding box that would contain all of the input text. ??? "How Input Text is Processed" - Whitespace characters to the right of the last non-whitespace character in a given line are stripped. - Empty lines are preserved. - Whitespace to the left of a non-whitespace character is preserved. - Tabs are converted to four spaces, and the tab width can be specified using the `--tab-width` option. `Input Text Dimensions` : The input text dimensions are determined by the input text provided to TTE and will be modified based on the following: : - If the `--wrap-text` option is passed, the text will be wrapped based on the Canvas dimensions, which will alter the original text dimensions. : - If the Canvas is set match the dimensions of the text and the `--wrap-text` option is passed, the text will be wrapped based on the Terminal dimensions. : - If the `--ignore-terminal-dimensions` option is passed, wrapping will occur based on the Canvas size or not at all (if the Canvas is set to match the text), however the output will exceed the dimensions of the terminal and will be wrapped by the terminal emulator (resulting in unexpected effect behavior) unless the output is directed somewhere else. Now that you understand how TTE handles the space where the effect is drawn, you can appreciate the latest updates to the Canvas. #### Arbitrary Canvas Sizing As mentioned above, the Canvas size can be specified with the `--canvas-width` and `--canvas-height` options, as well as the corresponding [TerminalConfig](../engine/terminal/terminalconfig.md) attributes. The following options are supported: - `-1` = Match the specified Canvas dimension to the corresponding input text dimension. - `0` = Match the specified Canvas dimension to the corresponding terminal dimension. - `n > 0` = Use the exact specified dimension. Either dimension can be specified with any of the three options. By sizing the Canvas greater than the input text, you can expand the total effect area and give the effects more room the breathe. ### Anchoring *...my adventure in off-by-one errors...* --- #### Anchoring the Canvas and/or Input Text The Canvas and/or Input Text can be anchored around the respective container using the `--anchor-[canvas/text]` option. Acceptable values are any of the Cardinal/Diagonal directions, or centered. - `sw` = South West (bottom left corner) (**default**) - `w` = West (centered on left edge) - `nw` = North West (top left corner) - `n` = North (centered on top edge) - `ne` = North East (top right corner) - `e` = East (centered on right edge) - `se` = South East (bottom right corner) - `s` = South (centered on bottom edge) - `c` = Center (centered within the Canvas/Terminal) Here is an example of the Canvas anchored to the North East of the terminal, and the text anchored to the center of the Canvas. ![canvas_anchored_ne](../img/changeblog_media/0.11.0/canvas_anchored_ne.png) Here is an example of the Canvas centered in the terminal and the text anchored to the East. ![canvas_centered_text_east](../img/changeblog_media/0.11.0/text_anchored_e.png) Here is an example of the Canvas sized to the text and centered in the terminal. Notice how there is no gray space outside the text. In this case, anchoring the text has no effect as there is no additional space in the Canvas. ![canvas_matched_to_text](../img/changeblog_media/0.11.0/canvas_sized_to_text.png) ### Effects are Canvas Aware Effects often base many of their attributes on the Canvas. For example, the [Beams](../effects/beams.md) effect features beams which travel across the entire Canvas. In the example below, the Canvas has been made wider and taller than the input text, and the text has been anchored to the center. Here's the invocation line and result: `cat testinput/demo.txt | tte --canvas-width 100 --canvas-height 40 --anchor-text c beams` ![beams_big_canvas](../img/changeblog_media/0.11.0/beams_centered_large_canvas.gif) Here's an example using the [Spray](../effects/spray.md) effect with a wide Canvas and the text anchored to the default `sw` along with the height set to match the input text height. The spray origin is much further away than usual and the resulting effect looks better. `cat testinput/demo.txt | tte --canvas-width 150 --canvas-height -1 --anchor-text sw spray` ![spray_wide_canvas](../img/changeblog_media/0.11.0/spray_wide_canvas_anchored.gif) All these changes with the Canvas has led to some additional complexity when ensuring all effects operate in reference to the Canvas dimensions properly in addition to the complexity handling the interactions between anchoring, text wrapping, and ignoring the terminal dimensions when appropriate. I'm sure there are edge cases somebody will find and report. Looking forward to fixing those. ## Other Changes --- - The `--version` switch will show the TTE version. High-tech stuff. - Inputting a keyboard interrupt (`ctrl-c`) during an animation will gracefully interrupt the effect, restore the terminal state, and exit. - A bug was fixed in the [Swarm](../effects/swarm.md) effect which was causing the first character group to be discarded. This was noticed by a user who submitted a [great issue](https://github.com/ChrisBuilds/terminaltexteffects/issues/13) documenting the unfortunate amputation of the *cowsay* cow. ``` bash $ echo "The cow misses legs!" | cowsay | tte --no-color swarm ______________________ < The cow misses legs! > ---------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ``` I'm happy to report that the bug was found and fixed, returning the cow to its intended state. --- That's all for this release. Thanks for stopping by. [0.11.0](https://github.com/ChrisBuilds/terminaltexteffects/blob/main/CHANGELOG.md) terminaltexteffects-release-0.15.0/docs/changeblog/changeblog_0.12.0.md000066400000000000000000000372551517776150200255540ustar00rootroot00000000000000# 0.12.0 (Color Parsing) ## Release 0.12.0 ### Release Summary (Color Sequence Parsing, Background Colors, and Tests) This release features three new effects, [Highlight](../showroom.md#highlight), [LaserEtch](../showroom.md#laseretch), and [Sweep](../showroom.md#sweep) as well as support for parsing existing color sequences from the input data. Support for background colors has been added throughout the engine. There are many smaller changes such as improved bezier curves, custom easing functions, and various optimizations, some of which will be detailed below. ### It's Been A While --- It has been nearly 8 months since the last release. That's entirely due to life changes on my end that kept me away from TTE development. I have not been able to work on TTE consistently for about six of those months. Things have settled and I am now able to dedicate time each week to this project (and new projects). Expect more frequent updates from now on. ### New Effects (Highlight, LaserEtch, Sweep) --- First up, there are three new effects. #### Highlight The [Highlight](../effects/highlight.md) effect runs a specular highlight across the text. To best demonstrate this effect, I will use a solid block of text. ![highlight_block_demo](../img/changeblog_media/0.12.0/highlight_block_demo.gif) The highlight brightness, width, and direction are all customizable. #### LaserEtch The [LaserEtch](../effects/laseretch.md) effect burns the text into the terminal, sending sparks flying. As the sparks fall, they cool and disappear. If the sparks reach the bottom of the canvas before burning out, they will land on the bottom. ![laseretch_demo](../img/effects_demos/laseretch_demo.gif) The etch speed, laser colors, spark colors and all gradients are customizable. #### Sweep The [Sweep](../effects/sweep.md) effect makes two passes over the canvas. On the first pass, the text is revealed, dimmed, and without color. On the second pass, the text is colored. ![sweep_demo](../img/effects_demos/sweep_demo.gif) The sweep directions and sweep noise symbols are customizable. On the second sweep, the noise takes on colors from the final gradient. ### Color Sequence Parsing --- TTE can now parse 8/24-bit color sequences from the input data, and associate them with the characters to which they should be applied. This includes both foreground and background sequences. The following ANSI escape sequence formats are supported: : 8-bit Sequences : - `ESC[38:5:⟨n⟩m Select foreground color` : - `ESC[48:5:⟨n⟩m Select background color` : 24-bit Sequences : - `ESC[38;2;⟨r⟩;⟨g⟩;⟨b⟩ m Select RGB foreground color` : - `ESC[48;2;⟨r⟩;⟨g⟩;⟨b⟩ m Select RGB background color` : [Wikipedia - ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) --- There is a new [TerminalConfig](../engine/terminal/terminalconfig.md#terminaltexteffects.engine.terminal.TerminalConfig.existing_color_handling) command line argument `--existing-color-handling` that determines how these sequences are used within effects. There are three options for the `--existing-color-handling` argument: `dynamic` : When set to 'dynamic', each effect will determine how the input sequences are handled. `always` : When set to 'always', no other color will be applied to the characters. They will always reflect the colors as provided in the input text. In most cases, this setting will result in an effect that loses much of its design. `ignore` : When set to 'ignore', the input colors will be ignored. This is the default. #### Color Handling Example I recommend using a tool such as [ASCII Silhouettify](https://meatfighter.com/ascii-silhouettify/) to turn your art into ASCII. In the example below, ASCII Silhouettify was used to process the image into ASCII with color sequences. The ASCII was piped directly into TTE. Each of the color handling options is demonstrated. === "Source Image" ![astro_planet_balloon](../img/changeblog_media/0.12.0/astro_holding_planet.png) === "Resulting ASCII" ```  ___-mmma___ _s"-_"~gggg~""= .<-_ _r_g@@@@@@@@@@D"r_g@p"q_a ,fg@@@@@@@@@D"o"o@@@@@@@P,"_o ________ /,@@@@@@B>_="g@@@@@@@@P,"o@@_ '(._-..q_ q_a jJ{@P".="<@@@@@@@@@P"=_g@@"~"g@' [ ].,[@,jW[ /_""~g@@@@@@@@@@P_="g@@P_+_@@@P_@.]F,_gP gPf /@ @@@@@@@@P"o"_@@@P_="g@@@>_P'_+.~_@"_wP; ..@,@BP".="_g@@D"->_g@@@>_*'_s",__@P _@"r ' >"_g@@@B="<>_g@@@B>-"~"o"_ "gB"~_@"o" ;D=_"s>"o@@@@B>'_->_="~ _g@P _gP"s"@ _>t"@@@_<4+"~_w>_="_^,_gB" _gD",>_D_,' -_u_Fo"gg@B>"o*"_'~_g@P"~_gM",>_d"o@B"F __1@1%mmP>"_gy@@@@@@.__~"""s"~8"<@@P"_'/ 0gW@@@@@@W@@@Q$@B@@@B>_s"_m>_g@@P"_g@Ps 1@@g5@@@@@g@@D>"-="_mD"_g@@D>__g@@@"+ '<=mmm==>"" "a_4@BP>"__g@@@BP"r" "<==mmy="" ] ] _~~B>"""->._ ] ,+_^~-"_@@B==4@g_g_ ] ,/gF^f_@P_g@BBBB@@_4@@, ] __@,/_@[_~g@@@@@@@g_8L0@L _~~1_ ,/@,//@@@@@@@@@@@@@@@@'gQ@\ jd/|g]h |@g'/@@@@@@@@@@@@@@@@@@'[@@//'.<>/[ @|.:@/gp\@@@@@@@@@@@@@@@@@ @ L=r_' !@h,,@'@@"@@@@@@@@@@@@@@@@@.@@']\ ]"==a<)@+"@@@@@@@@@@@@@B2#F@g, " ', F@@@_`qt\oW@BP@@@@0S$W@@'/AB"f| \ l4@@@_,!r0@@@@@@@@@@@P,^"g+@WJ ^'@@P/_<.<-_"""""_+"_"oFg@\s _ ">_"=_""""_<"_@"g@@@@`, !!|0@@@@BP"_g@@P"~":aV_ '_;@@@Fa[@@P+faB=4@\9p\[ @[@@@' [@B___`' `[,"~ , [@@@'[-mm=>"_''qB>J~"g[ [!@@;TR"""~__~~_g@@P'T[ |[@@g_"LG@@@@[g""~_-_@[ |[@@@8-"@@@@@@F@ "_@@8| {:"_r] 8"==*"~T'*8="- ;/@@@"_ ]|@@+_1 [@@@@g| [\@@@@, !`@@@F' \*"="' ".""_" """  ``` ??? example "Dynamic Color Sequence Handling" The beams effect supports dynamic color handling which results in the standard gradient being replaced with the colors parsed from the input data. The beams, however, keep their color. ![beams_astro_dynamic](../img/changeblog_media/0.12.0/beams_dynamic_astro_demo.gif) ??? example "Always Color Sequence Handling" The characters never deviate from the input colors. This results in the beams taking on the color of the character as they pass over. The dimming effect is not applied. ![beams_astro_always](../img/changeblog_media/0.12.0/beams_always_astro_demo.gif) ??? example "Ignore Color Sequence Handling" The color sequences are parsed and removed from the input. The result is the normal effect behavior. ![beams_astro_ignore](../img/changeblog_media/0.12.0/beams_ignore_astro_demo.gif) Support for dynamic color handling has not been added to all effects. To track the progress of this feature, see [this Issue](https://github.com/ChrisBuilds/terminaltexteffects/issues/37). ### Background Colors --- TTE now supports specifying background colors throughout the engine. In calls which expect color values, a [ColorPair](../engine/utils/colorpair.md) object, providing a foreground and/or background color is used. There are no effects currently which use background colors. --- ### Ease All The Things *easing is easy* #### Easing Closure A new [easing](../engine/utils/easing.md) function is available that provides a closure around an arbitrary ease. Example: ```python import terminaltexteffects as tte bounce_ease = tte.easing.eased_step_function(easing_func=tte.easing.out_bounce, step_size=0.01) print(bounce_ease()) print(bounce_ease()) print(bounce_ease()) print(bounce_ease()) ``` Output: ```text (0.0, 0.0) (0.01, 0.0007562500000000001) (0.02, 0.0030250000000000003) (0.03, 0.00680625) ``` Every call to `bounce_ease` above, outputs a tuple of (current step, eased value). Once the current step reaches 1, the function will stop increasing the step and the return value will never change. The [wipe](../effects/wipe.md) effect supports arbitrary easing via this new function. Here is an example of the `out_bounce` easing function applied to the progression of the wipe. ![bounce_wipe](../img/changeblog_media/0.12.0/wipe_bounce_demo.gif) #### Custom Cubic Bezier Ease In addition to the function above, a new easing function has been added which allows for the specification of completely custom easing function using bezier control points. An example follows. Here, I'll use [cubic-bezier.com](https://cubic-bezier.com/#0,.80,1,.20) to visually build a curve. The x-axis is time, in our case each call to the ease function will progress one unit across the x-axis. The y-axis is the progression of the function. The bottom is 0, the top is 1. ![cubic-bezier.com](../img/changeblog_media/0.12.0/cubic-bezier.com_demo.png) I will apply the control points seen in the image above, (0, .81, .98, .22) to the easing function and pass the function to the `ease` argument when creating a new [Path](../engine/motion/path.md). The target [Coord](../engine/utils/geometry.md) will be the right side of the canvas. ```python target_coord = tte.Coord( self.terminal.canvas.right, self.terminal.canvas.center_row, ) pth = test_char.motion.new_path(ease=tte.easing.make_easing(0, 0.81, 0.98, 0.22), speed=0.5) pth.new_waypoint(target_coord) ``` With the custom easing function applied to the motion of a single character traveling across the canvas, we would expect to see the character move quickly at first, slow down towards the center, and speed up again as it progresses to the right of the canvas. ![custom_eased_path](../img/changeblog_media/0.12.0/custom_ease_demo.gif) ### Misc Optimizations and Fixes This updates includes many fixes and optimizations, details can be found in the full changelog. ### Testing Now that TTE has grown in scale beyond that which I can meaningfully test manually, a full suite of unittests has been added. Pytest is fun, and parameterization has enabled the testing of all effects, with all of their arguments, and a representative sample of the acceptable ranges for those arguments. This was simply impossible manually. All together, there are approximately 30,000 tests run against the codebase, taking about seven minutes. --- ### Plain Old Changelog [0.12.0](https://github.com/ChrisBuilds/terminaltexteffects/blob/main/CHANGELOG.md) terminaltexteffects-release-0.15.0/docs/changeblog/changeblog_0.13.0.md000066400000000000000000000240351517776150200255450ustar00rootroot00000000000000# 0.13.0 (Still Alive) ## Release 0.13.0 ### Release Summary (Thunderstorm, Smoke, Spanning Trees, and Screensaver Support) This release features two brand new effects, [Smoke](../showroom.md#smoke) and [Thunderstorm](../showroom.md#thunderstorm), as well as refreshed effects [Burn](../showroom.md#burn) and [Laseretch](../showroom.md#laseretch). Behind the scenes, the engine and application received some big changes, especially around argument parsing and configuration. ### This Took Too Long --- Despite my intention, life stuff caused this update to take far longer than necessary. To address the unpredictability of the universe, I am going to make updates much smaller and more frequent. ### Yer a ScreenSaver, Harry --- In other news, TerminalTextEffects is currently a screensaver in [Omarchy](https://omarchy.org/). ![Omarchy Laseretch](../img/changeblog_media/0.13.0/omarchy_laseretch.gif) I found out when they opened an issue/PR to support this TTE use-case. To that end, there are three new command-line options: * `--reuse-canvas` : Rather than create lines beneath the current prompt as the canvas. This option causes the terminal handler to move the cursor up by the number of visible-lines in the input text. This will eliminate scrolling between effects. Note that this option has unpredictable results when used from a terminal prompt as the prompt itself and any intermediate output will offset the canvas from the prior run. * `--no-eol` : When an effect ends, TTE normally prints a newline to place the prompt on a new line below the canvas. When this option is used, the newline is suppressed. * `--no-restore-cursor` : TTE uses an ANSI sequence to hide the cursor during an effect and restores the cursor after the effect ends. When this option is used, the cursor will remain hidden. Using these three options together, you can run TTE repeatedly in the same location in a terminal as one may do in a screensaver scenario. These options can be used when using TTE as a library, too. So you can run effects repeated in your application. ??? info "Reusing the terminal in your Python application." Here we allow the first effect to run without the `reuse_canvas` option, to get the original canvas setup. Then we set `effect.terminal_config.reuse_canvas` to true for the remaining effects. All effects will run at the same position in the terminal, unless something else produces output. ```python from terminaltexteffects.effects import Highlight, Slide, Wipe text = ("EXAMPLE" * 10 + "\n") * 10 for pos, effect_class in enumerate((Wipe, Highlight, Slide)): effect = effect_class(text) effect.terminal_config.reuse_canvas = bool(pos) effect.terminal_config.no_eol = True with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` On that note, it's very cool to find TTE in the wild. If you are currently using TTE or have an idea for a use-case, feel free to open an Issue/Discussion for questions/support. ### New Effects (Thunderstorm and Smoke) --- There are two new effects in this update, which feels a bit underwhelming given how long it took to release. Really, writing effects often takes a few hours +/- some time to polish. Moving into a more frequent and smaller release cycle will significantly aid in new effect production. #### Thunderstorm The text darkens as though storm clouds are rolling in. It begins to rain. Lightning strikes randomly around the canvas. When lightning reaches the bottom of the canvas, it flashes and all characters flash in sync with the strike. Sparks fly up from the lightning point of contact. Any characters in the path of the strike have a burning glow that lingers for a bit after the strike dissipates. Eventually, the rain stops and the characters lighten. ![Thunderstorm](../img/effects_demos/thunderstorm_demo.gif) The storm time is configurable, as is the lightning strike chance. As usual, colors and gradients are also configurable. #### Smoke Text appears uncolored. Smoke begins to flow out of a random point and spread around the canvas. When a character encounters the smoke, the smoke and character flow through the final gradient colors until landing on the correct color. Smoke will expand through the entire canvas and colorize all characters. ![Smoke](../img/effects_demos/smoke_demo.gif) The smoke will limit itself to the text boundary unless the `--use-whole-canvas` effect option is passed. Smoke symbols, gradient, and all other colors are configurable. ### Refreshed Effects --- #### Burn Burn has been upgraded with a more realistic burn pattern and new smoke particles. A random spot on the canvas is chosen for the initial ignition, the flame spreads out in all direction from this point. Smoke is emitted from some of the burned characters. ![Burn](../img/effects_demos/burn_demo.gif) #### LaserEtch Laseretch has a new, and default, etch pattern, in addition to the existing patterns. The laser travels around the input text rather than only moving in rows/columns/diagonals. ![LaserEtch](../img/effects_demos/laseretch_demo.gif) ### Minimum Spanning Trees and Search/Explore --- As a bit of background, I have an older project called [TerminalMaze](https://github.com/ChrisBuilds/terminalmaze) which is the primordial soup from which many of the TTE concepts emerged. TerminalMaze has a very different animation mechanism and features a DSL for describing animations via TOML config. None of this is properly documented at this time. I hope to create a nice README for it in the near future. TerminalMaze uses various Minimum Spanning-Tree algorithms to build mazes in the terminal. The nodes in the graph are animated based on the current activity of the algorithm. For example, below you will see a visualization of the recursive backtracker algorithm. When the algorithm links a node into the graph, the node glows bright greenish-white with a gradient toward green. When the algorithm pops the node off it's stack, the color is returned to white. There are examples of many algorithms below, with unique animations corresponding to the algorithm's decision logic. === "Recursive Backtracker" ![RecursiveBacktracker](../img/changeblog_media/0.13.0/terminalmaze_recursive_backtracker.gif) === "Prims Simple" ![PrimsSimple](../img/changeblog_media/0.13.0/terminalmaze_prims_simple.gif) === "Prims Weighted" ![PrimsWeighted](../img/changeblog_media/0.13.0/terminalmaze_prims_weighted.gif) === "Aldous Broder" ![AldousBroder](../img/changeblog_media/0.13.0/terminalmaze_aldous_broder.gif) === "Wilsons" ![Wilsons](../img/changeblog_media/0.13.0/terminalmaze_wilsons.gif) === "Recursive Division" ![RecursiveDivision](../img/changeblog_media/0.13.0/terminalmaze_recursive_division.gif) === "Kruskals Randomized" ![KruskalsRandomized](../img/changeblog_media/0.13.0/terminalmaze_kruskals_randomized.gif) === "Ellers" ![Ellers](../img/changeblog_media/0.13.0/terminalmaze_ellers.gif) A more interesting demonstration of the TerminalMaze animation can be seen by building a maze and then running a Breadth First search across it to find the bottom-right-most cell. The Breadth First algorithm has a more elaborate animation. ![RecursiveBacktrackerBreadthFirst](../img/changeblog_media/0.13.0/terminalmaze_recursive_backtracker_breadth_first.gif) #### Graph Algorithms in TTE I've wanted to incorporate many of the algorithms from TerminalMaze into TTE. I finally got around to designing the algorithm interface and relevant methods/attributes on the Terminal/Canvas/EffectCharacter classes and can now easily provide graphing algorithms to the effects. The Canvas can be treated like a graph with all EffectCharacters as nodes. Nodes can be linked and their neighbors queried. That's pretty much all it takes. This new feature has already been put to use in the new and refreshed effects. * Smoke : Smoke is a Prims Weighted tree being explored by a breadth first search. All new EffectCharacters explored are turned into smoke followed by the character animation. * LaserEtch : The new laser path is a Recursive Backtracker tree with the backtracking removed. * Burn : The new burn pattern is a Prims Simple tree where the node-link order is used as the ignition order for the characters. I have many ideas for new effects which are enabled by these sorts of algorithms. ### Misc --- #### Random Effect TTE now has a random effect option that is used like this: `ll | tte random_effect` You can still pass global terminal options, but you cannot pass effect-specific options. !!! success "Valid Invocation with Terminal Config" `ll | tte --canvas-height 0 --canvas-width 0 --anchor-text c random_effect` Passing terminal options such as `--canvas-height` works. !!! failure "Invalid Invocation with Effect Config" `ll | tte random_effect --final-gradient-stops 00ff00 ff0000 0000ff` Passing effect options such as `--final-gradient-stops` does not work. #### Faster TTE Startup TTE will now launch faster as a result of the changes in Config/Terminal/Argument parsing classes. How much faster? Not sure but definitely something. #### Framerate Reduced TTE has always set the target framerate to 100FPS. This framerate was chosen arbitrarily during initial development and I mostly forgot about it. This framerate is unnecessarily high and leaves little time for the effects to do more complicated work between frames. It has been adjusted to 60FPS and all effects have had animation frames and movement speeds adjusted for visual parity with 100FPS. This should result in smoother animation on slower machines for effects which do a lot of dynamic work during the run. ### Moving Forward --- Look forward to more frequent updates. Likely many of which are only a single effect or two without bundling them with months of engine changes nobody cares about. Who knows, maybe one day I'll get to actually writing a guide to producing your own effects. I won't consider TTE _Done_ until that happens. --- ### Plain Old Changelog [0.13.0](https://github.com/ChrisBuilds/terminaltexteffects/blob/main/CHANGELOG.md) terminaltexteffects-release-0.15.0/docs/changeblog/changeblog_0.14.0.md000066400000000000000000000126371517776150200255530ustar00rootroot00000000000000# 0.14.0 (Easier Easing) ## Release 0.14.0 ### Release Summary (Easing and Random Filters) This release features two new [Easing](https://easings.net/) classes and improves the functionality of the random effect selection CLI option. It also removes an effect which was inadvertently included in the last release. There are some effect changes/upgrades as well, but no new effects. ### Sequence Easing Easing is a powerful tool when animating. It's how you bring life to otherwise boring linear motion/progression. TTE uses easing for motion and animation. However, it's also useful to ease progress over a sequence. For example, the [Wipe](../effects/wipe.md) effect uses easing to enable the `--wipe-ease` argument. In the effect, a subset of a sequence of characters is 'activated' based on the output of an easing function. For example, when the easing function outputs 0.1, the first 10% of the character list is activated. That's pretty simple, until you realize some easing functions increase and decrease the output number as they progress. See [easeOutBounce](https://easings.net/#easeOutBounce) for example. Handling this means tracking not just the added characters, as the function output increases, but also the removed characters when the function output decreases. These adjustments must be made after each step in the function. If you do it right, you can achieve the following: ![wipe_bounce_demo](../img/changeblog_media/0.14.0/wipe_bounce_demo.gif) I had found myself repeating the logic surrounding this in multiple effects such as [Sweep](../effects/sweep.md) and [Highlight](../effects/highlight.md). Implementing this was always enough of a challenge that I was not exploring the various ways eased sequences could improve other effects. To reduce friction in the future, I created a class `SequenceEaser` which exposes the relevant data while an easing function is applied to n sequence. These attributes include which elements of the sequence were added/removed on the last step. Using the `SequenceEaser` class reduced dozens of lines of logic to: === "Wipe Easing" ```python self.easer.step() for group in self.easer.added: # (1) for character in group: # (2) character.animation.activate_scene("wipe") self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) for group in self.easer.removed: # (3) for character in group: character.animation.deactivate_scene() character.animation.query_scene("wipe").reset_scene() self.terminal.set_character_visibility(character, is_visible=False) ``` 1. `easer.added` is all sequence elements that fall within the positive difference between the easing functions last two steps. 2. Wipe groups characters into lists representing a row/column/diag based on the `--wipe-direction` option. 3. `easer.removed` is all sequence elements that fall within the negative difference between the easing functions last two steps. `SequenceEaser` will lead to more experimentation and, hopefully, more interesting effects. Here's an example that shows how helper classes such as `SequenceEaser` and the new spanning tree algorithms can be used to achieve otherwise complicated results with very few lines of code. === "Custom Ease over a Tree" ```python self.tree = PrimsSimple( self.terminal, limit_to_text_boundary=True, starting_char=self.terminal.get_character_by_input_coord(self.terminal.canvas.text_center), ) self.sequence_easer = tte.easing.SequenceEaser( sequence=self.tree.char_link_order, easing_function=tte.easing.make_easing(0, 0.97, 0.99, 0.05), total_steps=100, ) # ... skip to iterator logic ... if self.active_characters or not self.sequence_easer.is_complete(): self.sequence_easer.step() for char in self.sequence_easer.added: self.terminal.set_character_visibility(char, is_visible=True) ``` This combines a `PrimsSimple` tree with a `SequenceEaser` applying a custom easing function via `tte.easing.make_easing` to the sequence of characters representing the link-order of the Prims tree. The custom easing function is based on a bezier curve which looks like this: ![cubic_bezier_ease](../img/changeblog_media/0.14.0/cubic-bezier_prims.png) So we should see fast progression over the tree at the start, followed by a slowing down to a near stop, then fast progression until the end. The Prims tree was set to start from the center of the canvas. Here's the result: ![ease_over_prims](../img/changeblog_media/0.14.0/custom_eased_tree_demo.gif) ### Do Random Better Previously, the `random_effect` option was sort-of hacked in by imitating an effect subparser and swapping an effect name into the `sys.argv` list. This had limited functionality without getting even more hack-y. This was refactored and, as such, random now supports the following: ``` --random-effect, -R Randomly select an effect to apply --include-effects INCLUDE_EFFECTS [INCLUDE_EFFECTS ...] Space-separated list of Effects to include when randomly selecting an effect --exclude-effects EXCLUDE_EFFECTS [EXCLUDE_EFFECTS ...] Space-separated list of Effects to exclude when randomly selecting an effect ``` ### Plain Old Changelog [0.14.0](https://github.com/ChrisBuilds/terminaltexteffects/blob/main/CHANGELOG.md)terminaltexteffects-release-0.15.0/docs/changeblog/changeblog_0.15.0.md000066400000000000000000000205761517776150200255550ustar00rootroot00000000000000# 0.15.0 (Go Fetch) ## Release 0.15.0 ### Release Summary (Shell Completion, Dynamic Colors, and Better Terminal Input) This release is mostly about catching up on the quality-of-life work that makes TTE nicer to use in real shells with real terminal output. There are no new built-in effects this time. Instead, TTE can now generate shell completions for `bash` and `zsh`, handle more of the ANSI escape sequence soup produced by terminal tools such as `neofetch` and `fastfetch`, and do a much better job preserving input colors in `existing_color_handling="dynamic"` mode. There is also support for discovering user-provided effects from a config directory, along with a pile of engine documentation and test improvements. ### Push Tab, Receive Effect --- TTE can now generate shell completions: ```bash tte --print-completion bash tte --print-completion zsh ``` For one-off use: ```bash eval "$(tte --print-completion bash)" ``` or: ```zsh eval "$(tte --print-completion zsh)" ``` If you want completions to stick around, add the relevant command to your shell startup file. The generated completion script includes discovered effects and their options, so custom effects show up there too. One small but useful bit of polish: the `zsh` completion script initializes `compinit` and `bashcompinit` when needed. That means the basic `eval "$(tte --print-completion zsh)"` path works with less shell ceremony. ### Go Fetch --- TTE has supported parsing ANSI colors from input for a while, but terminal applications do more than color text. Fetch-style tools often move the cursor around to lay out a logo and system information side-by-side. Previously, TTE had no idea what to do with the control sequences fetch tools use for layout, so it treated them as text. That often led to interesting chaos as the sequences were broken apart and reassembled throughout the effect. TTE now interprets common cursor movement CSI sequences into its virtual input canvas. That includes relative movement up, down, forward, and back; moving to the next or previous line; setting the cursor column; and jumping to an explicit row/column position: ```text A: cursor up B: cursor down C: cursor forward D: cursor back E: next line F: previous line G: set column H/f: set row and column ``` In practice, those sequences let TTE reconstruct terminal layouts that are drawn out of order. When a fetch tool prints a logo, moves the cursor, then prints system details next to it, TTE can now place those characters on the intended canvas cells before the effect starts. It also accepts common DEC private mode toggles for cursor visibility and line wrapping as no-op input state: ```text ?25h ?25l ?7h ?7l ``` That does not mean TTE is a full terminal emulator. But it now understands enough of the layout language used by common fetch applications to preserve the intended shape. The color parser also got more complete. TTE now supports 3-bit and 4-bit SGR foreground/background colors, reset sequences, and mixed style/color SGR sequences. Bold standard ANSI foreground colors are preserved as bright colors, which better matches how normal terminals display many fetch outputs. === "Unparsed Fetch Sequences" ![fetch_unparsed](../img/changeblog_media/0.15.0/fetch_unparsed.gif) === "Parsed Fetch Sequences" ![fetch_parsed](../img/changeblog_media/0.15.0/fetch_parsed.gif) ### Dynamic Colors Everywhere --- In `0.12.0`, TTE added `--existing-color-handling` with three modes: `ignore` : Strip parsed input colors and let the effect do its normal thing. `always` : Keep input colors on the characters all the time, even when that removes some of the effect's personality. `dynamic` : Let the effect decide how to use the input colors. That last option is the interesting one, but it also requires effect-by-effect care. Some effects should keep their own colors during the action and resolve to input colors at the end. Some should use input colors the whole way. Some need a neutral fallback for uncolored input. Some need to treat background-only spaces as real input. It goes on. This release fills in a lot of that work. Examples: * [Burn](../effects/burn.md), [LaserEtch](../effects/laseretch.md), and [Matrix](../effects/matrix.md) now process input spaces that carry parsed ANSI colors, which preserves background-color art cells. * [Beams](../effects/beams.md), [BinaryPath](../effects/binarypath.md), and [Overflow](../effects/overflow.md) no longer accidentally finish uncolored input in effect-owned colors when using `dynamic`. * [Highlight](../effects/highlight.md) now bases the highlight on parsed foreground colors and preserves parsed backgrounds, including background-only spaces. * [Rings](../effects/rings.md), [Scattered](../effects/scattered.md), [Slice](../effects/slice.md), [Slide](../effects/slide.md), [Spray](../effects/spray.md), and [Wipe](../effects/wipe.md) can now run with input colors driving the whole effect. * [Thunderstorm](../effects/thunderstorm.md), [Unstable](../effects/unstable.md), and [VHSTape](../effects/vhstape.md) now start from input colors, do their effect-specific chaos, and return to the input colors or terminal default state correctly. This is one of those changes where the feature sounds simple from the outside: > keep my colors And then inside the engine it becomes: > preserve foreground and background independently, don't invent a foreground for background-only cells, don't erase colored spaces, keep helper/fill characters effect-owned, let uncolored input return to terminal default, and make all of that happen at the visually correct part of each effect === "Dynamic Colors" ![dynamic_colors](../img/changeblog_media/0.15.0/color_dynamic_burn.gif) === "Always Colors" ![always_colors](../img/changeblog_media/0.15.0/color_always_burn.gif) === "Ignore Colors" ![ignore_colors](../img/changeblog_media/0.15.0/color_ignore_burn.gif) ### Your Effects Live Here Now --- TTE can now discover effects from: ```text ${XDG_CONFIG_HOME}/terminaltexteffects/effects ``` If `XDG_CONFIG_HOME` is not set, that resolves to: ```text ~/.config/terminaltexteffects/effects ``` Effect files are normal Python files. For example: ```text ~/.config/terminaltexteffects/effects/effect_custom.py ``` If the file provides a `get_effect_resources()` function, TTE can register it just like a built-in effect. The function returns the CLI command name, the effect class, and the config class: ```python def get_effect_resources(): return "custom", CustomEffect, CustomConfig ``` After that, the custom effect can be invoked from the CLI: ```bash cat message.txt | tte custom ``` The important part is that user effects now participate in the same parser-building path as built-in effects. Runtime parsing, effect help text, random-effect selection, and generated completions all build from the same discovered effect definitions. ### Small API Tweaks --- There are also a number of engine and documentation improvements in this release. The final-gradient CLI arguments are now shared helpers: ```python FinalGradientStopsArg FinalGradientStepsArg FinalGradientFramesArg FinalGradientDirectionArg ``` All effects now use these helpers, which keeps parser defaults and help text more consistent. RandomSequence now uses `--terminal-background-color` as the starting point for its reveal fade, so its effect-specific `--starting-color` option has been removed. Several spanning tree generators received bug fixes and focused test coverage. The highlights: * `SpanningTreeGenerator.get_neighbors()` now honors `unlinked_only=False`. * `PrimsSimple` applies `limit_to_text_boundary` more consistently. * `PrimsWeighted` limits random starting-character selection to the text boundary when requested. * `BreadthFirst` initializes correctly when `starting_char` is omitted and records discovered characters once. * `AldousBroder` returns immediately once all characters are linked. Path and scene deactivation are also more convenient. These calls now accept an object, an ID string, or no argument: ```python character.motion.deactivate_path() character.motion.deactivate_path("path_id") character.animation.deactivate_scene() character.animation.deactivate_scene("scene_id") ``` The no-argument form deactivates the currently active path or scene. Event registrations for `DEACTIVATE_PATH` and `DEACTIVATE_SCENE` support the same shapes. ### Plain Old Changelog [0.15.0](https://github.com/ChrisBuilds/terminaltexteffects/blob/main/CHANGELOG.md) terminaltexteffects-release-0.15.0/docs/cookbook.md000066400000000000000000000022511517776150200223660ustar00rootroot00000000000000# Library Cookbook Below you'll find examples of interesting ways to use the TTE library. ## Animated Prompts You can use any effect to create an animated prompt by setting the `end_symbol` parameter of the `terminal_output()` context manager to `" "`. Adjust the effect configuration to achieve a more responsive prompt. ```python from terminaltexteffects.effects.effect_beams import Beams from terminaltexteffects.effects.effect_slide import Slide def slide_animated_prompt(prompt_text: str) -> str: effect = Slide(prompt_text) effect.effect_config.final_gradient_frames = 1 with effect.terminal_output(end_symbol=" ") as terminal: for frame in effect: terminal.print(frame) return input() def beams_animated_prompt(prompt_text: str) -> str: effect = Beams(prompt_text) effect.effect_config.final_gradient_frames = 1 with effect.terminal_output(end_symbol=" ") as terminal: for frame in effect: terminal.print(frame) return input() resp = slide_animated_prompt("Here's a sliding prompt:") resp = beams_animated_prompt("Here's one with beams:") ``` ### Output ![t](./img/lib_demos/animated_prompts_demo.gif) terminaltexteffects-release-0.15.0/docs/effectguide/000077500000000000000000000000001517776150200225105ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/effectguide/effectguide.md000066400000000000000000000004471517776150200253110ustar00rootroot00000000000000# Writing Custom Effects with TTE The following lessons will teach you how to write your own effects using the TerminalTextEffects engine. These lessons will cover: * TTE Engine Update Cycle * Anatomy of an Effect * Character Motion * Character Animation * Effect CLI Arguments and Validation terminaltexteffects-release-0.15.0/docs/effectguide/effectguide_lesson0.md000066400000000000000000000020311517776150200267430ustar00rootroot00000000000000# Lesson 0 ## Introduction to the TTE Engine The TTE engine architecture features a single Terminal object and many, mostly isolated, EffectCharacter objects. The Terminal is responsible for creating EffectCharacters, providing them to the effects, and printing them to the screen. EffectCharacters are responsible for progressing themselves through their animation/motion logic. You can think of every character on the screen as it's own object, moving itself around, and modifying it's own appearance. The Terminal simply gets the latest location and string representation of each character on every call to Terminal.print(). ### Terminal Let's look at the lifecycle of the Terminal. ``` mermaid --- title: Terminal --- get_dimensions make_effectcharacters update_terminal_state make_output_string print [*] --> get_dimensions get_dimensions --> make_effectcharacters make_effectcharacters --> update_terminal_state update_terminal_state --> make_output_string make_output_string --> print print --> update_terminal_state print --> [*] ``` terminaltexteffects-release-0.15.0/docs/effects/000077500000000000000000000000001517776150200216555ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/effects/beams.md000066400000000000000000000005161517776150200232700ustar00rootroot00000000000000# Beams ![Demo](../img/effects_demos/beams_demo.gif) ## Quick Start ``` py title="beams.py" from terminaltexteffects.effects.effect_beams import Beams effect = Beams("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_beams terminaltexteffects-release-0.15.0/docs/effects/binarypath.md000066400000000000000000000005611517776150200243420ustar00rootroot00000000000000# Binarypath ![Demo](../img/effects_demos/binarypath_demo.gif) ## Quick Start ``` py title="binarypath.py" from terminaltexteffects.effects.effect_binarypath import BinaryPath effect = BinaryPath("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_binarypath terminaltexteffects-release-0.15.0/docs/effects/blackhole.md000066400000000000000000000005521517776150200241250ustar00rootroot00000000000000# Blackhole ![Demo](../img/effects_demos/blackhole_demo.gif) ## Quick Start ``` py title="blackhole.py" from terminaltexteffects.effects.effect_blackhole import Blackhole effect = Blackhole("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_blackhole terminaltexteffects-release-0.15.0/docs/effects/bouncyballs.md000066400000000000000000000005701517776150200245160ustar00rootroot00000000000000# BouncyBalls ![Demo](../img/effects_demos/bouncyballs_demo.gif) ## Quick Start ``` py title="bouncyballs.py" from terminaltexteffects.effects.effect_bouncyballs import BouncyBalls effect = BouncyBalls("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_bouncyballs terminaltexteffects-release-0.15.0/docs/effects/bubbles.md000066400000000000000000000005341517776150200236170ustar00rootroot00000000000000# Bubbles ![Demo](../img/effects_demos/bubbles_demo.gif) ## Quick Start ``` py title="bubbles.py" from terminaltexteffects.effects.effect_bubbles import Bubbles effect = Bubbles("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_bubbles terminaltexteffects-release-0.15.0/docs/effects/burn.md000066400000000000000000000005071517776150200231470ustar00rootroot00000000000000# Burn ![Demo](../img/effects_demos/burn_demo.gif) ## Quick Start ``` py title="burn.py" from terminaltexteffects.effects.effect_burn import Burn effect = Burn("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_burn terminaltexteffects-release-0.15.0/docs/effects/colorshift.md000066400000000000000000000007771517776150200243660ustar00rootroot00000000000000# ColorShift ![Demo](../img/effects_demos/colorshift_demo.gif) ## Quick Start ``` py title="colorshift.py" from terminaltexteffects import Gradient from terminaltexteffects.effects.effect_colorshift import ColorShift effect = ColorShift("YourTextHere") effect.effect_config.travel = True effect.effect_config.travel_direction = Gradient.Direction.RADIAL with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_colorshift terminaltexteffects-release-0.15.0/docs/effects/crumble.md000066400000000000000000000005341517776150200236320ustar00rootroot00000000000000# Crumble ![Demo](../img/effects_demos/crumble_demo.gif) ## Quick Start ``` py title="crumble.py" from terminaltexteffects.effects.effect_crumble import Crumble effect = Crumble("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_crumble terminaltexteffects-release-0.15.0/docs/effects/decrypt.md000066400000000000000000000005341517776150200236530ustar00rootroot00000000000000# Decrypt ![Demo](../img/effects_demos/decrypt_demo.gif) ## Quick Start ``` py title="decrypt.py" from terminaltexteffects.effects.effect_decrypt import Decrypt effect = Decrypt("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_decrypt terminaltexteffects-release-0.15.0/docs/effects/errorcorrect.md000066400000000000000000000005771517776150200247230ustar00rootroot00000000000000# ErrorCorrect ![Demo](../img/effects_demos/errorcorrect_demo.gif) ## Quick Start ``` py title="errorcorrect.py" from terminaltexteffects.effects.effect_errorcorrect import ErrorCorrect effect = ErrorCorrect("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_errorcorrect terminaltexteffects-release-0.15.0/docs/effects/expand.md000066400000000000000000000005251517776150200234600ustar00rootroot00000000000000# Expand ![Demo](../img/effects_demos/expand_demo.gif) ## Quick Start ``` py title="expand.py" from terminaltexteffects.effects.effect_expand import Expand effect = Expand("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_expand terminaltexteffects-release-0.15.0/docs/effects/fireworks.md000066400000000000000000000005521517776150200242140ustar00rootroot00000000000000# Fireworks ![Demo](../img/effects_demos/fireworks_demo.gif) ## Quick Start ``` py title="fireworks.py" from terminaltexteffects.effects.effect_fireworks import Fireworks effect = Fireworks("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_fireworks terminaltexteffects-release-0.15.0/docs/effects/highlight.md000066400000000000000000000007461517776150200241550ustar00rootroot00000000000000# Highlight ![Demo](../img/effects_demos/highlight_demo.gif) ## Quick Start ``` py title="highlight.py" from terminaltexteffects import Gradient from terminaltexteffects.effects.effect_highlight import Highlight effect = Highlight("YourTextHere") with effect.terminal_output() as terminal: effect.effect_config.final_gradient_direction = Gradient.Direction.HORIZONTAL for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_highlight terminaltexteffects-release-0.15.0/docs/effects/laseretch.md000066400000000000000000000007461517776150200241600ustar00rootroot00000000000000# LaserEtch ![Demo](../img/effects_demos/laseretch_demo.gif) ## Quick Start ``` py title="laseretch.py" from terminaltexteffects import Gradient from terminaltexteffects.effects.effect_laseretch import LaserEtch effect = LaserEtch("YourTextHere") with effect.terminal_output() as terminal: effect.effect_config.final_gradient_direction = Gradient.Direction.HORIZONTAL for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_laseretch terminaltexteffects-release-0.15.0/docs/effects/matrix.md000066400000000000000000000005341517776150200235050ustar00rootroot00000000000000# Matrix ![Demo](../img/effects_demos/matrix_demo.gif) ## Quick Start ``` py title="matrix.py" from terminaltexteffects.effects.effect_matrix import Matrix effect = Matrix("YourTextHere\n" * 10) with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_matrix terminaltexteffects-release-0.15.0/docs/effects/middleout.md000066400000000000000000000005521517776150200241670ustar00rootroot00000000000000# MiddleOut ![Demo](../img/effects_demos/middleout_demo.gif) ## Quick Start ``` py title="middleout.py" from terminaltexteffects.effects.effect_middleout import MiddleOut effect = MiddleOut("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_middleout terminaltexteffects-release-0.15.0/docs/effects/orbittingvolley.md000066400000000000000000000006241517776150200254350ustar00rootroot00000000000000# OrbittingVolley ![Demo](../img/effects_demos/orbittingvolley_demo.gif) ## Quick Start ``` py title="orbittingvolley.py" from terminaltexteffects.effects.effect_orbittingvolley import OrbittingVolley effect = OrbittingVolley("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_orbittingvolley terminaltexteffects-release-0.15.0/docs/effects/overflow.md000066400000000000000000000005431517776150200240440ustar00rootroot00000000000000# Overflow ![Demo](../img/effects_demos/overflow_demo.gif) ## Quick Start ``` py title="overflow.py" from terminaltexteffects.effects.effect_overflow import Overflow effect = Overflow("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_overflow terminaltexteffects-release-0.15.0/docs/effects/pour.md000066400000000000000000000005071517776150200231660ustar00rootroot00000000000000# Pour ![Demo](../img/effects_demos/pour_demo.gif) ## Quick Start ``` py title="pour.py" from terminaltexteffects.effects.effect_pour import Pour effect = Pour("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_pour terminaltexteffects-release-0.15.0/docs/effects/print.md000066400000000000000000000005161517776150200233350ustar00rootroot00000000000000# Print ![Demo](../img/effects_demos/print_demo.gif) ## Quick Start ``` py title="print.py" from terminaltexteffects.effects.effect_print import Print effect = Print("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_print terminaltexteffects-release-0.15.0/docs/effects/rain.md000066400000000000000000000005071517776150200231320ustar00rootroot00000000000000# Rain ![Demo](../img/effects_demos/rain_demo.gif) ## Quick Start ``` py title="rain.py" from terminaltexteffects.effects.effect_rain import Rain effect = Rain("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_rain terminaltexteffects-release-0.15.0/docs/effects/randomsequence.md000066400000000000000000000006171517776150200252140ustar00rootroot00000000000000# RandomSequence ![Demo](../img/effects_demos/randomsequence_demo.gif) ## Quick Start ``` py title="randomsequence.py" from terminaltexteffects.effects.effect_random_sequence import RandomSequence effect = RandomSequence("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_random_sequence terminaltexteffects-release-0.15.0/docs/effects/rings.md000066400000000000000000000005161517776150200233230ustar00rootroot00000000000000# Rings ![Demo](../img/effects_demos/rings_demo.gif) ## Quick Start ``` py title="rings.py" from terminaltexteffects.effects.effect_rings import Rings effect = Rings("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_rings terminaltexteffects-release-0.15.0/docs/effects/scattered.md000066400000000000000000000005521517776150200241570ustar00rootroot00000000000000# Scattered ![Demo](../img/effects_demos/scattered_demo.gif) ## Quick Start ``` py title="scattered.py" from terminaltexteffects.effects.effect_scattered import Scattered effect = Scattered("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_scattered terminaltexteffects-release-0.15.0/docs/effects/slice.md000066400000000000000000000006001517776150200232720ustar00rootroot00000000000000# Slice ![Demo](../img/effects_demos/slice_demo.gif) ## Quick Start ``` py title="slice.py" from terminaltexteffects.effects.effect_slice import Slice effect = Slice("YourTextHere") effect.effect_config.slice_direction = "diagonal" with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_slice terminaltexteffects-release-0.15.0/docs/effects/slide.md000066400000000000000000000005161517776150200233010ustar00rootroot00000000000000# Slide ![Demo](../img/effects_demos/slide_demo.gif) ## Quick Start ``` py title="slide.py" from terminaltexteffects.effects.effect_slide import Slide effect = Slide("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_slide terminaltexteffects-release-0.15.0/docs/effects/smoke.md000066400000000000000000000005001517776150200233100ustar00rootroot00000000000000# Smoke ![Demo](../img/effects_demos/smoke_demo.gif) ## Quick Start ``` py title="smoke.py" from terminaltexteffects.effects import Smoke effect = Smoke("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_smoketerminaltexteffects-release-0.15.0/docs/effects/spotlights.md000066400000000000000000000005611517776150200244010ustar00rootroot00000000000000# Spotlights ![Demo](../img/effects_demos/spotlights_demo.gif) ## Quick Start ``` py title="spotlights.py" from terminaltexteffects.effects.effect_spotlights import Spotlights effect = Spotlights("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_spotlights terminaltexteffects-release-0.15.0/docs/effects/spray.md000066400000000000000000000005161517776150200233370ustar00rootroot00000000000000# Spray ![Demo](../img/effects_demos/spray_demo.gif) ## Quick Start ``` py title="spray.py" from terminaltexteffects.effects.effect_spray import Spray effect = Spray("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_spray terminaltexteffects-release-0.15.0/docs/effects/swarm.md000066400000000000000000000005161517776150200233320ustar00rootroot00000000000000# Swarm ![Demo](../img/effects_demos/swarm_demo.gif) ## Quick Start ``` py title="swarm.py" from terminaltexteffects.effects.effect_swarm import Swarm effect = Swarm("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_swarm terminaltexteffects-release-0.15.0/docs/effects/sweep.md000066400000000000000000000007021517776150200233210ustar00rootroot00000000000000# Sweep ![Demo](../img/effects_demos/sweep_demo.gif) ## Quick Start ``` py title="sweep.py" import terminaltexteffects as tte from terminaltexteffects.effects.effect_sweep import Sweep effect = Sweep("YourTextHere") effect.effect_config.final_gradient_direction = tte.Gradient.Direction.HORIZONTAL with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_sweep terminaltexteffects-release-0.15.0/docs/effects/synthgrid.md000066400000000000000000000005521517776150200242140ustar00rootroot00000000000000# SynthGrid ![Demo](../img/effects_demos/synthgrid_demo.gif) ## Quick Start ``` py title="synthgrid.py" from terminaltexteffects.effects.effect_synthgrid import SynthGrid effect = SynthGrid("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_synthgrid terminaltexteffects-release-0.15.0/docs/effects/thunderstorm.md000066400000000000000000000005521517776150200247370ustar00rootroot00000000000000# Thunderstorm ![Demo](../img/effects_demos/thunderstorm_demo.gif) ## Quick Start ``` py title="thunderstorm.py" from terminaltexteffects.effects import Thunderstorm effect = Thunderstorm("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_thunderstormterminaltexteffects-release-0.15.0/docs/effects/unstable.md000066400000000000000000000005431517776150200240160ustar00rootroot00000000000000# Unstable ![Demo](../img/effects_demos/unstable_demo.gif) ## Quick Start ``` py title="unstable.py" from terminaltexteffects.effects.effect_unstable import Unstable effect = Unstable("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_unstable terminaltexteffects-release-0.15.0/docs/effects/vhstape.md000066400000000000000000000005341517776150200236530ustar00rootroot00000000000000# VHSTape ![Demo](../img/effects_demos/vhstape_demo.gif) ## Quick Start ``` py title="vhstape.py" from terminaltexteffects.effects.effect_vhstape import VHSTape effect = VHSTape("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_vhstape terminaltexteffects-release-0.15.0/docs/effects/waves.md000066400000000000000000000005161517776150200233260ustar00rootroot00000000000000# Waves ![Demo](../img/effects_demos/waves_demo.gif) ## Quick Start ``` py title="waves.py" from terminaltexteffects.effects.effect_waves import Waves effect = Waves("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_waves terminaltexteffects-release-0.15.0/docs/effects/wipe.md000066400000000000000000000005071517776150200231450ustar00rootroot00000000000000# Wipe ![Demo](../img/effects_demos/wipe_demo.gif) ## Quick Start ``` py title="wipe.py" from terminaltexteffects.effects.effect_wipe import Wipe effect = Wipe("YourTextHere") with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` ::: terminaltexteffects.effects.effect_wipe terminaltexteffects-release-0.15.0/docs/engine/000077500000000000000000000000001517776150200215035ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/engine/animation/000077500000000000000000000000001517776150200234625ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/engine/animation/animation.md000066400000000000000000000001621517776150200257620ustar00rootroot00000000000000# Animation *Module*: `terminaltexteffects.engine.animation` ::: terminaltexteffects.engine.animation.Animation terminaltexteffects-release-0.15.0/docs/engine/animation/charactervisual.md000066400000000000000000000001761517776150200271700ustar00rootroot00000000000000# CharacterVisual *Module*: `terminaltexteffects.engine.animation` ::: terminaltexteffects.engine.animation.CharacterVisual terminaltexteffects-release-0.15.0/docs/engine/animation/frame.md000066400000000000000000000001521517776150200250740ustar00rootroot00000000000000# Frame *Module*: `terminaltexteffects.engine.animation` ::: terminaltexteffects.engine.animation.Frame terminaltexteffects-release-0.15.0/docs/engine/animation/scene.md000066400000000000000000000001521517776150200250770ustar00rootroot00000000000000# Scene *Module*: `terminaltexteffects.engine.animation` ::: terminaltexteffects.engine.animation.Scene terminaltexteffects-release-0.15.0/docs/engine/basecharacter.md000066400000000000000000000002101517776150200246050ustar00rootroot00000000000000# EffectCharacter *Module*: `terminaltexteffects.engine.base_character` ::: terminaltexteffects.engine.base_character.EffectCharacter terminaltexteffects-release-0.15.0/docs/engine/baseconfig.md000066400000000000000000000001541517776150200241250ustar00rootroot00000000000000# BaseConfig *Module*: `terminaltexteffects.engine.base_config` ::: terminaltexteffects.engine.base_configterminaltexteffects-release-0.15.0/docs/engine/baseeffect.md000066400000000000000000000001551517776150200241150ustar00rootroot00000000000000# BaseEffect *Module*: `terminaltexteffects.engine.base_effect` ::: terminaltexteffects.engine.base_effect terminaltexteffects-release-0.15.0/docs/engine/eventhandler.md000066400000000000000000000002021517776150200244760ustar00rootroot00000000000000# EventHandler *Module*: `terminaltexteffects.engine.base_character` ::: terminaltexteffects.engine.base_character.EventHandler terminaltexteffects-release-0.15.0/docs/engine/motion/000077500000000000000000000000001517776150200230105ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/engine/motion/motion.md000066400000000000000000000001461517776150200246400ustar00rootroot00000000000000# Motion *Module*: `terminaltexteffects.engine.motion` ::: terminaltexteffects.engine.motion.Motion terminaltexteffects-release-0.15.0/docs/engine/motion/path.md000066400000000000000000000001421517776150200242630ustar00rootroot00000000000000# Path *Module*: `terminaltexteffects.engine.motion` ::: terminaltexteffects.engine.motion.Path terminaltexteffects-release-0.15.0/docs/engine/motion/segment.md000066400000000000000000000001501517776150200247700ustar00rootroot00000000000000# Segment *Module*: `terminaltexteffects.engine.motion` ::: terminaltexteffects.engine.motion.Segment terminaltexteffects-release-0.15.0/docs/engine/motion/waypoint.md000066400000000000000000000001521517776150200252020ustar00rootroot00000000000000# Waypoint *Module*: `terminaltexteffects.engine.motion` ::: terminaltexteffects.engine.motion.Waypoint terminaltexteffects-release-0.15.0/docs/engine/terminal/000077500000000000000000000000001517776150200233165ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/engine/terminal/canvas.md000066400000000000000000000001521517776150200251110ustar00rootroot00000000000000# Canvas *Module*: `terminaltexteffects.engine.terminal` ::: terminaltexteffects.engine.terminal.Canvas terminaltexteffects-release-0.15.0/docs/engine/terminal/terminal.md000066400000000000000000000001561517776150200254550ustar00rootroot00000000000000# Terminal *Module*: `terminaltexteffects.engine.terminal` ::: terminaltexteffects.engine.terminal.Terminal terminaltexteffects-release-0.15.0/docs/engine/terminal/terminalconfig.md000066400000000000000000000001721517776150200266410ustar00rootroot00000000000000# TerminalConfig *Module*: `terminaltexteffects.engine.terminal` ::: terminaltexteffects.engine.terminal.TerminalConfig terminaltexteffects-release-0.15.0/docs/engine/utils/000077500000000000000000000000001517776150200226435ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/engine/utils/ansitools.md000066400000000000000000000001461517776150200252010ustar00rootroot00000000000000# ANSItools *Module*: `terminaltexteffects.utils.ansitools` ::: terminaltexteffects.utils.ansitools terminaltexteffects-release-0.15.0/docs/engine/utils/argutils.md000066400000000000000000000013401517776150200250150ustar00rootroot00000000000000# ArgUtils *Module*: `terminaltexteffects.utils.argutils` ::: terminaltexteffects.utils.argutils ## Example Usage The following example demonstrates using the `PositiveFloat` class to provide a `type_parser` and `metavar` to the `RandomSequenceConfig.speed` argument. This will validate that the argument passed as `--speed` is a float > 0. ```python class RandomSequenceConfig(ArgsDataClass): speed: float = ArgField( cmd_name=["--speed"], type_parser=argutils.PositiveFloat.type_parser, default=0.004, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the animation as a percentage of the total number of characters to reveal in each tick.", ) # type: ignore[assignment] ```terminaltexteffects-release-0.15.0/docs/engine/utils/color.md000066400000000000000000000024171517776150200243070ustar00rootroot00000000000000# Color *Module*: `terminaltexteffects.utils.graphics` ## Basic Usage Color objects are used to represent colors throughout TTE. However, they can be instantiated and printed directly. ### Supports Multiple Specification Formats ```python from terminaltexteffects.utils.graphics import Color red = Color('ff0000') xterm_red = Color(9) rgb_red_again = Color('#ff0000') ``` ### Printing Colors Colors can be printed to show the code and resulting color appearance. ```python from terminaltexteffects.utils.graphics import Color red = Color("#ff0000") print(red) ``` ![t](../../img/lib_demos/printing_colors_demo.png) ### Using Colors to build a Gradient ```python from terminaltexteffects.utils.graphics import Gradient, Color rgb = Gradient(Color("#ff0000"), Color("#00ff00"), Color("#0000ff"), steps=5) for color in rgb: # color is a hex string ... ``` ### Passing Colors to effect configurations ```python text = ("EXAMPLE" * 10 + "\n") * 10 red = Color("#ff0000") green = Color("#00ff00") blue = Color("#0000ff") effect = ColorShift(text) effect.effect_config.gradient_stops = (red, green, blue) with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` --- ## Color Reference ::: terminaltexteffects.utils.graphics.Color terminaltexteffects-release-0.15.0/docs/engine/utils/colorpair.md000066400000000000000000000014301517776150200251550ustar00rootroot00000000000000# ColorPair *Module*: `terminaltexteffects.utils.graphics` ## Basic Usage ColorPair objects are used to represent a foreground and background color pair. ### Usage ```python import terminaltexteffects as tte color_pair = tte.ColorPair(fg=tte.Color("#FF0000"), bg=tte.Color("#00FF00")) ``` ### Alternate Signature Colors can be specified using strings or integers. Color objects will be created automatically. ```python import terminaltexteffects as tte color_pair = tte.ColorPair("#FF0000", "#00FF00") ``` `fg` and/or `bg` are optional and default to `None`. ### Printing ColorPairs ColorPair objects can be printed to see the resulting colors. ![t](../../img/lib_demos/colorpair_print_example.png) --- ## ColorPair Reference ::: terminaltexteffects.utils.graphics.ColorPair terminaltexteffects-release-0.15.0/docs/engine/utils/colorterm.md000066400000000000000000000001461517776150200251740ustar00rootroot00000000000000# ColorTerm *Module*: `terminaltexteffects.utils.colorterm` ::: terminaltexteffects.utils.colorterm terminaltexteffects-release-0.15.0/docs/engine/utils/easing.md000066400000000000000000000001471517776150200244350ustar00rootroot00000000000000# Easing Functions *Module*: `terminaltexteffects.utils.easing` ::: terminaltexteffects.utils.easing terminaltexteffects-release-0.15.0/docs/engine/utils/exceptions.md000066400000000000000000000001511517776150200253430ustar00rootroot00000000000000# Exceptions *Module*: `terminaltexteffects.utils.exceptions` ::: terminaltexteffects.utils.exceptions terminaltexteffects-release-0.15.0/docs/engine/utils/geometry.md000066400000000000000000000001431517776150200250160ustar00rootroot00000000000000# Geometry *Module*: `terminaltexteffects.utils.geometry` ::: terminaltexteffects.utils.geometry terminaltexteffects-release-0.15.0/docs/engine/utils/gradient.md000066400000000000000000000010471517776150200247640ustar00rootroot00000000000000# Gradient *Module*: `terminaltexteffects.utils.graphics` ## Basic Usage ```python from terminaltexteffects.utils.graphics import Gradient, Color rgb = Gradient(Color("#ff0000"), Color("#00ff00"), Color("#0000ff"), steps=5) for color in rgb: # color is a hex string ... ``` ## Printing Gradients Gradients can be printed to the terminal to show information about the stops, steps, and resulting spectrum. ![t](../../img/lib_demos/printing_gradients_demo.png) --- ## Gradient Reference ::: terminaltexteffects.utils.graphics.Gradient terminaltexteffects-release-0.15.0/docs/engine/utils/hexterm.md000066400000000000000000000001401517776150200246340ustar00rootroot00000000000000# HexTerm *Module*: `terminaltexteffects.utils.hexterm` ::: terminaltexteffects.utils.hexterm terminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/000077500000000000000000000000001517776150200253405ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/algos/000077500000000000000000000000001517776150200264455ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/algos/aldous_broder.md000066400000000000000000000002241517776150200316110ustar00rootroot00000000000000# Aldous Broder *Module*: `terminaltexteffects.utils.spanningtree.algo.aldousbroder` ::: terminaltexteffects.utils.spanningtree.algo.aldousbroder terminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/algos/breadthfirst.md000066400000000000000000000002231517776150200314450ustar00rootroot00000000000000# Breadth First *Module*: `terminaltexteffects.utils.spanningtree.algo.breadthfirst` ::: terminaltexteffects.utils.spanningtree.algo.breadthfirstterminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/algos/prims_simple.md000066400000000000000000000002201517776150200314640ustar00rootroot00000000000000# Prims Simple *Module*: `terminaltexteffects.utils.spanningtree.algo.primssimple` ::: terminaltexteffects.utils.spanningtree.algo.primssimpleterminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/algos/prims_weighted.md000066400000000000000000000002261517776150200320010ustar00rootroot00000000000000# Prims Weighted *Module*: `terminaltexteffects.utils.spanningtree.algo.primsweighted` ::: terminaltexteffects.utils.spanningtree.algo.primsweightedterminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/algos/recursive_backtracker.md000066400000000000000000000002531517776150200333320ustar00rootroot00000000000000# Recursive Backtracker *Module*: `terminaltexteffects.utils.spanningtree.algo.recursivebacktracker` ::: terminaltexteffects.utils.spanningtree.algo.recursivebacktrackerterminaltexteffects-release-0.15.0/docs/engine/utils/spanningtree/base_generator.md000066400000000000000000000002161517776150200306410ustar00rootroot00000000000000# base_generator *Module*: `terminaltexteffects.utils.spanningtree.base_generator` ::: terminaltexteffects.utils.spanningtree.base_generatorterminaltexteffects-release-0.15.0/docs/index.md000066400000000000000000000034711517776150200216740ustar00rootroot00000000000000# Intro to TTE ![title_blackhole](./img/application_demos/shadow_title_blackhole.gif) ## What is TTE? TerminalTextEffects (TTE) is a terminal visual effects engine. TTE can be installed as a system application to produce effects in your terminal, or as a Python library to enable effects within your Python scripts/applications. TTE includes a growing library of built-in effects which showcase the engine's features. These features include: * Xterm 256 / RGB hex color support * Complex character movement via Paths, Waypoints, and motion easing, with support for quadratic/cubic bezier curves. * Complex animations via Scenes with symbol/color changes, layers, easing, and Path synced progression. * Variable stop/step color gradient generation. * Event handling for Path/Scene state changes with custom callback support and many pre-defined actions. * Effect customization exposed through a typed effect configuration dataclass that is automatically handled as CLI arguments. * Runs inline, preserving terminal state and workflow. ## Getting Started TTE can be used as a system application or as a Python library. To get started, visit the installation and usage guides below. [Installation Guide](./installation.md){ .md-button } [Application Usage](./appguide.md){ .md-button } [Library Usage](./libguide.md){ .md-button } ## Effects Library TTE includes a growing library of built-in effects. Visit the showroom to see examples of each effect. [Effects Showroom](./showroom.md){ .md-button } ## Library Cookbook Check out the cookbook to see interesting examples using the TTE library. [Library Cookbook](./cookbook.md){ .md-button } ## Release Write-Ups Friendly release write-ups can be found in the [ChangeBlog](./changeblog/changeblog.md) [ChangeBlog](./changeblog/changeblog.md){ .md-button } terminaltexteffects-release-0.15.0/docs/installation.md000066400000000000000000000017471517776150200232720ustar00rootroot00000000000000# Installation TerminalTextEffects can be installed as a system application using pipx or as a library using pip. ## System Application using Pipx When installed as an application, TerminalTextEffects can be called from the shell to produce effects on any input piped to stdin. Usages include invocation on shell launch, aliasing commands to pass output through TTE, and SSH login animations. Pipx is the easiest way to make TTE available in your shell. `pipx install terminaltexteffects` !!! note If pipx is unavailable, you can install via `pip` and run TTE by calling the python binary with the module argument. ```bash title="ls redirection" ls -latr | python3 -m terminaltexteffects ``` [Application Usage](./appguide.md){ .md-button } ## Library installation using Pip When installed as a library, TerminalTextEffects can be imported to produce animations in your Python applications. `pip install terminaltexteffects` [Library Usage](./libguide.md){ .md-button } terminaltexteffects-release-0.15.0/docs/libguide.md000066400000000000000000000127521517776150200223530ustar00rootroot00000000000000# Library Guide ## Playing Effect Animations All effects are iterators which return a string representing the current frame. Basic usage is as simple as importing the effect, instantiating it with the input text, and iterating over the effect. Effects includes a helpful context manager ([effect.terminal_output()](./engine/baseeffect.md#terminaltexteffects.engine.base_effect.BaseEffect.terminal_output)) to handle terminal setup/teardown and cursor positioning. The following example plays the [Slide](./effects/slide.md) effect animation using the [effect.terminal_output()](./engine/baseeffect.md#terminaltexteffects.engine.base_effect.BaseEffect.terminal_output) context manager. === "Syntax" ```python from terminaltexteffects.effects.effect_slide import Slide text = ("EXAMPLE" * 10 + "\n") * 10 effect = Slide(text) effect.effect_config.merge = True # (1) with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` 1. Use the `effect_config` attribute to modify the effect configuration. Setting `merge` to `True` on the Slide effect causes the text to slide in from alternating sides of the terminal. === "Output" ![t](./img/lib_demos/libguide_onlyslide_output.gif) ## Effects are Iterable If you want to handle the output yourself, such as sending the frames to a TUI or GUI, simply iterate over the effect without the context manager. ```python from terminaltexteffects.effects.effect_slide import Slide text = ("EXAMPLE" * 10 + "\n") * 10 effect = Slide(text) effect.effect_config.merge = True # (1) for frame in effect: # frame is a string, do something with it ``` 1. Use the `effect_config` attribute to modify the effect configuration. Setting `merge` to `True` on the Slide effect causes the text to slide in from alternating sides of the terminal. ## Configuring Effects All effect configuration options are available within each effect via the `effect.effect_config` and `effect.terminal_config` attributes. === "Syntax" ```python from terminaltexteffects.effects.effect_slide import Slide from terminaltexteffects.utils.graphics import Color text = ("EXAMPLE" * 10 + "\n") * 10 effect = Slide(text) effect.effect_config.merge = True # (1) effect.effect_config.grouping = "column" # (2) effect.effect_config.final_gradient_stops = (Color("#0ff000"), Color("#000ff0"), Color("#0f00f0")) # (3) effect.terminal_config.canvas_width = 30 # (4) with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) ``` 1. Use the `effect_config` attribute to modify the effect configuration. Setting `merge` to `True` on the Slide effect causes the text to slide in from alternating sides of the terminal. 2. Columns will slide in, rather than rows. 3. Change the gradient colors from the defaults. 4. Set the canvas width manually rather than automatically detect. Canvas height will be automatically set to the input text height. === "Output" ![t](./img/lib_demos/libguide_configuration_output.gif) ## Configuring the Terminal/Canvas ### Terminal/Canvas Dimensions TTE uses a [Terminal](./engine/terminal/terminal.md) class and a [Canvas](./engine/terminal/canvas.md) class to handle terminal/canvas dimensions, wrapping text, etc. Effects contain an attribute (`effect.terminal_config`) which allows access to the various terminal configuration options. The configuration should be modified prior to iterating over the effect. For example, to set the Canvas dimensions manually: ```python effect.terminal_config.canvas_width = 80 effect.terminal_config.canvas_height = 24 ``` If either `canvas_width` or `canvas_height` are set to `0`, that dimension will be automatically detected based on the terminal device dimensions. If either dimensions is set to `-1`, that dimensions will be set to match the input text dimensions. By default, if your Canvas dimensions exceed the visible area of the Terminal, the text outside of that area will not be output in the frame. If you want to output all characters regardless of they position relative to the visible terminal area, set `terminal_config.ignore_terminal_dimensions` to `True`. This should **only** be used if you are handing the frame yourself and outputting somewhere other than the terminal. The terminal emulator will wrap the text and produce unexpected results. ```python effect.terminal_config.ignore_terminal_dimensions = True ``` ### Frame Rate TTE sets a default target frame rate of 100FPS. To configure this within your scripts, access the `effect.terminal_config.frame_rate` attribute. Set this attribute to `0` to remove the frame limit. For more information on terminal configuration options, check out the [TerminalConfig](./engine/terminal/terminalconfig.md) reference. ## Infinitely Looping Effects Some effects support infinite looping. For example, [ColorShift](./effects/colorshift.md) via the [ColorShiftConfig.cycles](./effects/colorshift.md#terminaltexteffects.effects.effect_colorshift.ColorShiftConfig) config attribute. When set to 0 directly, the effect will cycle indefinitely. Explore the configuration options for a given effect to see if it supports infinite looping. !!! note Infinite looping is *NOT* supported when TTE is run as an application. The command line argument validators will not accept these values. This is by design, to prevent users inadvertently inputting a configuration that results in having to interrupt the process to end an effect. terminaltexteffects-release-0.15.0/docs/showroom.md000066400000000000000000002750651517776150200224540ustar00rootroot00000000000000# Effects Showroom The effects shown below represent the built-in library of effects and their default configuration. ## Beams Creates beams which travel over the canvas illuminating the characters. ![Demo](./img/effects_demos/beams_demo.gif) [Reference](./effects/beams.md){ .md-button } [Config](./effects/beams.md#terminaltexteffects.effects.effect_beams.BeamsConfig){ .md-button } ??? example "Beams Command Line Arguments" ``` --beam-row-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Symbols to use for the beam effect when moving along a row. Strings will be used in sequence to create an animation. (default: ('▂', '▁', '_')) --beam-column-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Symbols to use for the beam effect when moving along a column. Strings will be used in sequence to create an animation. (default: ('▌', '▍', '▎', '▏')) --beam-delay (int > 0) Number of frames to wait before adding the next group of beams. Beams are added in groups of size random(1, 5). (default: 10) --beam-row-speed-range (hyphen separated int range e.g. '1-10') Minimum speed of the beam when moving along a row. (default: (10, 40)) --beam-column-speed-range (hyphen separated int range e.g. '1-10') Minimum speed of the beam when moving along a column. (default: (6, 10)) --beam-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 beam, a gradient will be created between the colors. (default: ('ffffff', '00D1FF', '8A008A')) --beam-gradient-steps (int > 0) [(int > 0) ...] Space separated, unquoted, numbers for the of gradient steps to use. More steps will create a smoother and longer gradient animation. Steps are paired with the colors in final-gradient- stops. (default: (2, 8)) --beam-gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 2) --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 wipe gradient. (default: ('8A008A', '00D1FF', 'ffffff')) --final-gradient-steps (int > 0) [(int > 0) ...] Space separated, unquoted, numbers for the of gradient steps to use. More steps will create a smoother and longer gradient animation. Steps are paired with the colors in final-gradient- stops. (default: (12,)) --final-gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 5) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) --final-wipe-speed (int > 0) Speed of the final wipe as measured in diagonal groups activated per frame. (default: 1) Example: terminaltexteffects beams --beam-row-symbols ▂ ▁ _ --beam-column-symbols ▌ ▍ ▎ ▏ --beam-delay 10 --beam-row-speed-range 10-40 --beam-column-speed-range 6-10 --beam-gradient-stops ffffff 00D1FF 8A008A --beam-gradient-steps 2 8 --beam-gradient-frames 2 --final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 12 --final-gradient-frames 5 --final-gradient-direction vertical --final-wipe-speed 1 ``` --- ## Binarypath Decodes characters into their binary form. Characters travel from outside the canvas towards their input coordinate, moving at right angles. ![Demo](./img/effects_demos/binarypath_demo.gif) [Reference](./effects/binarypath.md){ .md-button } [Config](./effects/binarypath.md#terminaltexteffects.effects.effect_binarypath.BinaryPathConfig){ .md-button } ??? example "Binarypath Command Line Arguments" ``` --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: ('00d500', '007500')) --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. (default: Direction.CENTER) --binary-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the binary characters. Character color is randomly assigned from this list. (default: ('044E29', '157e38', '45bf55', '95ed87')) --movement-speed (float > 0) Speed of the binary groups as they travel around the terminal. (default: 1.0) --active-binary-groups (0 <= float(n) <= 1) Maximum number of binary groups that are active at any given time. Lower this to improve performance. (default: 0.05) Example: terminaltexteffects binarypath --final-gradient-stops 00d500 007500 --final-gradient-steps 12 --final-gradient-direction vertical --binary-colors 044E29 157e38 45bf55 95ed87 --movement-speed 1.0 --active-binary-groups 0.05 ``` --- ## Blackhole Creates a blackhole in a starfield, consumes the stars, explodes the input data back into position. ![Demo](./img/effects_demos/blackhole_demo.gif) [Reference](./effects/blackhole.md){ .md-button } [Config](./effects/blackhole.md#terminaltexteffects.effects.effect_blackhole.BlackholeConfig){ .md-button } ??? example "Blackhole Command Line Arguments" ``` --blackhole-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the stars that comprise the blackhole border. (default: ffffff) --star-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] List of colors from which character colors will be chosen and applied after the explosion, but before the cooldown to final color. (default: ('ffcc0d', 'ff7326', 'ff194d', 'bf2669', '702a8c', '049dbf')) --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: ('8A008A', '00D1FF', 'ffffff')) --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. (default: Direction.DIAGONAL) Example: terminaltexteffects blackhole --star-colors ffcc0d ff7326 ff194d bf2669 702a8c 049dbf --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --final-gradient-direction vertical ``` --- ## BouncyBalls Characters fall from the top of the canvas as bouncy balls before settling into place. ![Demo](./img/effects_demos/bouncyballs_demo.gif) [Reference](./effects/bouncyballs.md){ .md-button } [Config](./effects/bouncyballs.md#terminaltexteffects.effects.effect_bouncyballs.BouncyBallsConfig){ .md-button } ??? example "Bouncyballs Command Line Arguments" ``` --ball-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated list of colors from which ball colors will be randomly selected. If no colors are provided, the colors are random. (default: ('d1f4a5', '96e2a4', '5acda9')) --ball-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Space separated list of symbols to use for the balls. (default: ('*', 'o', 'O', '0', '.')) --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: ('f8ffae', '43c6ac')) --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. (default: Direction.DIAGONAL) --ball-delay (int >= 0) Number of frames between ball drops, increase to reduce ball drop rate. (default: 7) --movement-speed (float > 0) Movement speed of the characters. (default: 0.25) --easing EASING Easing function to use for character movement. (default: out_bounce) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects bouncyballs --ball-colors d1f4a5 96e2a4 5acda9 --ball-symbols o "*" O 0 . --final-gradient-stops f8ffae 43c6ac --final-gradient-steps 12 --final-gradient-direction diagonal --ball-delay 7 --movement-speed 0.25 --easing OUT_BOUNCE ``` --- ## Bubbles Forms bubbles with the characters. Bubbles float down and pop. ![Demo](./img/effects_demos/bubbles_demo.gif) [Reference](./effects/bubbles.md){ .md-button } [Config](./effects/bubbles.md#terminaltexteffects.effects.effect_bubbles.BubblesConfig){ .md-button } ??? example "Bubbles Command Line Arguments" ``` --rainbow If set, the bubbles will be colored with a rotating rainbow gradient. (default: False) --bubble-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the bubbles. Ignored if --no-rainbow is left as default False. (default: ('d33aff', '7395c4', '43c2a7', '02ff7f')) --pop-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the spray emitted when a bubble pops. (default: ffffff) --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: ('d33aff', '02ff7f')) --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. (default: Direction.DIAGONAL) --bubble-speed (float > 0) Speed of the floating bubbles. (default: 0.1) --bubble-delay (int > 0) Number of frames between bubbles. (default: 50) --pop-condition {row,bottom,anywhere} Condition for a bubble to pop. 'row' will pop the bubble when it reaches the the lowest row for which a character in the bubble originates. 'bottom' will pop the bubble at the bottom row of the terminal. 'anywhere' will pop the bubble randomly, or at the bottom of the terminal. (default: row) --easing (Easing Function) Easing function to use for character movement after a bubble pops. (default: in_out_sine) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects bubbles --bubble-colors d33aff 7395c4 43c2a7 02ff7f --pop-color ffffff --final-gradient-stops d33aff 02ff7f --final-gradient-steps 12 --final-gradient-direction diagonal --bubble-speed 0.1 --bubble-delay 50 --pop-condition row --easing IN_OUT_SINE ``` --- ## Burn Characters are ignited and burn up the screen. ![Demo](./img/effects_demos/burn_demo.gif) [Reference](./effects/burn.md){ .md-button } [Config](./effects/burn.md#terminaltexteffects.effects.effect_burn.BurnConfig){ .md-button } ??? example "Burn Command Line Arguments" ``` --starting-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color of the characters before they start to burn. (default: Color Code: 837373 Color Appearance: █████) --burn-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Colors transitioned through as the characters burn. (default: (Color('ffffff'), Color('fff75d'), Color('fe650d'), Color('8A003C'), Color('510100'))) --smoke-chance (0 <= float(n) <= 1) Chance a given character will produce smoke while burning. Use 0 for no smoke. (default: 0.2) --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: (Color('00c3ff'), Color('ffff1c'))) --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. (default: Direction.VERTICAL) Example: terminaltexteffects burn --starting-color 837373 --burn-colors ffffff fff75d fe650d 8a003c 510100 --smoke-chance 0.2 --final-gradient-stops 00c3ff ffff1c --final-gradient-steps 12 ``` --- ## ColorShift Display a gradient that shifts colors across the terminal. ![Demo](./img/effects_demos/colorshift_demo.gif) !!! note Demo GIF uses `--travel` and `--travel-direction radial` arguments. [Reference](./effects/colorshift.md){ .md-button } [Config](./effects/colorshift.md#terminaltexteffects.effects.effect_colorshift.ColorShiftConfig){ .md-button } ??? example "ColorShift Command Line Arguments" ``` --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 gradient. (default: (Color('e81416'), Color('ffa500'), Color('faeb36'), Color('79c314'), Color('487de7'), Color('4b369d'), Color('70369d'))) --gradient-steps (int > 0) [(int > 0) ...] Number of gradient steps to use. More steps will create a smoother gradient animation. (default: 12) --gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 5) --travel Display the gradient as a traveling wave (default: False) --travel-direction (diagonal, horizontal, vertical, radial) Direction the gradient travels across the canvas. (default: Direction.HORIZONTAL) --reverse-travel-direction Reverse the gradient travel direction. (default: False) --no-loop Do not loop the gradient. If not set, the gradient generation will loop the final gradient color back to the first gradient color. (default: False) --cycles (int > 0) Number of times to cycle the gradient. (default: 3) --skip-final-gradient Skip the final gradient. (default: False) --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 from bottom to top). If only one color is provided, the characters will be displayed in that color. (default: (Color('e81416'), Color('ffa500'), Color('faeb36'), Color('79c314'), Color('487de7'), Color('4b369d'), Color('70369d'))) --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. (default: Direction.VERTICAL) Example: terminaltexteffects colorshift --gradient-stops 0000ff ffffff 0000ff --gradient-steps 12 --gradient-frames 10 --cycles 3 --travel --travel-direction radial --final-gradient-stops 00c3ff ffff1c --final-gradient-steps 12 ``` --- ## Crumble Characters crumble into dust before being vacuumed up and reformed. ![Demo](./img/effects_demos/crumble_demo.gif) [Reference](./effects/crumble.md){ .md-button } [Config](./effects/crumble.md#terminaltexteffects.effects.effect_crumble.CrumbleConfig){ .md-button } ??? example "Crumble Command Line Arguments" ``` --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: ('5CE1FF', 'FF8C00')) --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. (default: Direction.DIAGONAL) Example: terminaltexteffects crumble --final-gradient-stops 5CE1FF FF8C00 --final-gradient-steps 12 --final-gradient-direction diagonal ``` --- ## Decrypt Movie style text decryption effect. ![Demo](./img/effects_demos/decrypt_demo.gif) [Reference](./effects/decrypt.md){ .md-button } [Config](./effects/decrypt.md#terminaltexteffects.effects.effect_decrypt.DecryptConfig){ .md-button } ??? example "Decrypt Command Line Arguments" ``` --typing-speed (int > 0) Number of characters typed per keystroke. (default: 1) --ciphertext-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the ciphertext. Color will be randomly selected for each character. (default: ('008000', '00cb00', '00ff00')) --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: ('eda000',)) --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. (default: Direction.VERTICAL) Example: terminaltexteffects decrypt --typing-speed 2 --ciphertext-colors 008000 00cb00 00ff00 --final-gradient-stops eda000 --final-gradient-steps 12 --final-gradient-direction vertical ``` --- ## ErrorCorrect Swaps characters from an incorrect initial position to the correct position. ![Demo](./img/effects_demos/errorcorrect_demo.gif) [Reference](./effects/errorcorrect.md){ .md-button } [Config](./effects/errorcorrect.md#terminaltexteffects.effects.effect_errorcorrect.ErrorCorrectConfig){ .md-button } ??? example "ErrorCorrect Command Line Arguments" ``` --error-pairs (int > 0) Percent of characters that are in the wrong position. This is a float between 0 and 1.0. 0.2 means 20 percent of the characters will be in the wrong position. (default: 0.1) --swap-delay (int > 0) Number of frames between swaps. (default: 10) --error-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the characters that are in the wrong position. (default: e74c3c) --correct-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the characters once corrected, this is a gradient from error-color and fades to final-color. (default: 45bf55) --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: ('8A008A', '00D1FF', 'FFFFFF')) --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. (default: Direction.VERTICAL) --movement-speed (float > 0) Speed of the characters while moving to the correct position. Valid values are n > 0. Adjust speed and animation rate separately to fine tune the effect. (default: 0.5) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects errorcorrect --error-pairs 0.1 --swap-delay 10 --error-color e74c3c --correct-color 45bf55 --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --movement-speed 0.5 ``` --- ## Expand Characters expand from the center. ![Demo](./img/effects_demos/expand_demo.gif) [Reference](./effects/expand.md){ .md-button } [Config](./effects/expand.md#terminaltexteffects.effects.effect_expand.ExpandConfig){ .md-button } ??? example "Expand Command Line Arguments" ``` --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: ('8A008A', '00D1FF', 'FFFFFF')) --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-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 5) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) --movement-speed (float > 0) Movement speed of the characters. (default: 0.35) --expand-easing EXPAND_EASING Easing function to use for character movement. (default: in_out_quart) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects expand --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --final-gradient-frames 5 --movement-speed 0.35 --expand-easing IN_OUT_QUART ``` --- ## Fireworks Launches characters up the screen where they explode like fireworks and fall into place. ![Demo](./img/effects_demos/fireworks_demo.gif) [Reference](./effects/fireworks.md){ .md-button } [Config](./effects/fireworks.md#terminaltexteffects.effects.effect_fireworks.FireworksConfig){ .md-button } ??? example "Fireworks Command Line Arguments" ``` --explode-anywhere If set, fireworks explode anywhere in the canvas. Otherwise, fireworks explode above highest settled row of text. (default: False) --firework-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated list of colors from which firework colors will be randomly selected. (default: ('88F7E2', '44D492', 'F5EB67', 'FFA15C', 'FA233E')) --firework-symbol (ASCII/UTF-8 character) Symbol to use for the firework shell. (default: o) --firework-volume (0 <= float(n) <= 1) Percent of total characters in each firework shell. (default: 0.02) --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: ('8A008A', '00D1FF', 'FFFFFF')) --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. (default: Direction.HORIZONTAL) --launch-delay (int >= 0) Number of frames to wait between launching each firework shell. +/- 0-50 percent randomness is applied to this value. (default: 60) --explode-distance (0 <= float(n) <= 1) Maximum distance from the firework shell origin to the explode waypoint as a percentage of the total canvas width. (default: 0.1) Example: terminaltexteffects fireworks --firework-colors 88F7E2 44D492 F5EB67 FFA15C FA233E --firework-symbol o --firework-volume 0.02 --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --launch-delay 60 --explode-distance 0.1 --explode-anywhere ``` --- ## Highlight Run a specular highlight across the text. ![Demo](./img/effects_demos/highlight_demo.gif) [Reference](./effects/highlight.md){ .md-button } [Config](./effects/highlight.md#terminaltexteffects.effects.effect_highlight.HighlightConfig){ .md-button } ??? example "Highlight Command Line Arguments" ``` --highlight-brightness (float > 0) Brightness of the highlight color. Values less than 1 will darken the highlight color, while values greater than 1 will brighten the highlight color. (default: 1.75) --highlight-direction {column_left_to_right,column_right_to_left,row_top_to_bottom,row_bottom_to_top,diagonal_top_left_to_bottom_right,diagonal_bottom_left_to_top_right,diagonal_top_right_to_bottom_left,diagonal_bottom_right_to_top_left,outside_to_center,center_to_outside} Direction the highlight will travel. (default: diagonal_bottom_left_to_top_right) --highlight-width (int > 0) Width of the highlight. n >= 1 (default: 8) --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 from bottom to top). If only one color is provided, the characters will be displayed in that color. (default: (Color('8A008A'), Color('00D1FF'), Color('FFFFFF'))) --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. (default: Direction.VERTICAL) Example: terminaltexteffects highlight --highlight-brightness 1.5 --highlight-direction diagonal_bottom_left_to_top_right --highlight-width 8 --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --final-gradient-direction vertical ``` --- ## LaserEtch A laser etches characters onto the terminal. ![Demo](./img/effects_demos/laseretch_demo.gif) [Reference](./effects/laseretch.md){ .md-button } [Config](./effects/laseretch.md#terminaltexteffects.effects.effect_laseretch.LaserEtchConfig){ .md-button } ??? example "LaserEtch Command Line Arguments" ``` --etch-direction {column_left_to_right,column_right_to_left,row_top_to_bottom,row_bottom_to_top,diagonal_top_left_to_bottom_right,diagonal_bottom_left_to_top_right,diagonal_top_right_to_bottom_left,diagonal_bottom_right_to_top_left,outside_to_center,center_to_outside} Pattern used to etch the text. (default: row_top_to_bottom) --etch-speed (int > 0) Along with etch_delay, determines the speed at which the characters are etched onto the terminal. This value specifies the number of characters to etch simultaneously. (default: 1) --etch-delay (int >= 0) Along with etch_speed, determines the speed at which the characters are etched onto the terminal. This values specifies the number of frames to wait before etching the next set of characters. (default: 3) --cool-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 gradient used to cool the characters after etching. If only one color is provided, the characters will be displayed in that color. (default: (Color('ffe680'), Color('ff7b00'))) --laser-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 laser gradient. If only one color is provided, the characters will be displayed in that color. (default: (Color('ffffff'), Color('376cff'))) --spark-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 spark cooling gradient. If only one color is provided, the characters will be displayed in that color. (default: (Color('ffffff'), Color('ffe680'), Color('ff7b00'), Color('1a0900'))) --spark-cooling-frames (int > 0) Number of frames to display each spark cooling gradient step. Increase to slow down the rate of cooling. (default: 10) --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: (Color('8A008A'), Color('00D1FF'), Color('ffffff'))) --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: 8) --final-gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 5) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) Example: terminaltexteffects laseretch --etch-speed 2 --etch-delay 5 --etch-direction row_top_to_bottom --cool-gradient-stops ffe680 ff7b00 --laser-gradient-stops ffffff 376cff --spark-gradient-stops ffffff ffe680 ff7b00 1a0900 --spark-cooling-frames 10 --final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 8 --final-gradient-frames 5 --final-gradient-direction vertical ``` --- ## Matrix Matrix digital rain effect. ![Demo](./img/effects_demos/matrix_demo.gif) [Reference](./effects/matrix.md){ .md-button } [Config](./effects/matrix.md#terminaltexteffects.effects.effect_matrix.MatrixConfig){ .md-button } ??? example "Matrix Command Line Arguments" ``` --highlight-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the bottom of the rain column. (default: Color Code: dbffdb Color Appearance: █████) --rain-color-gradient (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the rain gradient. Colors are selected from the gradient randomly. If only one color is provided, the characters will be displayed in that color. (default: (Color(92be92), Color(185318))) --rain-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Space separated, unquoted, list of symbols to use for the rain. (default: ('2', '5', '9', '8', 'Z', '*', ')', ':', '.', '"', '=', '+', '-', '¦', '|', '_', 'ヲ', 'ア', 'ウ', 'エ', 'オ', 'カ', 'キ', 'ケ', 'コ', 'サ', 'シ', 'ス', 'セ', 'ソ', 'タ', 'ツ', 'テ', 'ナ', 'ニ', 'ヌ', 'ネ', 'ハ', 'ヒ', 'ホ', 'マ', 'ミ', 'ム', 'メ', 'モ', 'ヤ', 'ユ', 'ラ', 'リ', 'ワ')) --rain-fall-delay-range (hyphen separated int range e.g. '1-10') Range for the speed of the falling rain as determined by the delay between rows. Actual delay is randomly selected from the range. (default: (8, 25)) --rain-column-delay-range (hyphen separated int range e.g. '1-10') Range of frames to wait between adding new rain columns. (default: (5, 15)) --rain-time (int > 0) Time, in seconds, to display the rain effect before transitioning to the input text. (default: 15) --symbol-swap-chance (float > 0) Chance of swapping a character's symbol on each tick. (default: 0.005) --color-swap-chance (float > 0) Chance of swapping a character's color on each tick. (default: 0.001) --resolve-delay (int > 0) Number of frames to wait between resolving the next group of characters. This is used to adjust the speed of the final resolve phase. (default: 5) --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: Color Code: 389c38 Color Appearance: █████) --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-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 5) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) Example: tte matrix --rain-color-gradient 92be92 185318 --rain-symbols 2 5 9 8 Z : . = + - ¦ _ --rain-fall-delay-range 8-25 --rain-column-delay-range 5-15 --rain-time 15 --symbol-swap-chance 0.005 --color-swap-chance 0.001 --resolve-delay 5 --final-gradient-stops 389c38 --final-gradient-steps 12 --final-gradient-frames 5 --final-gradient-direction vertical --highlight-color dbffdb ``` --- ## MiddleOut Text expands in a single row or column in the middle of the canvas then out. ![Demo](./img/effects_demos/middleout_demo.gif) [Reference](./effects/middleout.md){ .md-button } [Config](./effects/middleout.md#terminaltexteffects.effects.effect_middleout.MiddleOutConfig){ .md-button } ??? example "MiddleOut Command Line Arguments" ``` --starting-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the initial text in the center of the canvas. (default: ffffff) --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: ('8A008A', '00D1FF', 'FFFFFF')) --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. (default: Direction.VERTICAL) --expand-direction {vertical,horizontal} Direction the text will expand. (default: vertical) --center-movement-speed (float > 0) Speed of the characters during the initial expansion of the center vertical/horiztonal line. Note: Speed effects the number of steps in the easing function. Adjust speed and animation rate separately to fine tune the effect. (default: 0.35) --full-movement-speed (float > 0) Speed of the characters during the final full expansion. Note: Speed effects the number of steps in the easing function. Adjust speed and animation rate separately to fine tune the effect. (default: 0.35) --center-easing CENTER_EASING Easing function to use for initial expansion. (default: in_out_sine) --full-easing FULL_EASING Easing function to use for full expansion. (default: in_out_sine) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects middleout --starting-color 8A008A --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --expand-direction vertical --center-movement-speed 0.35 --full-movement-speed 0.35 --center-easing IN_OUT_SINE --full-easing IN_OUT_SINE ``` --- ## OrbittingVolley Four launchers orbit the canvas firing volleys of characters inward to build the input text from the center out. ![Demo](./img/effects_demos/orbittingvolley_demo.gif) [Reference](./effects/orbittingvolley.md){ .md-button } [Config](./effects/orbittingvolley.md#terminaltexteffects.effects.effect_orbittingvolley.OrbittingVolleyConfig){ .md-button } ??? example "OrbittingVolley Command Line Arguments" ``` --top-launcher-symbol (ASCII/UTF-8 character) Symbol for the top launcher. (default: █) --right-launcher-symbol (ASCII/UTF-8 character) Symbol for the right launcher. (default: █) --bottom-launcher-symbol (ASCII/UTF-8 character) Symbol for the bottom launcher. (default: █) --left-launcher-symbol (ASCII/UTF-8 character) Symbol for the left launcher. (default: █) --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: ('FFA15C', '44D492')) --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. (default: Direction.RADIAL) --launcher-movement-speed (float > 0) Orbitting speed of the launchers. (default: 0.5) --character-movement-speed (float > 0) Speed of the launched characters. (default: 1) --volley-size (0 <= float(n) <= 1) Percent of total input characters each launcher will fire per volley. Lower limit of one character. (default: 0.03) --launch-delay (int >= 0) Number of animation ticks to wait between volleys of characters. (default: 50) --character-easing (Easing Function) Easing function to use for launched character movement. (default: out_sine) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects orbittingvolley --top-launcher-symbol █ --right-launcher-symbol █ --bottom-launcher-symbol █ --left-launcher-symbol █ --final-gradient-stops FFA15C 44D492 --final-gradient-steps 12 --launcher-movement-speed 0.5 --character-movement-speed 1 --volley-size 0.03 --launch-delay 50 --character-easing OUT_SINE ``` --- ## Overflow Input text overflows and scrolls the terminal in a random order until eventually appearing ordered. ![Demo](./img/effects_demos/overflow_demo.gif) [Reference](./effects/overflow.md){ .md-button } [Config](./effects/overflow.md#terminaltexteffects.effects.effect_overflow.OverflowConfig){ .md-button } ??? example "Overflow Command Line Arguments" ``` --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: ('8A008A', '00D1FF', 'FFFFFF')) --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. (default: Direction.VERTICAL) --overflow-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 overflow gradient. (default: ('f2ebc0', '8dbfb3', 'f2ebc0')) --overflow-cycles-range (hyphen separated int range e.g. '1-10') Number of cycles to overflow the text. (default: (2, 4)) --overflow-speed (int > 0) Speed of the overflow effect. (default: 3) Example: terminaltexteffects overflow --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --overflow-gradient-stops f2ebc0 8dbfb3 f2ebc0 --overflow-cycles-range 2-4 --overflow-speed 3 ``` --- ## Pour Pours the characters back and forth from the top, bottom, left, or right. ![Demo](./img/effects_demos/pour_demo.gif) [Reference](./effects/pour.md){ .md-button } [Config](./effects/pour.md#terminaltexteffects.effects.effect_pour.PourConfig){ .md-button } ??? example "Pour Command Line Arguments" ``` --pour-direction {up,down,left,right} Direction the text will pour. (default: down) --pour-speed (int > 0) Number of characters poured in per tick. Increase to speed up the effect. (default: 1) --movement-speed (float > 0) Movement speed of the characters. (default: 0.2) --gap (int >= 0) Number of frames to wait between each character in the pour effect. Increase to slow down effect and create a more defined back and forth motion. (default: 1) --starting-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color of the characters before the gradient starts. (default: ffffff) --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. If only one color is provided, the characters will be displayed in that color. (default: ('8A008A', '00D1FF', 'FFFFFF')) --final-gradient-steps (int > 0) Number of gradient steps to use. More steps will create a smoother and longer gradient animation. (default: (12,)) --final-gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 10) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) --easing EASING Easing function to use for character movement. (default: in_quad) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects pour --pour-direction down --movement-speed 0.2 --gap 1 --starting-color FFFFFF --final-gradient-stops 8A008A 00D1FF FFFFFF --easing IN_QUAD ``` --- ## Print Prints the input data one line at at time with a carriage return and line feed. ![Demo](./img/effects_demos/print_demo.gif) [Reference](./effects/print.md){ .md-button } [Config](./effects/print.md#terminaltexteffects.effects.effect_print.PrintConfig){ .md-button } ??? example "Print Command Line Arguments" ``` --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: ('02b8bd', 'c1f0e3', '00ffa0')) --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. (default: Direction.DIAGONAL) --print-head-return-speed (float > 0) Speed of the print head when performing a carriage return. (default: 1.25) --print-speed (int > 0) Speed of the print head when printing characters. (default: 1) --print-head-easing PRINT_HEAD_EASING Easing function to use for print head movement. (default: in_out_quad) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects print --final-gradient-stops 02b8bd c1f0e3 00ffa0 --final-gradient-steps 12 --print-head-return-speed 1.25 --print-speed 1 --print-head-easing IN_OUT_QUAD ``` --- ## Rain Rain characters from the top of the canvas. ![Demo](./img/effects_demos/rain_demo.gif) [Reference](./effects/rain.md){ .md-button } [Config](./effects/rain.md#terminaltexteffects.effects.effect_rain.RainConfig){ .md-button } ??? example "Rain Command Line Arguments" ``` --rain-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] List of colors for the rain drops. Colors are randomly chosen from the list. (default: ('00315C', '004C8F', '0075DB', '3F91D9', '78B9F2', '9AC8F5', 'B8D8F8', 'E3EFFC')) --movement-speed (hyphen separated float range e.g. '0.25-0.5') Falling speed range of the rain drops. (default: (0.1, 0.2)) --rain-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Space separated list of symbols to use for the rain drops. Symbols are randomly chosen from the list. (default: ('o', '.', ',', '*', '|')) --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: ('488bff', 'b2e7de', '57eaf7')) --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. (default: Direction.DIAGONAL) --easing (Easing Function) Easing function to use for character movement. (default: in_quart) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects rain --rain-symbols o . , "*" "|" --rain-colors 00315C 004C8F 0075DB 3F91D9 78B9F2 9AC8F5 B8D8F8 E3EFFC --final-gradient-stops 488bff b2e7de 57eaf7 --final-gradient-steps 12 --movement-speed 0.1-0.2 --easing IN_QUART ``` --- ## RandomSequence Prints the input data in a random sequence, one character at a time. ![Demo](./img/effects_demos/randomsequence_demo.gif) [Reference](./effects/randomsequence.md){ .md-button } [Config](./effects/randomsequence.md#terminaltexteffects.effects.effect_random_sequence.RandomSequenceConfig){ .md-button } ??? example "RandomSequence Command Line Arguments" ``` --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: ('8A008A', '00D1FF', 'FFFFFF')) --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-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 12) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) --speed (float > 0) Speed of the animation as a percentage of the total number of characters. (default: 0.004) Example: terminaltexteffects randomsequence --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --final-gradient-frames 12 --speed 0.004 ``` --- ## Rings Characters are dispersed and form into spinning rings. ![Demo](./img/effects_demos/rings_demo.gif) [Reference](./effects/rings.md){ .md-button } [Config](./effects/rings.md#terminaltexteffects.effects.effect_rings.RingsConfig){ .md-button } ??? example "Rings Command Line Arguments" ``` --ring-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the rings. (default: ('ab48ff', 'e7b2b2', 'fffebd')) --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: ('ab48ff', 'e7b2b2', 'fffebd')) --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. (default: Direction.VERTICAL) --ring-gap RING_GAP Distance between rings as a percent of the smallest canvas dimension. (default: 0.1) --spin-duration SPIN_DURATION Number of frames for each cycle of the spin phase. (default: 200) --spin-speed (hyphen separated float range e.g. '0.25-0.5') Range of speeds for the rotation of the rings. The speed is randomly selected from this range for each ring. (default: (0.25, 1.0)) --disperse-duration DISPERSE_DURATION Number of frames spent in the dispersed state between spinning cycles. (default: 200) --spin-disperse-cycles SPIN_DISPERSE_CYCLES Number of times the animation will cycles between spinning rings and dispersed characters. (default: 3) Example: terminaltexteffects rings --ring-colors ab48ff e7b2b2 fffebd --final-gradient-stops ab48ff e7b2b2 fffebd --final-gradient-steps 12 --ring-gap 0.1 --spin-duration 200 --spin-speed 0.25-1.0 --disperse-duration 200 --spin-disperse-cycles 3 ``` --- ## Scattered Text is scattered across the canvas and moves into position. ![Demo](./img/effects_demos/scattered_demo.gif) [Reference](./effects/scattered.md){ .md-button } [Config](./effects/scattered.md#terminaltexteffects.effects.effect_scattered.ScatteredConfig){ .md-button } ??? example "Scattered Command Line Arguments" ``` --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. If only one color is provided, the characters will be displayed in that color. (default: ('ff9048', 'ab9dff', 'bdffea')) --final-gradient-steps (int > 0) Number of gradient steps to use. More steps will create a smoother and longer gradient animation. (default: (12,)) --final-gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 12) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) --movement-speed (float > 0) Movement speed of the characters. (default: 0.5) --movement-easing MOVEMENT_EASING Easing function to use for character movement. (default: in_out_back) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects scattered --final-gradient-stops ff9048 ab9dff bdffea --final-gradient-steps 12 --final-gradient-frames 12 --movement-speed 0.5 --movement-easing IN_OUT_BACK ``` --- ## Slice Slices the input in half and slides it into place from opposite directions. ![Demo](./img/effects_demos/slice_demo.gif) [Reference](./effects/slice.md){ .md-button } [Config](./effects/slice.md#terminaltexteffects.effects.effect_slice.SliceConfig){ .md-button } ??? example "Slice Command Line Arguments" ``` --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: (Color(8A008A), Color(00D1FF), Color(FFFFFF))) --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. (default: Direction.DIAGONAL) --slice-direction {vertical,horizontal,diagonal} Direction of the slice. (default: vertical) --movement-speed (float > 0) Movement speed of the characters. (default: 0.15) --movement-easing MOVEMENT_EASING Easing function to use for character movement. (default: in_out_expo) Easing ------ Note: A prefix must be added to the function name (except LINEAR). All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- LINEAR - Linear easing SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects slice --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --slice-direction vertical--movement-speed 0.15 --movement-easing IN_OUT_EXPO ``` --- ## Slide Slide characters into view from outside the terminal. ![Demo](./img/effects_demos/slide_demo.gif) [Reference](./effects/slide.md){ .md-button } [Config](./effects/slide.md#terminaltexteffects.effects.effect_slide.SlideConfig){ .md-button } ??? example "Slide Command Line Arguments" ``` --movement-speed (float > 0) Speed of the characters. (default: 0.5) --grouping {row,column,diagonal} Direction to group characters. (default: row) --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. If only one color is provided, the characters will be displayed in that color. (default: ('833ab4', 'fd1d1d', 'fcb045')) --final-gradient-steps (int > 0) Number of gradient steps to use. More steps will create a smoother and longer gradient animation. (default: (12,)) --final-gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 10) --final-gradient-direction FINAL_GRADIENT_DIRECTION Direction of the gradient (vertical, horizontal, diagonal, center). (default: Direction.VERTICAL) --gap (int >= 0) Number of frames to wait before adding the next group of characters. Increasing this value creates a more staggered effect. (default: 3) --reverse-direction Reverse the direction of the characters. (default: False) --merge Merge the character groups originating from either side of the terminal. (--reverse-direction is ignored when merging) (default: False) --movement-easing (Easing Function) Easing function to use for character movement. (default: in_out_quad) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects slide --movement-speed 0.5 --grouping row --final-gradient-stops 833ab4 fd1d1d fcb045 --final-gradient-steps 12 --final-gradient-frames 10 --final-gradient-direction vertical --gap 3 --reverse-direction --merge --movement-easing OUT_QUAD ``` --- ## Smoke Smoke floods the canvas colorizing any characters it crosses. ![Demo](./img/effects_demos/smoke_demo.gif) [Reference](./effects/smoke.md){ .md-button } [Config](./effects/smoke.md#terminaltexteffects.effects.effect_smoke.SmokeConfig){ .md-button } ??? example "Smoke Command Line Arguments" ``` --starting-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color of the text before being colorized by the smoke. (default: Color Code: 7A7A7A Color Appearance: █████) --smoke-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Symbols to use for the smoke. Strings will be used in sequence to create an animation. (default: ('░', '▒', '▓', '▒', '░')) --smoke-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 smoke gradient. Smoke will transition through this gradient before moving through the final gradient stops. (default: (Color('242424'), Color('FFFFFF'))) --use-whole-canvas If True, the entire canvas will be flooded. Otherwise the effect is limited to the text boundary. (default: False) --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: (Color('8A008A'), Color('00D1FF'), Color('FFFFFF'))) --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. (default: Direction.VERTICAL) Example: terminaltexteffects smoke --starting-color 7A7A7A --smoke-symbols ░ ▒ ▓ ▒ ░ --smoke-gradient-stops 242424 FFFFFF --use-whole-canvas --final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 12 --final-gradient-direction vertical ``` --- ## Spotlights Spotlights search the text area, illuminating characters, before converging in the center and expanding. ![Demo](./img/effects_demos/spotlights_demo.gif) [Reference](./effects/spotlights.md){ .md-button } [Config](./effects/spotlights.md#terminaltexteffects.effects.effect_spotlights.SpotlightsConfig){ .md-button } ??? example "Spotlights Command Line Arguments" ``` --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: ('ab48ff', 'e7b2b2', 'fffebd')) --final-gradient-steps (int > 0) [(int > 0) ...] 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. (default: Direction.VERTICAL) --beam-width-ratio (float > 0) Width of the beam of light as min(width, height) // n of the input text. (default: 2.0) --beam-falloff (float >= 0) Distance from the edge of the beam where the brightness begins to fall off, as a percentage of total beam width. (default: 0.3) --search-duration (int > 0) Duration of the search phase, in frames, before the spotlights converge in the center. (default: 750) --search-speed-range (hyphen separated float range e.g. '0.25-0.5') Range of speeds for the spotlights during the search phase. The speed is a random value between the two provided values. (default: (0.25, 0.5)) --spotlight-count (int > 0) Number of spotlights to use. (default: 3) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects spotlights --final-gradient-stops ab48ff e7b2b2 fffebd --final-gradient-steps 12 --beam-width-ratio 2.0 --beam-falloff 0.3 --search-duration 750 --search-speed-range 0.25-0.5 --spotlight-count 3 ``` --- ## Spray Sprays the characters from a single point. ![Demo](./img/effects_demos/spray_demo.gif) [Reference](./effects/spray.md){ .md-button } [Config](./effects/spray.md#terminaltexteffects.effects.effect_spray.SprayConfig){ .md-button } ??? example "Spray Command Line Arguments" ``` --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: ('8A008A', '00D1FF', 'FFFFFF')) --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. (default: Direction.VERTICAL) --spray-position {n,ne,e,se,s,sw,w,nw,center} Position for the spray origin. (default: e) --spray-volume (float > 0) Number of characters to spray per tick as a percent of the total number of characters. (default: 0.005) --movement-speed (hyphen separated float range e.g. '0.25-0.5') Movement speed of the characters. (default: (0.4, 1.0)) --movement-easing MOVEMENT_EASING Easing function to use for character movement. (default: out_expo) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects spray --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --spray-position e --spray-volume 0.005 --movement-speed 0.4-1.0 --movement-easing OUT_EXPO ``` --- ## Swarm Characters are grouped into swarms and move around the terminal before settling into position. ![Demo](./img/effects_demos/swarm_demo.gif) [Reference](./effects/swarm.md){ .md-button } [Config](./effects/swarm.md#terminaltexteffects.effects.effect_swarm.SwarmConfig){ .md-button } ??? example "Swarm Command Line Arguments" ``` --base-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the swarms (default: ('31a0d4',)) --flash-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the character flash. Characters flash when moving. (default: f2ea79) --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: ('31b900', 'f0ff65')) --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. (default: Direction.HORIZONTAL) --swarm-size (0 <= float(n) <= 1) Percent of total characters in each swarm. (default: 0.1) --swarm-coordination (0 <= float(n) <= 1) Percent of characters in a swarm that move as a group. (default: 0.8) --swarm-area-count (hyphen separated int range e.g. '1-10') Range of the number of areas where characters will swarm. (default: (2, 4)) Example: terminaltexteffects swarm --base-color 31a0d4 --flash-color f2ea79 --final-gradient-stops 31b900 f0ff65 --final-gradient-steps 12 --swarm-size 0.1 --swarm-coordination 0.80 --swarm-area-count 2-4 ``` --- ## Sweep Sweep across the canvas to reveal uncolored text, reverse sweep to color the text. ![Demo](./img/effects_demos/sweep_demo.gif) [Reference](./effects/sweep.md){ .md-button } [Config](./effects/sweep.md#terminaltexteffects.effects.effect_sweep.SweepConfig){ .md-button } ??? example "Sweep Command Line Arguments" ``` --sweep-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Space separated list of symbols to use for the sweep shimmer. (default: ('█', '▓', '▒', '░')) --first-sweep-direction {column_left_to_right,column_right_to_left,row_top_to_bottom,row_bottom_to_top,diagonal_top_left_to_bottom_right,diagonal_bottom_left_to_top_right,diagonal_top_right_to_bottom_left,diagonal_bottom_right_to_top_left,outside_to_center,center_to_outside} Direction of the first sweep, revealing uncolored characters. (default: column_right_to_left) --second-sweep-direction {column_left_to_right,column_right_to_left,row_top_to_bottom,row_bottom_to_top,diagonal_top_left_to_bottom_right,diagonal_bottom_left_to_top_right,diagonal_top_right_to_bottom_left,diagonal_bottom_right_to_top_left,outside_to_center,center_to_outside} Direction of the second sweep, coloring the characters. (default: column_left_to_right) --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 from bottom to top). If only one color is provided, the characters will be displayed in that color. (default: (Color('8A008A'), Color('00D1FF'), Color('ffffff'))) --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: 8) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) Example: terminaltexteffects sweep --sweep-symbols '█' '▓' '▒' '░' --first-sweep-direction column_right_to_left --second-sweep-direction column_left_to_right --final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 8 8 8 --final-gradient-direction vertical ``` --- ## SynthGrid Create a grid which fills with characters dissolving into the final text. ![Demo](./img/effects_demos/synthgrid_demo.gif) [Reference](./effects/synthgrid.md){ .md-button } [Config](./effects/synthgrid.md#terminaltexteffects.effects.effect_synthgrid.SynthGridConfig){ .md-button } ??? example "SynthGrid Command Line Arguments" ``` --grid-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 grid gradient. (default: ('CC00CC', 'ffffff')) --grid-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,)) --grid-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the gradient for the grid color. (default: Direction.DIAGONAL) --text-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 text gradient. (default: ('8A008A', '00D1FF', 'FFFFFF')) --text-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,)) --text-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the gradient for the text color. (default: Direction.VERTICAL) --grid-row-symbol (ASCII/UTF-8 character) Symbol to use for grid row lines. (default: ─) --grid-column-symbol (ASCII/UTF-8 character) Symbol to use for grid column lines. (default: │) --text-generation-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Space separated, unquoted, list of characters for the text generation animation. (default: ('░', '▒', '▓')) --max-active-blocks (float > 0) Maximum percentage of blocks to have active at any given time. For example, if set to 0.1, 10 percent of the blocks will be active at any given time. (default: 0.1) Example: terminaltexteffects synthgrid --grid-gradient-stops CC00CC ffffff --grid-gradient-steps 12 --text-gradient-stops 8A008A 00D1FF FFFFFF --text-gradient-steps 12 --grid-row-symbol ─ --grid-column-symbol "│" --text-generation-symbols ░ ▒ ▓ --max-active-blocks 0.1 ``` --- ## Thunderstorm Create a thunderstorm in the terminal. ![Demo](./img/effects_demos/thunderstorm_demo.gif) [Reference](./effects/thunderstorm.md){ .md-button } [Config](./effects/thunderstorm.md#terminaltexteffects.effects.effect_thunderstorm.ThunderstormConfig){ .md-button } ??? example "Thunderstorm Command Line Arguments" ``` --lightning-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the lightning strike. (default: Color Code: 68A3E8 Color Appearance: █████) --glowing-text-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the text when glowing after a lightning strike. (default: Color Code: EF5411 Color Appearance: █████) --text-glow-time (int > 0) Duration, in number of frames, for the glowing/cooling animation for post-lightning text glow. (default: 6) --raindrop-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Symbols to use for the raindrops. (default: ('\\', '.', ',')) --spark-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Symbols to use for the lightning impact sparks. (default: ('*', '.', "'")) --spark-glow-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color for the spark glow after a lightning strike. (default: Color Code: ff4d00 Color Appearance: █████) --spark-glow-time (int > 0) Duration, in number of frames, for the cooling animation for post-lightning sparks. (default: 18) --storm-time (int > 0) Duration, in seconds, the storm will occur. (default: 12) --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: (Color('8A008A'), Color('00D1FF'), Color('FFFFFF'))) --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-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 3) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) terminaltexteffects thunderstorm --lightning-color 68A3E8 --glowing-text-color EF5411 --text-glow-time 10 --raindrop-symbols '\' '.' ',' --spark-symbols '*' '.' '`' --spark-glow-color ff4d00 --spark-glow-time 30 --storm-time 10 --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --final-gradient-frames 5 --final-gradient-direction vertical ``` --- ## Unstable Spawns characters jumbled, explodes them to the edge of the canvas, then reassembles them. ![Demo](./img/effects_demos/unstable_demo.gif) [Reference](./effects/unstable.md){ .md-button } [Config](./effects/unstable.md#terminaltexteffects.effects.effect_unstable.UnstableConfig){ .md-button } ??? example "Unstable Command Line Arguments" ``` --unstable-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) Color transitioned to as the characters become unstable. (default: ff9200) --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: ('8A008A', '00D1FF', 'FFFFFF')) --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. (default: Direction.VERTICAL) --explosion-ease EXPLOSION_EASE Easing function to use for character movement during the explosion. (default: out_expo) --explosion-speed (float > 0) Speed of characters during explosion. (default: 0.75) --reassembly-ease REASSEMBLY_EASE Easing function to use for character reassembly. (default: out_expo) --reassembly-speed (float > 0) Speed of characters during reassembly. (default: 0.75) Easing ------ Note: A prefix must be added to the function name. All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects unstable --unstable-color ff9200 --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 --explosion-ease OUT_EXPO --explosion-speed 0.75 --reassembly-ease OUT_EXPO --reassembly-speed 0.75 ``` --- ## VHSTape Lines of characters glitch left and right and lose detail like an old VHS tape. ![Demo](./img/effects_demos/vhstape_demo.gif) [Reference](./effects/vhstape.md){ .md-button } [Config](./effects/vhstape.md#terminaltexteffects.effects.effect_vhstape.VHSTapeConfig){ .md-button } ??? example "VHSTape Command Line Arguments" ``` --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: ('ab48ff', 'e7b2b2', 'fffebd')) --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. (default: Direction.VERTICAL) --glitch-line-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the characters when a single line is glitching. Colors are applied in order as an animation. (default: ('ffffff', 'ff0000', '00ff00', '0000ff', 'ffffff')) --glitch-wave-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the characters in lines that are part of the glitch wave. Colors are applied in order as an animation. (default: ('ffffff', 'ff0000', '00ff00', '0000ff', 'ffffff')) --noise-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the characters during the noise phase. (default: ('1e1e1f', '3c3b3d', '6d6c70', 'a2a1a6', 'cbc9cf', 'ffffff')) --glitch-line-chance (0 <= float(n) <= 1) Chance that a line will glitch on any given frame. (default: 0.05) --noise-chance (0 <= float(n) <= 1) Chance that all characters will experience noise on any given frame. (default: 0.004) --total-glitch-time (int > 0) Total time, frames, that the glitching phase will last. (default: 1000) Example: terminaltexteffects vhstape --final-gradient-stops ab48ff e7b2b2 fffebd --final-gradient-steps 12 --glitch-line-colors ffffff ff0000 00ff00 0000ff ffffff --glitch-wave-colors ffffff ff0000 00ff00 0000ff ffffff --noise-colors 1e1e1f 3c3b3d 6d6c70 a2a1a6 cbc9cf ffffff --glitch-line-chance 0.05 --noise-chance 0.004 --total-glitch-time 1000 ``` --- ## Waves Waves travel across the terminal leaving behind the characters. ![Demo](./img/effects_demos/waves_demo.gif) [Reference](./effects/waves.md){ .md-button } [Config](./effects/waves.md#terminaltexteffects.effects.effect_waves.WavesConfig){ .md-button } ??? example "Waves Command Line Arguments" ``` --wave-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] Symbols to use for the wave animation. Multi-character strings will be used in sequence to create an animation. (default: ('▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂', '▁')) --wave-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: (Color(#f0ff65), Color(#ffb102), Color(#31a0d4), Color(#ffb102), Color(#f0ff65))) --wave-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: (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: (Color(#ffb102), Color(#31a0d4), Color(#f0ff65))) --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. (default: Direction.DIAGONAL) --wave-count WAVE_COUNT Number of waves to generate. n > 0. (default: 7) --wave-length (int > 0) The number of frames for each step of the wave. Higher wave-lengths will create a slower wave. (default: 2) --wave-direction {column_left_to_right,column_right_to_left,row_top_to_bottom,row_bottom_to_top,center_to_outside,outside_to_center} Direction of the wave. (default: column_left_to_right) --wave-easing WAVE_EASING Easing function to use for wave travel. (default: in_out_sine) Easing ------ Note: A prefix must be added to the function name (except LINEAR). All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- LINEAR - Linear easing SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. Example: terminaltexteffects waves --wave-symbols ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▇ ▆ ▅ ▄ ▃ ▂ ▁ --wave-gradient-stops f0ff65 ffb102 31a0d4 ffb102 f0ff65 --wave-gradient-steps 6 --final-gradient-stops ffb102 31a0d4 f0ff65 --final-gradient-steps 12 --wave-count 7 --wave-length 2 --wave-easing IN_OUT_SINE ``` --- ## Wipe Performs a wipe across the terminal to reveal characters. ![Demo](./img/effects_demos/wipe_demo.gif) [Reference](./effects/wipe.md){ .md-button } [Config](./effects/wipe.md#terminaltexteffects.effects.effect_wipe.WipeConfig){ .md-button } ??? example "Wipe Command Line Arguments" ``` --wipe-direction {column_left_to_right,column_right_to_left,row_top_to_bottom,row_bottom_to_top,diagonal_top_left_to_bottom_right,diagonal_bottom_left_to_top_right,diagonal_top_right_to_bottom_left,diagonal_bottom_right_to_top_left,outside_to_center,center_to_outside} Direction the text will wipe. (default: diagonal_bottom_left_to_top_right) --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 wipe gradient. (default: (Color(#833ab4), Color(#fd1d1d), Color(#fcb045))) --final-gradient-steps (int > 0) [(int > 0) ...] Number of gradient steps to use. More steps will create a smoother and longer gradient animation. (default: 12) --final-gradient-frames (int > 0) Number of frames to display each gradient step. Increase to slow down the gradient animation. (default: 5) --final-gradient-direction (diagonal, horizontal, vertical, radial) Direction of the final gradient. (default: Direction.VERTICAL) --wipe-delay (int >= 0) Number of frames to wait before adding the next character group. Increase, to slow down the effect. (default: 0) Example: terminaltexteffects wipe --wipe-direction diagonal_bottom_left_to_top_right --final-gradient-stops 833ab4 fd1d1d fcb045 --final-gradient-steps 12 --final-gradient-frames 5 --wipe-delay 0 ``` terminaltexteffects-release-0.15.0/effect_archive/000077500000000000000000000000001517776150200222435ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/effect_archive/effect_dev_spaceflight.py000066400000000000000000000241541517776150200272660ustar00rootroot00000000000000"""Effect Description. Classes: """ from __future__ import annotations import random from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.argutils import ArgSpec, ParserSpec def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "spaceflight", Effect, EffectConfig @dataclass class EffectConfig(BaseConfig): """Effect configuration dataclass.""" parser_spec: ParserSpec = ParserSpec( name="spaceflight", help="effect_description", description="effect_description", epilog=f"""{argutils.EASING_EPILOG} """, ) color_single: tte.Color = ArgSpec( name="--color-single", type=argutils.ColorArg.type_parser, default=tte.Color(0), metavar=argutils.ColorArg.METAVAR, help="Color for the ___.", ) # pyright: ignore[reportAssignmentType] "Color: Color for the ___." final_gradient_stops: tuple[tte.Color, ...] = ArgSpec( name="--final-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#FFFFFF")), metavar=argutils.ColorArg.METAVAR, help=( "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." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...]: 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." ) final_gradient_steps: tuple[int, ...] | int = ArgSpec( name="--final-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", action=argutils.TupleAction, default=12, metavar=argutils.PositiveInt.METAVAR, help=( "Space separated, unquoted, list of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int: Space separated, unquoted, list of the number of gradient steps to use. More " "steps will create a smoother and longer gradient animation." ) final_gradient_frames: int = ArgSpec( name="--final-gradient-frames", type=argutils.PositiveInt.type_parser, default=5, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to display each gradient step. Increase to slow down the gradient animation.", ) # pyright: ignore[reportAssignmentType] "int: Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: tte.Gradient.Direction = ArgSpec( name="--final-gradient-direction", type=argutils.GradientDirection.type_parser, default=tte.Gradient.Direction.VERTICAL, metavar=argutils.GradientDirection.METAVAR, help="Direction of the final gradient.", ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." movement_speed: float = ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=1, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the ___.", ) # pyright: ignore[reportAssignmentType] "float: Speed of the ___." easing: tte.easing.EasingFunction = ArgSpec( name="--easing", default=tte.easing.in_out_sine, type=argutils.Ease.type_parser, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction: Easing function to use for character movement." class EffectIterator(BaseEffectIterator[EffectConfig]): """Effect iterator for the NamedEffect effect.""" def __init__(self, effect: Effect) -> None: """Initialize the effect iterator. Args: effect (NamedEffect): The effect to iterate over. """ super().__init__(effect) self.pending_chars: list[tte.EffectCharacter] = [] self.character_final_color_map: dict[tte.EffectCharacter, tte.Color] = {} self.available_stars: set[tte.EffectCharacter] = set() self.active_stars: set[tte.EffectCharacter] = set() self.travel_frames = 0 self.build() def reset_star(self, star: tte.EffectCharacter) -> None: """Reset a star to the center of the canvas. Args: star (EffectCharacter): The star to reset. """ star.motion.set_coordinate(self.terminal.canvas.center) self.available_stars.add(star) star.motion.activate_path("star") star.animation.activate_scene("approach") self.terminal.set_character_visibility(star, is_visible=False) def spawn_star(self) -> None: """Pop an available star and make it visible and active.""" if not self.available_stars: return new_star = self.available_stars.pop() self.terminal.set_character_visibility(new_star, is_visible=True) self.active_characters.add(new_star) def build(self) -> None: """Build the effect.""" final_gradient = tte.Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] character.animation.set_appearance(colors=tte.ColorPair(self.character_final_color_map[character])) character.motion.set_coordinate(self.terminal.canvas.center) arrive_path = character.motion.new_path( path_id="arrive", speed=random.uniform(0.1, 0.4), hold_time=random.randint(300, 900), ) arrive_path.new_waypoint(character.input_coord) depart_path = character.motion.new_path(speed=random.uniform(0.4, 0.7)) if character.input_coord == self.terminal.canvas.center: depart_path.new_waypoint(self.terminal.canvas.random_coord(outside_scope=True)) else: depart_path.new_waypoint( tte.geometry.extrapolate_along_ray( self.terminal.canvas.center, character.input_coord, self.terminal.canvas.width, ), ) character.event_handler.register_event( tte.Event.PATH_COMPLETE, caller=arrive_path, action=tte.Action.ACTIVATE_PATH, target=depart_path, ) character.motion.activate_path(arrive_path) character.layer = 2 self.pending_chars.append(character) for _ in range(500): starting_symbol = random.choice([".", "`", "'", ","]) star_char = self.terminal.add_character(starting_symbol, coord=self.terminal.canvas.center) approach_scn = star_char.animation.new_scene(scene_id="approach", sync=tte.Scene.SyncMetric.DISTANCE) approach_scn.add_frame(symbol=starting_symbol, duration=random.randint(1, 3)) approach_scn.add_frame(symbol=random.choice(["*", "-", "x", "~", "."]), duration=1) star_path = star_char.motion.new_path( path_id="star", speed=random.uniform(0.01, 0.4), ease=tte.easing.in_quart, ) star_path.new_waypoint(self.terminal.canvas.random_coord(outside_scope=True)) star_char.motion.activate_path(star_path) star_char.animation.activate_scene("approach") star_char.event_handler.register_event( tte.Event.PATH_COMPLETE, star_path, action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(self.reset_star), ) self.available_stars.add(star_char) for _ in range(self.terminal.canvas.height * self.terminal.canvas.width // 20): stationary_star = self.terminal.add_character( symbol=random.choice([".", "`", "'", ",", "*"]), coord=self.terminal.canvas.random_coord(), ) self.terminal.set_character_visibility(stationary_star, is_visible=True) def __next__(self) -> str: """Return the next frame of the effect.""" if self.active_characters or self.available_stars: if self.travel_frames > 300: while self.pending_chars: character = self.pending_chars.pop() self.active_characters.add(character) self.terminal.set_character_visibility(character, is_visible=True) if random.random() < 0.5: for _ in range(random.randint(1, 4)): self.spawn_star() self.travel_frames += 1 self.update() return self.frame raise StopIteration class Effect(BaseEffect[EffectConfig]): """Effect description.""" @property def _config_cls(self) -> type[EffectConfig]: return EffectConfig @property def _iterator_cls(self) -> type[EffectIterator]: return EffectIterator terminaltexteffects-release-0.15.0/effect_archive/effect_dev_tesselated.py000066400000000000000000000200341517776150200271230ustar00rootroot00000000000000"""Effect Description. Classes: """ from __future__ import annotations from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.argutils import ArgSpec, ParserSpec from terminaltexteffects.utils.spanningtree.algo.breadthfirst import BreadthFirst from terminaltexteffects.utils.spanningtree.algo.primssimple import PrimsSimple def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "tess", Effect, EffectConfig @dataclass class EffectConfig(BaseConfig): """Effect configuration dataclass.""" parser_spec: ParserSpec = ParserSpec( name="tess", help="effect_description", description="effect_description", epilog=f"""{argutils.EASING_EPILOG} """, ) color_single: tte.Color = ArgSpec( name="--color-single", type=argutils.ColorArg.type_parser, default=tte.Color(0), metavar=argutils.ColorArg.METAVAR, help="Color for the ___.", ) # pyright: ignore[reportAssignmentType] "Color: Color for the ___." final_gradient_stops: tuple[tte.Color, ...] = ArgSpec( name="--final-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#FFFFFF")), metavar=argutils.ColorArg.METAVAR, help=( "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." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...]: 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." ) final_gradient_steps: tuple[int, ...] | int = ArgSpec( name="--final-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", default=12, metavar=argutils.PositiveInt.METAVAR, help=( "Space separated, unquoted, list of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int: Space separated, unquoted, list of the number of gradient steps to use. More " "steps will create a smoother and longer gradient animation." ) final_gradient_frames: int = ArgSpec( name="--final-gradient-frames", type=argutils.PositiveInt.type_parser, default=5, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to display each gradient step. Increase to slow down the gradient animation.", ) # pyright: ignore[reportAssignmentType] "int: Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: tte.Gradient.Direction = ArgSpec( name="--final-gradient-direction", type=argutils.GradientDirection.type_parser, default=tte.Gradient.Direction.VERTICAL, metavar=argutils.GradientDirection.METAVAR, help="Direction of the final gradient.", ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." movement_speed: float = ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=1, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the ___.", ) # pyright: ignore[reportAssignmentType] "float: Speed of the ___." easing: tte.easing.EasingFunction = ArgSpec( name="--easing", default=tte.easing.in_out_sine, type=argutils.Ease.type_parser, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction: Easing function to use for character movement." class EffectIterator(BaseEffectIterator[EffectConfig]): """Effect iterator for the NamedEffect effect.""" def __init__(self, effect: Effect) -> None: """Initialize the effect iterator. Args: effect (NamedEffect): The effect to iterate over. """ super().__init__(effect) self.pending_chars: list[tte.EffectCharacter] = [] self.character_final_color_map: dict[tte.EffectCharacter, tte.Color] = {} self.prims = PrimsSimple( self.terminal, starting_char=self.terminal.get_character_by_input_coord( self.terminal.canvas.text_center, ), ) self.breadth_first = BreadthFirst( self.terminal, starting_char=self.terminal.get_character_by_input_coord( self.terminal.canvas.text_center, ), limit_to_text_boundary=True, ) self.state = "pulse" self.sequence_easer = tte.easing.SequenceEaser( self.terminal.get_characters(), easing_function=tte.easing.in_out_expo, ) self.chars_activated = 0 self.total_chars = len( self.terminal.get_characters(inner_fill_chars=True, outer_fill_chars=True), ) self.build() def build(self) -> None: """Build the effect.""" tesselated_chars = [("/", "_", "\\", "_"), ("\\", "_", "/", "_")] final_gradient = tte.Gradient( *self.config.final_gradient_stops, steps=self.config.final_gradient_steps, ) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) glow_gradient = tte.Gradient( tte.Color("#000000"), tte.Color("#1F00AC"), tte.Color("#00EEFF"), tte.Color("#FFFFFF"), tte.Color("#00EEFF"), tte.Color("#1F00AC"), steps=(3, 3, 3, 3, 3), loop=True, ) for character in self.terminal.get_characters(inner_fill_chars=True, outer_fill_chars=True): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] character.animation.set_appearance( colors=tte.ColorPair( fg=self.character_final_color_map[character], ), ) self.terminal.set_character_visibility(character, is_visible=True) char = tesselated_chars[character.input_coord.row % 2][character.input_coord.column % 4] glow_scn = character.animation.new_scene(scene_id="glow") for color in glow_gradient: glow_scn.add_frame(symbol=char, duration=1, colors=tte.ColorPair(fg=color)) glow_scn.add_frame( symbol=character.input_symbol, duration=1, colors=tte.ColorPair(fg=final_gradient_mapping[character.input_coord]), ) while not self.prims.complete: self.prims.step() def __next__(self) -> str: """Return the next frame of the effect.""" if self.active_characters or self.state != "complete": self.update() return self.frame raise StopIteration class Effect(BaseEffect[EffectConfig]): """Effect description.""" @property def _config_cls(self) -> type[EffectConfig]: return EffectConfig @property def _iterator_cls(self) -> type[EffectIterator]: return EffectIterator terminaltexteffects-release-0.15.0/effect_archive/effect_dev_worm.py000066400000000000000000000204001517776150200257470ustar00rootroot00000000000000"""Effect Description. Classes: """ from __future__ import annotations import random from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.argutils import ArgSpec, ParserSpec from terminaltexteffects.utils.spanningtree.algo.aldousbroder import AldousBroder def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "worm", Effect, EffectConfig @dataclass class EffectConfig(BaseConfig): """Effect configuration dataclass.""" parser_spec: ParserSpec = ParserSpec( name="worm", help="effect_description", description="effect_description", epilog=f"""{argutils.EASING_EPILOG} """, ) color_single: tte.Color = ArgSpec( name="--color-single", type=argutils.ColorArg.type_parser, default=tte.Color(0), metavar=argutils.ColorArg.METAVAR, help="Color for the ___.", ) # pyright: ignore[reportAssignmentType] "Color: Color for the ___." final_gradient_stops: tuple[tte.Color, ...] = ArgSpec( name="--final-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#FFFFFF")), metavar=argutils.ColorArg.METAVAR, help=( "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." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...]: 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." ) final_gradient_steps: tuple[int, ...] | int = ArgSpec( name="--final-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", default=12, metavar=argutils.PositiveInt.METAVAR, help=( "Space separated, unquoted, list of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int: Space separated, unquoted, list of the number of gradient steps to use. More " "steps will create a smoother and longer gradient animation." ) final_gradient_frames: int = ArgSpec( name="--final-gradient-frames", type=argutils.PositiveInt.type_parser, default=5, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to display each gradient step. Increase to slow down the gradient animation.", ) # pyright: ignore[reportAssignmentType] "int: Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: tte.Gradient.Direction = ArgSpec( name="--final-gradient-direction", type=argutils.GradientDirection.type_parser, default=tte.Gradient.Direction.VERTICAL, metavar=argutils.GradientDirection.METAVAR, help="Direction of the final gradient.", ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." movement_speed: float = ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=1, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the ___.", ) # pyright: ignore[reportAssignmentType] "float: Speed of the ___." easing: tte.easing.EasingFunction = ArgSpec( name="--easing", default=tte.easing.in_out_sine, type=argutils.Ease.type_parser, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction: Easing function to use for character movement." class EffectIterator(BaseEffectIterator[EffectConfig]): """Effect iterator for the NamedEffect effect.""" def __init__(self, effect: Effect) -> None: """Initialize the effect iterator. Args: effect (NamedEffect): The effect to iterate over. """ super().__init__(effect) self.pending_chars: list[tte.EffectCharacter] = [] self.character_final_color_map: dict[tte.EffectCharacter, tte.Color] = {} self.alg = AldousBroder(self.terminal) self.worm_body: list[tte.EffectCharacter] = [] self.worm_body_dir_map = {"north": "|", "south": "|", "east": "-", "west": "-"} self.skipped_frames = 0 self.speed_up = 4 self.build() def update_worm(self) -> None: current_pos = self.alg.char_last_linked or self.alg.linked_char_last_visited if current_pos is None: return current_pos.animation.set_appearance("o") self.worm_body.insert(0, current_pos) if len(self.worm_body) > 1: for direction, symbol in self.worm_body_dir_map.items(): if current_pos is self.worm_body[1].neighbors.get(direction): self.worm_body[1].animation.set_appearance(symbol) break if len(self.worm_body) > 9: removed_char = self.worm_body.pop() if removed_char not in self.worm_body: removed_char.animation.set_appearance( removed_char.input_symbol, colors=tte.ColorPair(self.character_final_color_map.get(removed_char)), ) def build(self) -> None: """Build the effect.""" final_gradient = tte.Gradient( *self.config.final_gradient_stops, steps=self.config.final_gradient_steps, ) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[ character.input_coord ] for char in self.terminal.get_characters(inner_fill_chars=True, outer_fill_chars=True): char.animation.set_appearance( "█", colors=tte.ColorPair( random.choice(("#5E3501", "#422601", "#A86002", "#584730", "#8A7861")), ), ) self.terminal.set_character_visibility(char, is_visible=True) def __next__(self) -> str: """Return the next frame of the effect.""" if not self.alg.complete or self.active_characters: if not self.alg.complete: self.alg.step() self.update_worm() while not self.alg.char_last_linked and self.skipped_frames < self.speed_up: self.alg.step() self.update_worm() self.skipped_frames += 1 if self.skipped_frames > self.speed_up: self.speed_up += 1 self.skipped_frames = 0 if linked_char := self.alg.char_last_linked: self.speed_up = 4 linked_char.animation.set_appearance( symbol=linked_char.input_symbol, colors=tte.ColorPair(self.character_final_color_map.get(linked_char)), ) self.update() return self.frame raise StopIteration class Effect(BaseEffect[EffectConfig]): """Effect description.""" @property def _config_cls(self) -> type[EffectConfig]: return EffectConfig @property def _iterator_cls(self) -> type[EffectIterator]: return EffectIterator terminaltexteffects-release-0.15.0/flake.lock000066400000000000000000000017671517776150200212550ustar00rootroot00000000000000{ "nodes": { "nixpkgs": { "locked": { "lastModified": 1717112898, "narHash": "sha256-7R2ZvOnvd9h8fDd65p0JnB7wXfUvreox3xFdYWd1BnY=", "owner": "nixos", "repo": "nixpkgs", "rev": "6132b0f6e344ce2fe34fc051b72fb46e34f668e0", "type": "github" }, "original": { "owner": "nixos", "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "nixpkgs": "nixpkgs", "systems": "systems" } }, "systems": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { "owner": "nix-systems", "repo": "default", "type": "github" } } }, "root": "root", "version": 7 } terminaltexteffects-release-0.15.0/flake.nix000066400000000000000000000010171517776150200211070ustar00rootroot00000000000000{ description = "Visual effects applied to text in the terminal. "; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; systems.url = "github:nix-systems/default"; }; outputs = { systems, nixpkgs, ... }: let forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { formatter = forEachSystem (pkgs: pkgs.alejandra); packages = forEachSystem (pkgs: { default = pkgs.callPackage ./default.nix {}; }); }; } terminaltexteffects-release-0.15.0/mkdocs.yml000066400000000000000000000075121517776150200213160ustar00rootroot00000000000000site_name: TerminalTextEffects Docs site_description: TerminalTextEffects Documentation site_author: ChrisBuilds repo_url: https://github.com/ChrisBuilds/terminaltexteffects docs_dir: docs theme: name: material palette: scheme: slate features: - content.code.copy - content.code.annotate plugins: - mkdocstrings markdown_extensions: - admonition - pymdownx.superfences - pymdownx.superfences: custom_fences: - name: mermaid class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.details - pymdownx.caret - pymdownx.tilde - attr_list - def_list nav: - Intro to TTE: index.md - Change[B]log: - changeblog/changeblog.md - changeblog/changeblog_0.15.0.md - changeblog/changeblog_0.14.0.md - changeblog/changeblog_0.13.0.md - changeblog/changeblog_0.12.0.md - changeblog/changeblog_0.11.0.md - changeblog/changeblog_0.10.0.md - How to install & use TTE: - Install: installation.md - Application Usage: appguide.md - Library Usage: libguide.md # - Effect Building Guide: # - effectguide/effectguide.md # - effectguide/effectguide_lesson0.md - Effects Showroom: showroom.md - Library Cookbook: cookbook.md - Reference: - Engine: - engine/baseeffect.md - engine/basecharacter.md - engine/baseconfig.md - engine/eventhandler.md - Animation: - engine/animation/animation.md - engine/animation/charactervisual.md - engine/animation/frame.md - engine/animation/scene.md - Motion: - engine/motion/motion.md - engine/motion/waypoint.md - engine/motion/segment.md - engine/motion/path.md - Terminal: - engine/terminal/terminal.md - engine/terminal/terminalconfig.md - engine/terminal/canvas.md - Utils: - engine/utils/ansitools.md - engine/utils/argutils.md - engine/utils/color.md - engine/utils/colorpair.md - engine/utils/colorterm.md - engine/utils/easing.md - engine/utils/exceptions.md - engine/utils/geometry.md - engine/utils/gradient.md - engine/utils/hexterm.md - SpanningTree: - engine/utils/spanningtree/base_generator.md - Algorithms: - engine/utils/spanningtree/algos/aldous_broder.md - engine/utils/spanningtree/algos/recursive_backtracker.md - engine/utils/spanningtree/algos/prims_simple.md - engine/utils/spanningtree/algos/prims_weighted.md - engine/utils/spanningtree/algos/breadthfirst.md - Effects: - effects/beams.md - effects/binarypath.md - effects/blackhole.md - effects/bouncyballs.md - effects/bubbles.md - effects/burn.md - effects/colorshift.md - effects/crumble.md - effects/decrypt.md - effects/errorcorrect.md - effects/expand.md - effects/fireworks.md - effects/highlight.md - effects/laseretch.md - effects/matrix.md - effects/middleout.md - effects/orbittingvolley.md - effects/overflow.md - effects/pour.md - effects/print.md - effects/rain.md - effects/randomsequence.md - effects/rings.md - effects/scattered.md - effects/slice.md - effects/slide.md - effects/smoke.md - effects/spotlights.md - effects/spray.md - effects/swarm.md - effects/sweep.md - effects/synthgrid.md - effects/thunderstorm.md - effects/unstable.md - effects/vhstape.md - effects/waves.md - effects/wipe.md terminaltexteffects-release-0.15.0/pyproject.toml000066400000000000000000000044341517776150200222270ustar00rootroot00000000000000[project] name = "terminaltexteffects" version = "0.15.0" description = "TerminalTextEffects (TTE) is a terminal visual effects engine." authors = [{ name = "Chris", email = "741258@pm.me" }] requires-python = ">=3.8" readme = "README.md" license = "MIT" [project.urls] Repository = "https://github.com/ChrisBuilds/terminaltexteffects" Documentation = "https://chrisbuilds.github.io/terminaltexteffects/" [project.scripts] tte = "terminaltexteffects.__main__:main" terminaltexteffects = "terminaltexteffects.__main__:main" [project.optional-dependencies] docs = [ "mkdocs>=1.6.1", "mkdocs-material>=9.6.22", "mkdocstrings-python>=1.11.1", "pymdown-extensions>=10.15", ] [tool.ruff] show-fixes = true unsafe-fixes = true line-length = 120 target-version = "py38" [tool.ruff.lint] ignore = [ "C90", "ANN401", "EXE", "PLR0912", # too many branches "PLR0913", # too many function args "PLR2004", # magic numbers "RUF009", # function-call-in-dataclass-default-argument "S101", # use of assert "S311", # suspicious-non-cryptographic-random-usage "SLF001", # private member access "TRY003", # f-strings in exception message "T201", # printing ] select = ["ALL"] fixable = ["ALL"] extend-fixable = ["EM102"] typing-extensions = false [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "D104"] "**/tests/*" = [ "S101", # assert ] [tool.pytest.ini_options] console_output_style = "progress" minversion = "6.0" addopts = ["--capture=sys", "--strict-markers", "--strict-config", "-ra"] markers = [ "manual: subset to run manually", "visual: visually inspect effects", "effects: quick effect tests", "engine: engine tests", "animation: animation tests", "motion: motion tests", "terminal: terminal tests", "base_character: base character tests", "utils: utility tests", "smoke: quick tests covering over 90% of code", ] testpaths = ["tests"] [tool.hatch.build.targets.sdist] exclude = ["docs/**", "tests/**"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] dev = [ "mkdocs>=1.6.1", "mkdocs-material>=9.6.22", "mkdocstrings-python>=1.11.1", "pymdown-extensions>=10.15", "pytest>=8.3.5", "pytest-cov>=5.0.0", "pytest-xdist>=3.6.1", ] terminaltexteffects-release-0.15.0/terminaltexteffects/000077500000000000000000000000001517776150200233665ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/__init__.py000066400000000000000000000012311517776150200254740ustar00rootroot00000000000000"""Terminal Text Effects package. This package provides various text effects for terminal applications. """ from terminaltexteffects.engine.animation import Animation, Scene from terminaltexteffects.engine.base_character import EffectCharacter, EventHandler from terminaltexteffects.engine.motion import ( Motion, Path, Segment, Waypoint, ) from terminaltexteffects.engine.terminal import Terminal from terminaltexteffects.utils import easing, geometry, graphics from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient Event = EventHandler.Event Action = EventHandler.Action terminaltexteffects-release-0.15.0/terminaltexteffects/__main__.py000066400000000000000000000214721517776150200254660ustar00rootroot00000000000000"""Package entry point for the TerminalTextEffects command line interface.""" from __future__ import annotations import argparse import importlib import importlib.util import os import pkgutil import random import sys from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import TYPE_CHECKING import terminaltexteffects.effects from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.exceptions import UnsupportedAnsiSequenceError from terminaltexteffects.utils.shell_completion import SUPPORTED_SHELLS, get_completion_script if TYPE_CHECKING: from types import ModuleType from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.engine.base_effect import BaseEffect 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. 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. """ parser = argparse.ArgumentParser( prog="tte", description="A terminal visual effects engine, application, and library", epilog="Ex: ls -a | tte decrypt --typing-speed 2 --ciphertext-colors 008000 00cb00 00ff00 " "--final-gradient-stops eda000 --final-gradient-steps 12 --final-gradient-direction vertical", ) parser.add_argument("--input-file", "-i", type=str, help="File to read input from") parser.add_argument( "--version", "-v", action="version", version="TerminalTextEffects " + _get_version(), ) parser.add_argument( "--print-completion", choices=SUPPORTED_SHELLS, help="Print a shell completion script for the requested shell and exit.", ) parser.add_argument("--random-effect", "-R", action="store_true", help="Randomly select an effect to apply") parser.add_argument( "--seed", type=int, default=None, help="Seed to use for random effect selection", ) random_include_exclude_group = parser.add_mutually_exclusive_group() random_include_exclude_group.add_argument( "--include-effects", type=str, nargs="+", help="Space-separated list of Effects to include when randomly selecting an effect", ) random_include_exclude_group.add_argument( "--exclude-effects", type=str, nargs="+", help="Space-separated list of Effects to exclude when randomly selecting an effect", ) # Future: add a CLI argument for a default text color so dynamic color-handling effects can use # it when input characters have no parsed colors. TerminalConfig._populate_parser(parser) subparsers = parser.add_subparsers( title="Effect", description="Name of the effect to apply. Use -h for effect specific help.", help="Available Effects", required=False, dest="effect", ) 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) return parser, effect_resource_map def build_parsers_and_parse_args() -> tuple[argparse.Namespace, dict[str, tuple[type[BaseEffect], type[BaseConfig]]]]: """Build the CLI parser, discover available effects, and parse arguments.""" parser, effect_resource_map = build_parser() return parser.parse_args(), effect_resource_map def _get_version() -> str: """Return the installed package version or a local-development fallback.""" try: return version("terminaltexteffects") except PackageNotFoundError: project_file = Path(__file__).resolve().parents[1] / "pyproject.toml" for line in project_file.read_text(encoding="utf-8").splitlines(): if line.startswith('version = "'): return line.removeprefix('version = "').removesuffix('"') return "unknown" def main() -> None: """Run the terminaltexteffects command line interface. Parse CLI arguments, load input text, choose and configure the requested effect, and stream rendered frames to the terminal. The process exits with status `1` for missing input, invalid effect selection, input file read failures, or keyboard interruption. """ args, effect_resource_map = build_parsers_and_parse_args() if args.print_completion: parser, _ = build_parser() print(get_completion_script(args.print_completion, parser), end="") return if args.seed is not None: random.seed(args.seed) if args.input_file: try: input_data = Path(args.input_file).read_text(encoding="UTF-8") except FileNotFoundError: print(f"File not found: {args.input_file}") sys.exit(1) except Exception as e: # noqa: BLE001 print(f"Error reading file: {args.input_file} - {e}") sys.exit(1) else: input_data = Terminal.get_piped_input() if not input_data.strip(): print("NO INPUT.") sys.exit(1) if args.random_effect: if args.include_effects: available_effects = [effect for effect in effect_resource_map if effect in args.include_effects] elif args.exclude_effects: available_effects = [effect for effect in effect_resource_map if effect not in args.exclude_effects] else: available_effects = list(effect_resource_map) if not available_effects: print("Error: No effects available for random selection based on include/exclude filters.\n") sys.exit(1) args.effect = random.choice(available_effects) elif not args.effect: print("Error: No effect specified. Must specify an effect or use --random-effect.\n") sys.exit(1) effect_class, effect_config_class = effect_resource_map[args.effect] terminal_config = TerminalConfig._build_config(args) effect_config = effect_config_class._build_config(None if args.random_effect else args) effect = effect_class(input_data, effect_config, terminal_config) try: with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) except UnsupportedAnsiSequenceError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) except KeyboardInterrupt: sys.exit(1) if __name__ == "__main__": main() terminaltexteffects-release-0.15.0/terminaltexteffects/effects/000077500000000000000000000000001517776150200250055ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/effects/__init__.py000066400000000000000000000045571517776150200271310ustar00rootroot00000000000000"""TerminalTextEffects effects module.""" from terminaltexteffects.effects.effect_beams import Beams from terminaltexteffects.effects.effect_binarypath import BinaryPath from terminaltexteffects.effects.effect_blackhole import Blackhole from terminaltexteffects.effects.effect_bouncyballs import BouncyBalls from terminaltexteffects.effects.effect_bubbles import Bubbles from terminaltexteffects.effects.effect_burn import Burn 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_errorcorrect import ErrorCorrect from terminaltexteffects.effects.effect_expand import Expand from terminaltexteffects.effects.effect_fireworks import Fireworks from terminaltexteffects.effects.effect_highlight import Highlight from terminaltexteffects.effects.effect_laseretch import LaserEtch from terminaltexteffects.effects.effect_matrix import Matrix from terminaltexteffects.effects.effect_middleout import MiddleOut from terminaltexteffects.effects.effect_orbittingvolley import OrbittingVolley from terminaltexteffects.effects.effect_overflow import Overflow from terminaltexteffects.effects.effect_pour import Pour from terminaltexteffects.effects.effect_print import Print from terminaltexteffects.effects.effect_rain import Rain from terminaltexteffects.effects.effect_random_sequence import RandomSequence from terminaltexteffects.effects.effect_rings import Rings from terminaltexteffects.effects.effect_scattered import Scattered from terminaltexteffects.effects.effect_slice import Slice from terminaltexteffects.effects.effect_slide import Slide from terminaltexteffects.effects.effect_smoke import Smoke from terminaltexteffects.effects.effect_spotlights import Spotlights from terminaltexteffects.effects.effect_spray import Spray from terminaltexteffects.effects.effect_swarm import Swarm from terminaltexteffects.effects.effect_sweep import Sweep from terminaltexteffects.effects.effect_synthgrid import SynthGrid from terminaltexteffects.effects.effect_thunderstorm import Thunderstorm from terminaltexteffects.effects.effect_unstable import Unstable from terminaltexteffects.effects.effect_vhstape import VHSTape from terminaltexteffects.effects.effect_waves import Waves from terminaltexteffects.effects.effect_wipe import Wipe terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_beams.py000066400000000000000000000517171517776150200277750ustar00rootroot00000000000000"""Creates beams which travel over the canvas illuminating the characters. Classes: Beams: Creates beams which travel over the canvas illuminating the characters. BeamsConfig: Configuration for the Beams effect. BeamsIterator: Iterates over the Beams effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "beams", Beams, BeamsConfig @dataclass class BeamsConfig(BaseConfig): """Configuration for the Beams effect. Attributes: beam_row_symbols (tuple[str, ...]): Symbols to use for the beam effect when moving along a row. Strings will be used in sequence to create an animation. beam_column_symbols (tuple[str, ...]): Symbols to use for the beam effect when moving along a column. Strings will be used in sequence to create an animation. beam_delay (int): Number of frames to wait before adding the next group of beams. Beams are added in groups of size random(1, 5). Valid values are n > 0. beam_row_speed_range (tuple[int, int]): Speed range of the beam when moving along a row. Valid values are n > 0. beam_column_speed_range (tuple[int, int]): Speed range of the beam when moving along a column. Valid values are n > 0. beam_gradient_stops (tuple[tte.Color, ...]): Tuple of colors for the beam, a gradient will be created between the colors. beam_gradient_steps (tuple[int, ...]): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Steps are paired with the colors in final-gradient-stops. Valid values are n > 0. beam_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the gradient animation. Valid values are n > 0. final_gradient_stops (tuple[tte.Color, ...]): Tuple of colors for the wipe gradient. final_gradient_steps (tuple[int, ...]): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Steps are paired with the colors in final-gradient-stops. Valid values are n > 0. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the gradient animation. final_gradient_direction (tte.Gradient.Direction): Direction of the final gradient. final_wipe_speed (int): Speed of the final wipe as measured in diagonal groups activated per frame. Valid values are n > 0. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="beams", help="Create beams which travel over the canvas illuminating the characters behind them.", description="beams | Create beams which travel over the canvas illuminating the characters behind them.", epilog=( "Example: terminaltexteffects beams --beam-row-symbols ▂ ▁ _ --beam-column-symbols ▌ ▍ ▎ ▏ --beam-delay " "6 --beam-row-speed-range 15-60 --beam-column-speed-range 9-15 --beam-gradient-stops ffffff 00D1FF " "8A008A --beam-gradient-steps 2 6 --beam-gradient-frames 2 --final-gradient-stops 8A008A 00D1FF " "ffffff --final-gradient-steps 12 --final-gradient-frames 4 --final-gradient-direction vertical " "--final-wipe-speed 3" ), ) beam_row_symbols: tuple[str, ...] = argutils.ArgSpec( name="--beam-row-symbols", type=argutils.Symbol.type_parser, nargs="+", action=argutils.TupleAction, default=("▂", "▁", "_"), metavar=argutils.Symbol.METAVAR, help=( "Symbols to use for the beam effect when moving along a row. " "Strings will be used in sequence to create an animation." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[str, ...]: Symbols to use for the beam effect when moving along a row. " "Strings will be used in sequence to create an animation." ) beam_column_symbols: tuple[str, ...] = argutils.ArgSpec( name="--beam-column-symbols", type=argutils.Symbol.type_parser, nargs="+", action=argutils.TupleAction, default=("▌", "▍", "▎", "▏"), metavar=argutils.Symbol.METAVAR, help=( "Symbols to use for the beam effect when moving along a column. " "Strings will be used in sequence to create an animation." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[str, ...]: Symbols to use for the beam effect when moving along a column. " "Strings will be used in sequence to create an animation." ) beam_delay: int = argutils.ArgSpec( name="--beam-delay", type=argutils.PositiveInt.type_parser, default=6, metavar=argutils.PositiveInt.METAVAR, help=( "Number of frames to wait before adding the next group of beams. " "Beams are added in groups of size random(1, 5)." ), ) # pyright: ignore[reportAssignmentType] ( "int : Number of frames to wait before adding the next group of beams. " "Beams are added in groups of size random(1, 5)." ) beam_row_speed_range: tuple[int, int] = argutils.ArgSpec( name="--beam-row-speed-range", type=argutils.PositiveIntRange.type_parser, default=(15, 60), metavar=argutils.PositiveIntRange.METAVAR, help="Speed range of the beam when moving along a row.", ) # pyright: ignore[reportAssignmentType] "tuple[int, int] : Speed range of the beam when moving along a row." beam_column_speed_range: tuple[int, int] = argutils.ArgSpec( name="--beam-column-speed-range", type=argutils.PositiveIntRange.type_parser, default=(9, 15), metavar=argutils.PositiveIntRange.METAVAR, help="Speed range of the beam when moving along a column.", ) # pyright: ignore[reportAssignmentType] "tuple[int, int] : Speed range of the beam when moving along a column." beam_gradient_stops: tuple[tte.Color, ...] = argutils.ArgSpec( name="--beam-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#ffffff"), tte.Color("#00D1FF"), tte.Color("#8A008A")), metavar="(XTerm [0-255] OR RGB Hex [000000-ffffff])", help="Space separated, unquoted, list of colors for the beam, a gradient will be created between the colors.", ) # pyright: ignore[reportAssignmentType] "tuple[tte.Color, ...]: Tuple of colors for the beam, a gradient will be created between the colors." beam_gradient_steps: tuple[int, ...] = argutils.ArgSpec( name="--beam-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", action=argutils.TupleAction, default=(2, 6), metavar=argutils.PositiveInt.METAVAR, help=( "Space separated, unquoted, numbers for the of gradient steps to use. " "More steps will create a smoother and longer gradient animation. " "Steps are paired with the colors in final-gradient-stops." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...]: Int or Tuple of ints for the number of gradient steps to use. " "More steps will create a smoother and longer gradient animation. " "Steps are paired with the colors in final-gradient-stops." ) beam_gradient_frames: int = argutils.ArgSpec( name="--beam-gradient-frames", type=argutils.PositiveInt.type_parser, default=2, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to display each gradient step. Increase to slow down the gradient animation.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_stops: tuple[tte.Color, ...] = FinalGradientStopsArg( default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#ffffff")), help="Space separated, unquoted, list of colors for the wipe gradient.", ) # pyright: ignore[reportAssignmentType] "tuple[tte.Color, ...]: Tuple of colors for the wipe gradient." final_gradient_steps: tuple[int, ...] = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...]: Int or Tuple of ints for the number of gradient steps to use. " "More steps will create a smoother and longer gradient animation. " "Steps are paired with the colors in final-gradient-stops." ) final_gradient_frames: int = FinalGradientFramesArg( default=4, ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: tte.Gradient.Direction = FinalGradientDirectionArg( default=tte.Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "tte.Gradient.Direction : Direction of the final gradient." final_wipe_speed: int = argutils.ArgSpec( name="--final-wipe-speed", type=argutils.PositiveInt.type_parser, default=3, metavar=argutils.PositiveInt.METAVAR, help="Speed of the final wipe as measured in diagonal groups activated per frame.", ) # pyright: ignore[reportAssignmentType] "int : Speed of the final wipe as measured in diagonal groups activated per frame." class BeamsIterator(BaseEffectIterator[BeamsConfig]): """Iterator for the Beams effect.""" class Group: """Represents a group of characters.""" def __init__( self, characters: list[tte.EffectCharacter], direction: str, terminal: tte.Terminal, args: BeamsConfig, ) -> None: """Initialize the Group.""" self.characters = characters self.direction: str = direction self.terminal = terminal direction_speed_range = { "row": (args.beam_row_speed_range[0], args.beam_row_speed_range[1]), "column": (args.beam_column_speed_range[0], args.beam_column_speed_range[1]), } self.speed = random.randint(direction_speed_range[direction][0], direction_speed_range[direction][1]) * 0.1 self.next_character_counter: float = 0 if self.direction == "row": self.characters.sort(key=lambda character: character.input_coord.column) elif self.direction == "column": self.characters.sort(key=lambda character: character.input_coord.row) if random.choice([True, False]): self.characters.reverse() def increment_next_character_counter(self) -> None: """Increment the counter for the next character.""" self.next_character_counter += self.speed def get_next_character(self) -> tte.EffectCharacter | None: """Get the next character in the group. If the next character is already active, determined by having an active scene, the active scene is reset and None is returned. Otherwise, the next character is returned and the character is made visible. Returns: tte.EffectCharacter | None: The next character if the character or None if the character is already active. """ self.next_character_counter -= 1 next_character = self.characters.pop(0) if next_character.animation.active_scene: next_character.animation.active_scene.reset_scene() return_value = None else: self.terminal.set_character_visibility(next_character, is_visible=True) return_value = next_character next_character.animation.activate_scene("beam_" + self.direction) return return_value def complete(self) -> bool: """Check if the group is complete. Returns: bool: True if the group is complete, False otherwise. """ return not self.characters def __init__(self, effect: Beams) -> None: """Initialize the BeamsIterator. Args: effect (Beams): The Beams effect instance. """ super().__init__(effect) self.pending_groups: list[BeamsIterator.Group] = [] self.character_final_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.active_groups: list[BeamsIterator.Group] = [] self.delay = 0 self.phase = "beams" self.final_wipe_groups = self.terminal.get_characters_grouped( argutils.CharacterGroup.DIAGONAL_TOP_LEFT_TO_BOTTOM_RIGHT, ) self.build() def build(self) -> None: """Build the initial state for the Beams effect.""" final_gradient = tte.Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(outer_fill_chars=True, inner_fill_chars=True): if character.is_fill_character: self.character_final_color_map[character] = tte.ColorPair(fg="#000000") continue if self.terminal.config.existing_color_handling == "dynamic": fg_color = character.animation.input_fg_color bg_color = character.animation.input_bg_color self.character_final_color_map[character] = tte.ColorPair(fg=fg_color, bg=bg_color) else: self.character_final_color_map[character] = tte.ColorPair( fg=final_gradient_mapping[character.input_coord], ) beam_gradient = tte.Gradient(*self.config.beam_gradient_stops, steps=self.config.beam_gradient_steps) groups: list[BeamsIterator.Group] = [] for row in self.terminal.get_characters_grouped( argutils.CharacterGroup.ROW_TOP_TO_BOTTOM, outer_fill_chars=True, inner_fill_chars=True, ): groups.append(BeamsIterator.Group(row, "row", self.terminal, self.config)) # noqa: PERF401 for column in self.terminal.get_characters_grouped( argutils.CharacterGroup.COLUMN_LEFT_TO_RIGHT, outer_fill_chars=True, inner_fill_chars=True, ): groups.append(BeamsIterator.Group(column, "column", self.terminal, self.config)) # noqa: PERF401 for group in groups: for character in group.characters: 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") beam_row_scn.apply_gradient_to_symbols( self.config.beam_row_symbols, self.config.beam_gradient_frames, fg_gradient=beam_gradient, ) beam_column_scn.apply_gradient_to_symbols( self.config.beam_column_symbols, self.config.beam_gradient_frames, fg_gradient=beam_gradient, ) fg_fade_gradient = bg_fade_gradient = fg_brighten_gradient = bg_brighten_gradient = None char_fg_color = self.character_final_color_map[character].fg_color char_bg_color = self.character_final_color_map[character].bg_color if char_fg_color: faded_fg_color = character.animation.adjust_color_brightness(char_fg_color, 0.3) fg_fade_gradient = tte.Gradient(char_fg_color, faded_fg_color, steps=10) fg_brighten_gradient = tte.Gradient(faded_fg_color, char_fg_color, steps=10) if char_bg_color: faded_bg_color = character.animation.adjust_color_brightness(char_bg_color, 0.3) bg_fade_gradient = tte.Gradient(char_bg_color, faded_bg_color, steps=10) bg_brighten_gradient = tte.Gradient(faded_bg_color, char_bg_color, steps=10) if fg_fade_gradient or bg_fade_gradient: beam_row_scn.apply_gradient_to_symbols( character.input_symbol, 2, fg_gradient=fg_fade_gradient, bg_gradient=bg_fade_gradient, ) beam_column_scn.apply_gradient_to_symbols( character.input_symbol, 2, fg_gradient=fg_fade_gradient, bg_gradient=bg_fade_gradient, ) else: beam_row_scn.add_frame(character.input_symbol, 2, colors=tte.ColorPair()) beam_column_scn.add_frame(character.input_symbol, 2, colors=tte.ColorPair()) if fg_brighten_gradient or bg_brighten_gradient: brigthen_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=fg_brighten_gradient, bg_gradient=bg_brighten_gradient, ) else: brigthen_scn.add_frame(character.input_symbol, self.config.final_gradient_frames, colors=tte.ColorPair()) self.pending_groups = groups random.shuffle(self.pending_groups) def __next__(self) -> str: """Return the next frame in the effect.""" if self.phase != "complete" or self.active_characters: if self.phase == "beams": if not self.delay: if self.pending_groups: for _ in range(random.randint(1, 5)): if self.pending_groups: self.active_groups.append(self.pending_groups.pop(0)) self.delay = self.config.beam_delay else: self.delay -= 1 for group in self.active_groups: group.increment_next_character_counter() if int(group.next_character_counter) > 1: for _ in range(int(group.next_character_counter)): if not group.complete(): next_char = group.get_next_character() if next_char: self.active_characters.add(next_char) self.active_groups = [group for group in self.active_groups if not group.complete()] if not self.pending_groups and not self.active_groups and not self.active_characters: self.phase = "final_wipe" elif self.phase == "final_wipe": if self.final_wipe_groups: for _ in range(self.config.final_wipe_speed): if not self.final_wipe_groups: break next_group = self.final_wipe_groups.pop(0) for character in next_group: character.animation.activate_scene("brighten") self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) else: self.phase = "complete" self.update() return self.frame raise StopIteration class Beams(BaseEffect[BeamsConfig]): """Creates beams which travel over the canvas illuminating the characters. Attributes: effect_config (BeamsConfig): Configuration for the effect. terminal_config (tte.TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[BeamsConfig]: return BeamsConfig @property def _iterator_cls(self) -> type[BeamsIterator]: return BeamsIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_binarypath.py000066400000000000000000000426631517776150200310470ustar00rootroot00000000000000"""Decodes characters into their binary form. Characters travel towards their input coordinate, moving at right angles. Classes: BinaryPath: Decodes characters into their binary form. Characters travel from outside the canvas towards their " "input coordinate, moving at right angles. BinaryPathConfig: Configuration for the BinaryPath effect. BinaryPathIterator: Effect iterator for the BinaryPath effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from typing import cast import terminaltexteffects as tte 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "binarypath", BinaryPath, BinaryPathConfig @dataclass class BinaryPathConfig(BaseConfig): """Configuration for the BinaryPath effect. Attributes: final_gradient_stops (tuple[tte.Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (tte.Gradient.Direction): Direction of the final gradient. binary_colors (tuple[tte.Color, ...]): Tuple of colors for the binary characters. Character color is randomly assigned from this list. movement_speed (float): Speed of the binary groups as they travel around the terminal. Valid values are n > 0. active_binary_groups (float): Maximum number of binary groups that are active at any given time as a percentage of the total number of binary groups. Lower this to improve performance. Valid values are 0 < n <= 1. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="binarypath", help="Binary representations of each character move towards the home coordinate of the character.", description="binarypath | Binary representations of each character move through the terminal towards the " "home coordinate of the character.", epilog=( "Example: terminaltexteffects binarypath --final-gradient-stops 00d500 007500 --final-gradient-steps 12 " "--final-gradient-direction radial --binary-colors 044E29 157e38 45bf55 95ed87 --movement-speed 1 " "--active-binary-groups 0.08" ), ) final_gradient_stops: tuple[tte.Color, ...] = FinalGradientStopsArg( default=(tte.Color("#00d500"), tte.Color("#007500")), ) # pyright: ignore[reportAssignmentType] ( "tuple[tte.Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, " "the characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number (n > 0) of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: tte.Gradient.Direction = FinalGradientDirectionArg( default=tte.Gradient.Direction.RADIAL, ) # pyright: ignore[reportAssignmentType] "tte.Gradient.Direction : Direction of the final gradient." binary_colors: tuple[tte.Color, ...] = argutils.ArgSpec( name="--binary-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#044E29"), tte.Color("#157e38"), tte.Color("#45bf55"), tte.Color("#95ed87")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the binary characters. Character color is randomly " "assigned from this list.", ) # pyright: ignore[reportAssignmentType] ( "tuple[tte.Color, ...] : Tuple of colors for the binary characters. Character color is randomly assigned from " "this list." ) movement_speed: float = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=1, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the binary groups as they travel around the terminal.", ) # pyright: ignore[reportAssignmentType] "float : Speed of the binary groups as they travel around the terminal." active_binary_groups: float = argutils.ArgSpec( name="--active-binary-groups", type=argutils.NonNegativeRatio.type_parser, default=0.08, metavar=argutils.NonNegativeRatio.METAVAR, help="Maximum number of binary groups that are active at any given time as a percentage of the total number " "of binary groups. Lower this to improve performance.", ) # pyright: ignore[reportAssignmentType] ( "float : Maximum number of binary groups that are active at any given time as a percentage of the total number " "of binary groups. Lower this to improve performance." ) class BinaryPathIterator(BaseEffectIterator[BinaryPathConfig]): """Iterator for the BinaryPath effect.""" class _BinaryRepresentation: """Binary representation of a character. Used to animate the characters moving towards the input coordinate.""" def __init__(self, character: tte.EffectCharacter, terminal: tte.Terminal) -> None: self.character = character self.terminal = terminal self.binary_string = format(ord(self.character.animation.current_character_visual.symbol), "08b") self.binary_characters: list[tte.EffectCharacter] = [] self.pending_binary_characters: list[tte.EffectCharacter] = [] self.input_coord = self.character.input_coord self.is_active = False def _travel_complete(self) -> bool: return all(bin_char.motion.current_coord == self.input_coord for bin_char in self.binary_characters) def _deactivate(self) -> None: for bin_char in self.binary_characters: self.terminal.set_character_visibility(bin_char, is_visible=False) self.is_active = False def _activate_source_character(self) -> None: self.terminal.set_character_visibility(self.character, is_visible=True) self.character.animation.activate_scene("collapse_scn") def __init__(self, effect: BinaryPath) -> None: """Initialize the BinaryPath effect iterator. Args: effect (BinaryPath): The BinaryPath effect instance. """ super().__init__(effect) self.pending_chars: list[tte.EffectCharacter] = [] self.pending_binary_representations: list[BinaryPathIterator._BinaryRepresentation] = [] self.character_final_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.last_frame_provided = False self.active_binary_reps: list[BinaryPathIterator._BinaryRepresentation] = [] self.complete = False self.phase = "travel" self.final_wipe_chars = self.terminal.get_characters_grouped( grouping=argutils.CharacterGroup.DIAGONAL_TOP_RIGHT_TO_BOTTOM_LEFT, ) self.max_active_binary_groups: int = 0 self.build() def build(self) -> None: # noqa: PLR0915 """Build the BinaryPath effect.""" final_gradient = tte.Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = tte.ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = tte.ColorPair( fg=final_gradient_mapping[character.input_coord], ) for character in self.terminal.get_characters(): bin_rep = BinaryPathIterator._BinaryRepresentation(character, self.terminal) for binary_char in bin_rep.binary_string: bin_rep.binary_characters.append(self.terminal.add_character(binary_char, tte.Coord(0, 0))) bin_rep.pending_binary_characters.append(bin_rep.binary_characters[-1]) self.pending_binary_representations.append(bin_rep) for bin_rep in self.pending_binary_representations: path_coords: list[tte.Coord] = [] starting_coord = self.terminal.canvas.random_coord(outside_scope=True) path_coords.append(starting_coord) last_orientation = random.choice(("col", "row")) next_coord = starting_coord # will be rebound in the loop while path_coords[-1] != bin_rep.character.input_coord: last_coord = path_coords[-1] if last_coord.column > bin_rep.character.input_coord.column: column_direction = -1 elif last_coord.column == bin_rep.character.input_coord.column: column_direction = 0 else: column_direction = 1 if last_coord.row > bin_rep.character.input_coord.row: row_direction = -1 elif last_coord.row == bin_rep.character.input_coord.row: row_direction = 0 else: row_direction = 1 max_column_distance = abs(last_coord.column - bin_rep.character.input_coord.column) max_row_distance = abs(last_coord.row - bin_rep.character.input_coord.row) if last_orientation == "col" and max_row_distance > 0: next_coord = tte.Coord( last_coord.column, last_coord.row + ( random.randint(1, min(max_row_distance, max(10, int(self.terminal.canvas.right * 0.2)))) * row_direction ), ) last_orientation = "row" elif last_orientation == "row" and max_column_distance > 0: next_coord = tte.Coord( last_coord.column + (random.randint(1, min(max_column_distance, 4)) * column_direction), last_coord.row, ) last_orientation = "col" else: next_coord = bin_rep.character.input_coord path_coords.append(next_coord) path_coords.append(next_coord) final_coord = bin_rep.character.input_coord path_coords.append(final_coord) for bin_effectchar in bin_rep.binary_characters: bin_effectchar.motion.set_coordinate(path_coords[0]) digital_path = bin_effectchar.motion.new_path(speed=self.config.movement_speed) for coord in path_coords: digital_path.new_waypoint(coord) bin_effectchar.motion.activate_path(digital_path) bin_effectchar.layer = 1 color_scn = bin_effectchar.animation.new_scene() color_scn.add_frame( bin_effectchar.animation.current_character_visual.symbol, 1, colors=tte.ColorPair(fg=random.choice(self.config.binary_colors)), ) bin_effectchar.animation.activate_scene(color_scn) for character in self.terminal.get_characters(): collapse_scn = character.animation.new_scene(ease=tte.easing.in_quad, scene_id="collapse_scn") final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color dim_fg_color = ( character.animation.adjust_color_brightness(final_fg_color, 0.5) if final_fg_color else None ) dim_bg_color = ( character.animation.adjust_color_brightness(final_bg_color, 0.5) if final_bg_color else None ) collapse_fg_gradient = tte.Gradient(tte.Color("#ffffff"), dim_fg_color, steps=7) if dim_fg_color else None collapse_bg_gradient = tte.Gradient(tte.Color("#ffffff"), dim_bg_color, steps=7) if dim_bg_color else None if collapse_fg_gradient or collapse_bg_gradient: collapse_scn.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=collapse_fg_gradient, bg_gradient=collapse_bg_gradient, ) else: collapse_scn.add_frame(character.input_symbol, 3, colors=tte.ColorPair()) brighten_scn = character.animation.new_scene(scene_id="brighten_scn") brighten_fg_gradient = ( tte.Gradient(dim_fg_color, cast("tte.Color", final_fg_color), steps=10) if dim_fg_color else None ) brighten_bg_gradient = ( tte.Gradient(dim_bg_color, cast("tte.Color", final_bg_color), steps=10) if dim_bg_color else None ) if brighten_fg_gradient or brighten_bg_gradient: brighten_scn.apply_gradient_to_symbols( character.input_symbol, 2, fg_gradient=brighten_fg_gradient, bg_gradient=brighten_bg_gradient, ) else: brighten_scn.add_frame(character.input_symbol, 2, colors=tte.ColorPair()) self.max_active_binary_groups = max( 1, int(self.config.active_binary_groups * len(self.pending_binary_representations)), ) def __next__(self) -> str: """Return the next frame in the effect.""" if not self.complete or self.active_characters: if self.phase == "travel": while ( len(self.active_binary_reps) < self.max_active_binary_groups and self.pending_binary_representations ): next_binary_rep = self.pending_binary_representations.pop( random.randrange(len(self.pending_binary_representations)), ) next_binary_rep.is_active = True self.active_binary_reps.append(next_binary_rep) if self.active_binary_reps: for active_rep in self.active_binary_reps: if active_rep.pending_binary_characters: next_char = active_rep.pending_binary_characters.pop(0) self.active_characters.add(next_char) self.terminal.set_character_visibility(next_char, is_visible=True) elif active_rep._travel_complete(): active_rep._deactivate() active_rep._activate_source_character() self.active_characters.add(active_rep.character) self.active_binary_reps = [ binary_rep for binary_rep in self.active_binary_reps if binary_rep.is_active ] if not self.active_characters: self.phase = "wipe" if self.phase == "wipe": for _ in range(2): if self.final_wipe_chars: next_group = self.final_wipe_chars.pop(0) for character in next_group: character.animation.activate_scene("brighten_scn") self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) else: self.complete = True self.update() return self.frame if not self.last_frame_provided: self.last_frame_provided = True return self.frame raise StopIteration class BinaryPath(BaseEffect): """Decode characters into their binary form. Characters travel to their input coordinate, moving at right angles. Attributes: effect_config (BinaryPathConfig): Configuration for the BinaryPath effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[BinaryPathConfig]: return BinaryPathConfig @property def _iterator_cls(self) -> type[BinaryPathIterator]: return BinaryPathIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_blackhole.py000066400000000000000000000440601517776150200306230ustar00rootroot00000000000000"""Creates a blackhole in a starfield, consumes the stars, explodes the input data back into position. Classes: BlackholeConfig: Configuration for the Blackhole effect. Blackhole: Creates a blackhole in a starfield, consumes the stars, explodes the input data back into position. BlackholeIterator: Iterator for the Blackhole effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, EffectCharacter, EventHandler, Gradient, Scene, easing, geometry from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.graphics import ColorPair def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "blackhole", Blackhole, BlackholeConfig @dataclass class BlackholeConfig(BaseConfig): """Configuration for the Blackhole effect. Attributes: blackhole_color (Color): Color for the stars that comprise the blackhole border. star_colors (tuple[Color, ...]): Tuple of colors from which character colors will be chosen and applied after the explosion, but before the cooldown to final color. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the character gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="blackhole", help="Characters are consumed by a black hole and explode outwards.", description="blackhole | Characters are consumed by a black hole and explode outwards.", epilog=( "Example: terminaltexteffects blackhole --blackhole-color ffffff " "--star-colors ffcc0d ff7326 ff194d bf2669 702a8c 049dbf " "--final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 9 --final-gradient-direction diagonal" ), ) blackhole_color: Color = argutils.ArgSpec( name="--blackhole-color", type=argutils.ColorArg.type_parser, default=Color("#ffffff"), metavar=argutils.ColorArg.METAVAR, help="Color for the stars that comprise the blackhole border.", ) # pyright: ignore[reportAssignmentType] "Color : Color for the stars that comprise the blackhole border." star_colors: tuple[Color, ...] = argutils.ArgSpec( name="--star-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=( Color("#ffcc0d"), Color("#ff7326"), Color("#ff194d"), Color("#bf2669"), Color("#702a8c"), Color("#049dbf"), ), metavar=argutils.ColorArg.METAVAR, help="List of colors from which character colors will be chosen and applied after the explosion, but before " "the cooldown to final color.", ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors from which character colors will be chosen and applied after the " "explosion, but before the cooldown to final color." ) final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#ffffff")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=9, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will create " "a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class BlackholeIterator(BaseEffectIterator[BlackholeConfig]): """Iterator for the Blackhole effect.""" def __init__(self, effect: Blackhole) -> None: """Initialize the Blackhole effect iterator. Args: effect (Blackhole): The Blackhole effect instance. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.blackhole_chars: list[EffectCharacter] = [] self.awaiting_consumption_chars: list[EffectCharacter] = [] self.blackhole_radius = max( min( round(self.terminal.canvas.width * 0.3), round(self.terminal.canvas.height * 0.20), ), 3, ) self.character_final_color_map: dict[EffectCharacter, Color] = {} self.preexisting_colors_present = any( any((character.animation.input_fg_color, character.animation.input_bg_color)) for character in self.terminal.get_characters() ) self.build() def prepare_blackhole(self) -> None: """Prepare the blackhole and starfield characters.""" star_symbols = ["*", "'", "`", "¤", "•", "°", "·"] starfield_colors = Gradient(Color("#4a4a4d"), Color("#ffffff"), steps=6).spectrum gradient_map = {} for color in starfield_colors: gradient_map[color] = Gradient(color, Color("#000000"), steps=10) available_chars = list(self.terminal._input_characters) while len(self.blackhole_chars) < self.blackhole_radius * 3 and available_chars: self.blackhole_chars.append(available_chars.pop(random.randrange(0, len(available_chars)))) black_hole_ring_positions = geometry.find_coords_on_circle( self.terminal.canvas.center, self.blackhole_radius, len(self.blackhole_chars), ) for position_index, character in enumerate(self.blackhole_chars): starting_pos = black_hole_ring_positions[position_index] blackhole_path = character.motion.new_path(path_id="blackhole", speed=0.7, ease=easing.in_out_sine) blackhole_path.new_waypoint(starting_pos) blackhole_scn = character.animation.new_scene(scene_id="blackhole") blackhole_scn.add_frame("*", 1, colors=ColorPair(fg=self.config.blackhole_color)) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, blackhole_path, EventHandler.Action.SET_LAYER, 1, ) # make rotation waypoints blackhole_rotation_path = character.motion.new_path(path_id="blackhole_rotation", speed=0.45, loop=True) for coord in black_hole_ring_positions[position_index:] + black_hole_ring_positions[:position_index]: blackhole_rotation_path.new_waypoint(coord, waypoint_id=str(len(blackhole_rotation_path.waypoints))) for character in self.terminal.get_characters(): self.terminal.set_character_visibility(character, is_visible=True) starting_scn = character.animation.new_scene() star_symbol = random.choice(star_symbols) star_color = random.choice(starfield_colors) starting_scn.add_frame(star_symbol, 1, colors=ColorPair(fg=star_color)) character.animation.activate_scene(starting_scn) if character not in self.blackhole_chars: starfield_coord = self.terminal.canvas.random_coord() character.motion.set_coordinate(starfield_coord) singularity_path = character.motion.new_path( path_id="singularity", speed=random.uniform(0.17, 0.30), ease=easing.in_expo, ) singularity_path.new_waypoint(self.terminal.canvas.center) consumed_scn = character.animation.new_scene() for color in gradient_map[star_color]: consumed_scn.add_frame(star_symbol, 1, colors=ColorPair(fg=color)) consumed_scn.add_frame(" ", 1) consumed_scn.sync = Scene.SyncMetric.DISTANCE character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, singularity_path, EventHandler.Action.SET_LAYER, 2, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, singularity_path, EventHandler.Action.ACTIVATE_SCENE, consumed_scn, ) self.awaiting_consumption_chars.append(character) random.shuffle(self.awaiting_consumption_chars) def rotate_blackhole(self) -> None: """Rotate the blackhole characters.""" for character in self.blackhole_chars: character.motion.activate_path("blackhole_rotation") self.active_characters.add(character) def collapse_blackhole(self) -> None: """Collapse the blackhole characters.""" black_hole_ring_positions = geometry.find_coords_on_circle( self.terminal.canvas.center, self.blackhole_radius + 3, len(self.blackhole_chars), ) unstable_symbols = ["◦", "◎", "◉", "●", "◉", "◎", "◦"] point_char_made = False for character in self.blackhole_chars: next_pos = black_hole_ring_positions.pop(0) expand_path = character.motion.new_path(speed=0.2, ease=easing.in_expo) expand_path.new_waypoint(next_pos) collapse_path = character.motion.new_path(speed=0.3, ease=easing.in_expo) collapse_path.new_waypoint(self.terminal.canvas.center) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, expand_path, EventHandler.Action.ACTIVATE_PATH, collapse_path, ) if not point_char_made: point_scn = character.animation.new_scene() for _ in range(3): for symbol in unstable_symbols: point_scn.add_frame( symbol, 3, colors=ColorPair(fg=random.choice(self.config.star_colors)), ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, collapse_path, EventHandler.Action.ACTIVATE_SCENE, point_scn, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, collapse_path, EventHandler.Action.SET_LAYER, 3, ) point_char_made = True character.motion.activate_path(expand_path) self.active_characters.add(character) def explode_singularity(self) -> None: """Explode the singularity characters.""" star_colors = [ Color("#ffcc0d"), Color("#ff7326"), Color("#ff194d"), Color("#bf2669"), Color("#702a8c"), Color("#049dbf"), ] for character in self.terminal.get_characters(): nearby_coord = geometry.find_coords_on_circle(character.input_coord, 3, 5)[random.randrange(0, 5)] nearby_path = character.motion.new_path(speed=random.randint(3, 4) / 10, ease=easing.out_expo) nearby_path.new_waypoint(nearby_coord) input_path = character.motion.new_path(speed=random.randint(4, 6) / 100, ease=easing.in_cubic) input_path.new_waypoint(character.input_coord) explode_scn = character.animation.new_scene() explode_star_color = random.choice(star_colors) explode_scn.add_frame(character.input_symbol, 1, colors=ColorPair(fg=explode_star_color)) cooling_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic" and self.preexisting_colors_present: if not any((character.animation.input_fg_color, character.animation.input_bg_color)): cooling_scn.add_frame(character.input_symbol, 1, colors=ColorPair()) else: cooling_gradient_fg = None cooling_gradient_bg = None if character.animation.input_fg_color: cooling_gradient_fg = Gradient( explode_star_color, character.animation.input_fg_color, steps=10, ) if character.animation.input_bg_color: cooling_gradient_bg = Gradient( explode_star_color, character.animation.input_bg_color, steps=10, ) cooling_scn.apply_gradient_to_symbols( character.input_symbol, 20, fg_gradient=cooling_gradient_fg, bg_gradient=cooling_gradient_bg, ) else: cooling_gradient = Gradient(explode_star_color, self.character_final_color_map[character], steps=10) cooling_scn.apply_gradient_to_symbols(character.input_symbol, 20, fg_gradient=cooling_gradient) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, nearby_path, EventHandler.Action.ACTIVATE_PATH, input_path, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, nearby_path, EventHandler.Action.ACTIVATE_SCENE, cooling_scn, ) character.animation.activate_scene(explode_scn) character.motion.activate_path(nearby_path) self.active_characters.add(character) def build(self) -> None: """Build the Blackhole effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] self.prepare_blackhole() self.formation_delay = max(100 // len(self.blackhole_chars), 6) self.f_delay = self.formation_delay self.phase = "forming" self.awaiting_blackhole_chars = list(self.blackhole_chars) def __next__(self) -> str: """Return the next frame in the Blackhole effect.""" if self.active_characters or self.phase != "complete": if self.phase == "forming": if self.awaiting_blackhole_chars: if not self.f_delay: next_char = self.awaiting_blackhole_chars.pop(0) next_char.motion.activate_path("blackhole") next_char.animation.activate_scene("blackhole") self.active_characters.add(next_char) self.f_delay = self.formation_delay else: self.f_delay -= 1 elif not self.active_characters: self.rotate_blackhole() self.phase = "consuming" elif self.phase == "consuming": if self.awaiting_consumption_chars: for char in self.awaiting_consumption_chars: char.motion.activate_path("singularity") self.active_characters.add(char) self.awaiting_consumption_chars.clear() elif all(character in self.blackhole_chars for character in self.active_characters): self.phase = "collapsing" elif self.phase == "collapsing": self.collapse_blackhole() self.phase = "exploding" elif self.phase == "exploding" and all( character.motion.active_path is None and character.animation.active_scene is None for character in self.blackhole_chars ): self.explode_singularity() self.phase = "complete" self.update() return self.frame raise StopIteration class Blackhole(BaseEffect[BlackholeConfig]): """Creates a blackhole in a starfield, consumes the stars, explodes the input data back into position. Attributes: effect_config (BlackholeConfig): Configuration for the Blackhole effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[BlackholeConfig]: return BlackholeConfig @property def _iterator_cls(self) -> type[BlackholeIterator]: return BlackholeIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_bouncyballs.py000066400000000000000000000265451517776150200312240ustar00rootroot00000000000000"""Characters fall from the top of the canvas as bouncy balls before settling into place. Classes: BouncyBalls: Characters fall from the top of the canvas as bouncy balls before settling into place. BouncyBallsConfig: Configuration for the BouncyBalls effect. BouncyBallsIterator: Iterator for the BouncyBalls effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, Coord, EffectCharacter, Gradient, easing from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.graphics import ColorPair def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "bouncyballs", BouncyBalls, BouncyBallsConfig @dataclass class BouncyBallsConfig(BaseConfig): """Configuration for the BouncyBalls effect. Attributes: ball_colors (tuple[Color, ...]): Tuple of colors from which ball colors will be randomly selected. If no colors are provided, the colors are random. ball_symbols (tuple[str, ...] | str): Tuple of symbols to use for the balls. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. ball_delay (int): Number of frames between ball drops, increase to reduce ball drop rate. Valid values are n > 0. movement_speed (float): Movement speed of the characters. Valid values are n > 0. easing (easing.EasingFunction): Easing function to use for character movement. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="bouncyballs", help="Characters are bouncy balls falling from the top of the canvas.", description="bouncyballs | Characters are bouncy balls falling from the top of the canvas.", epilog=f"{argutils.EASING_EPILOG}" "Example: terminaltexteffects bouncyballs --ball-colors d1f4a5 96e2a4 5acda9 --ball-symbols * o O 0 . " "--ball-delay 4 --movement-speed 0.45 --movement-easing OUT_BOUNCE " "--final-gradient-stops f8ffae 43c6ac --final-gradient-steps 12 --final-gradient-direction diagonal", ) ball_colors: tuple[Color, ...] = argutils.ArgSpec( name="--ball-colors", type=argutils.ColorArg.type_parser, metavar=argutils.ColorArg.METAVAR, nargs="+", action=argutils.TupleAction, default=(Color("#d1f4a5"), Color("#96e2a4"), Color("#5acda9")), help="Space separated list of colors from which ball colors will be randomly selected. If no colors are " "provided, the colors are random.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors from which ball colors will be randomly selected. If no colors are " "provided, the colors are random." ball_symbols: tuple[str, ...] = argutils.ArgSpec( name="--ball-symbols", type=argutils.Symbol.type_parser, nargs="+", action=argutils.TupleAction, default=("*", "o", "O", "0", "."), metavar=argutils.Symbol.METAVAR, help="Space separated list of symbols to use for the balls.", ) # pyright: ignore[reportAssignmentType] "tuple[str, ...] | str : Tuple of symbols to use for the balls." ball_delay: int = argutils.ArgSpec( name="--ball-delay", type=argutils.NonNegativeInt.type_parser, default=4, metavar=argutils.NonNegativeInt.METAVAR, help="Number of frames between ball drops, increase to reduce ball drop rate.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames between ball drops, increase to reduce ball drop rate." movement_speed: float = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=0.45, metavar=argutils.PositiveFloat.METAVAR, help="Movement speed of the characters. ", ) # pyright: ignore[reportAssignmentType] "float : Movement speed of the characters. " movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", type=argutils.Ease.type_parser, default=easing.out_bounce, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#f8ffae"), Color("#43c6ac")), ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will create " "a smoother and longer gradient animation." final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class BouncyBallsIterator(BaseEffectIterator[BouncyBallsConfig]): """Iterator for the BouncyBalls effect.""" def __init__(self, effect: BouncyBalls) -> None: """Initialize the effect iterator. Args: effect (BouncyBalls): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.group_by_row: dict[int, list[EffectCharacter | None]] = {} self.character_final_color_map: dict[EffectCharacter, Color] = {} self.build() def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] color = random.choice(self.config.ball_colors) symbol = random.choice(self.config.ball_symbols) ball_scene = character.animation.new_scene() ball_scene.add_frame(symbol, 1, colors=ColorPair(fg=color)) final_scene = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(color, character.animation.input_fg_color, steps=10) if character.animation.input_fg_color else None ) bg_gradient = ( Gradient(color, character.animation.input_bg_color, steps=10) if character.animation.input_bg_color else None ) if fg_gradient or bg_gradient: final_scene.apply_gradient_to_symbols( character.input_symbol, 6, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: final_scene.add_frame(character.input_symbol, 6, colors=ColorPair()) else: char_final_gradient = Gradient(color, self.character_final_color_map[character], steps=10) final_scene.apply_gradient_to_symbols(character.input_symbol, 6, fg_gradient=char_final_gradient) character.motion.set_coordinate( Coord(character.input_coord.column, int(self.terminal.canvas.top * random.uniform(1.0, 1.5))), ) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) character.animation.activate_scene(ball_scene) character.event_handler.register_event( character.event_handler.Event.PATH_COMPLETE, input_coord_path, character.event_handler.Action.ACTIVATE_SCENE, final_scene, ) self.pending_chars.append(character) for character in sorted(self.pending_chars, key=lambda c: c.input_coord.row): if character.input_coord.row not in self.group_by_row: self.group_by_row[character.input_coord.row] = [] self.group_by_row[character.input_coord.row].append(character) self.pending_chars.clear() self.ball_delay = 0 def __next__(self) -> str: """Return the next frame in the animation.""" if self.group_by_row or self.active_characters or self.pending_chars: if not self.pending_chars and self.group_by_row: self.pending_chars.extend(self.group_by_row.pop(min(self.group_by_row.keys()))) # type: ignore[arg-type] if self.pending_chars: if self.ball_delay == 0: for _ in range(random.randint(2, 6)): if self.pending_chars: next_character = self.pending_chars.pop(random.randint(0, len(self.pending_chars) - 1)) self.terminal.set_character_visibility(next_character, is_visible=True) self.active_characters.add(next_character) else: break self.ball_delay = self.config.ball_delay else: self.ball_delay -= 1 self.update() return self.frame raise StopIteration class BouncyBalls(BaseEffect[BouncyBallsConfig]): """Characters fall from the top of the canvas as bouncy balls before settling into place. Attributes: effect_config (BouncyBallsConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[BouncyBallsConfig]: return BouncyBallsConfig @property def _iterator_cls(self) -> type[BouncyBallsIterator]: return BouncyBallsIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_bubbles.py000066400000000000000000000451251517776150200303200ustar00rootroot00000000000000"""Forms bubbles with the characters. Bubbles float down and pop. Classes: Bubbles: Forms bubbles with the characters. Bubbles float down and pop. BubblesConfig: Configuration for the Bubbles effect. BubblesIterator: Iterates over the Bubbles effect. Does not normally need to be called directly. """ from __future__ import annotations import random import typing from dataclasses import dataclass from terminaltexteffects import Color, Coord, EffectCharacter, EventHandler, Gradient, Terminal, easing, geometry from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.graphics import ColorPair def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "bubbles", Bubbles, BubblesConfig @dataclass class BubblesConfig(BaseConfig): """Configuration for the Bubbles effect. Attributes: rainbow (bool): If set, the bubbles will be colored with a rotating rainbow gradient. bubble_colors (tuple[Color, ...]): Tuple of colors for the bubbles. Ignored if --no-rainbow is left as default False. pop_color (Color): Color for the spray emitted when a bubble pops. bubble_speed (float): Speed of the floating bubbles. Valid values are n > 0. bubble_delay (int): Number of frames between bubbles. Valid values are n >= 0. pop_condition (typing.Literal["row", "bottom", "anywhere"]): Condition for a bubble to pop. 'row' will pop the bubble when it reaches the the lowest row for which a character in the bubble originates. 'bottom' will pop the bubble at the bottom row of the terminal. 'anywhere' will pop the bubble randomly, or at the bottom of the terminal. movement_easing (easing.EasingFunction): Easing function to use for character movement after a bubble pops. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="bubbles", help="Characters are formed into bubbles that float down and pop.", description="bubbles | Characters are formed into bubbles that float down and pop.", epilog=f"{argutils.EASING_EPILOG}" "Example: terminaltexteffects bubbles [--rainbow] --bubble-colors d33aff 7395c4 43c2a7 02ff7f " "--pop-color ffffff --bubble-speed 0.5 --bubble-delay 20 --pop-condition row --movement-easing IN_OUT_SINE " "--final-gradient-stops d33aff 02ff7f --final-gradient-steps 12 --final-gradient-direction diagonal", ) rainbow: bool = argutils.ArgSpec( name="--rainbow", action="store_true", default=False, help="If set, the bubbles will be colored with a rotating rainbow gradient.", ) # pyright: ignore[reportAssignmentType] "bool : If set, the bubbles will be colored with a rotating rainbow gradient." bubble_colors: tuple[Color, ...] = argutils.ArgSpec( name="--bubble-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#d33aff"), Color("#7395c4"), Color("#43c2a7"), Color("#02ff7f")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the bubbles. Ignored if --no-rainbow is left as " "default False.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the bubbles. Ignored if --no-rainbow is left as default False." pop_color: Color = argutils.ArgSpec( name="--pop-color", type=argutils.ColorArg.type_parser, default=Color("#ffffff"), metavar=argutils.ColorArg.METAVAR, help="Color for the spray emitted when a bubble pops.", ) # pyright: ignore[reportAssignmentType] "Color : Color for the spray emitted when a bubble pops." bubble_speed: float = argutils.ArgSpec( name="--bubble-speed", type=argutils.PositiveFloat.type_parser, default=0.5, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the floating bubbles. ", ) # pyright: ignore[reportAssignmentType] "float : Speed of the floating bubbles. " bubble_delay: int = argutils.ArgSpec( name="--bubble-delay", type=argutils.PositiveInt.type_parser, default=20, metavar=argutils.PositiveInt.METAVAR, help="Number of frames between bubbles.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames between bubbles." pop_condition: typing.Literal["row", "bottom", "anywhere"] = argutils.ArgSpec( name="--pop-condition", default="row", choices=["row", "bottom", "anywhere"], help="Condition for a bubble to pop. 'row' will pop the bubble when it reaches the the lowest row for which " "a character in the bubble originates. 'bottom' will pop the bubble at the bottom row of the terminal. " "'anywhere' will pop the bubble randomly, or at the bottom of the terminal.", ) # pyright: ignore[reportAssignmentType] ( "typing.Literal['row', 'bottom', 'anywhere'] : Condition for a bubble to pop. 'row' will pop the bubble when " "it reaches the the lowest row for which a character in the bubble originates. 'bottom' will pop the bubble at " "the bottom row of the terminal. 'anywhere' will pop the bubble randomly, or at the bottom of the terminal." ) movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", default=easing.in_out_sine, type=argutils.Ease.type_parser, metavar=argutils.Ease.METAVAR, help="Easing function to use for character movement after a bubble pops.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement after a bubble pops." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#d33aff"), Color("#02ff7f")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class BubblesIterator(BaseEffectIterator[BubblesConfig]): """Iterator for the Bubbles effect.""" class Bubble: """A bubble of characters that float down and pop.""" def __init__( self, effect: BubblesIterator, origin: Coord, characters: list[EffectCharacter], terminal: Terminal, ) -> None: """Initialize the bubble.""" self.effect = effect self.characters = characters self.terminal = terminal self.radius = max(len(self.characters) // 5, 1) self.origin = origin self.anchor_char = self.terminal.add_character(" ", self.origin) if self.effect.config.pop_condition == "row": self.lowest_row = min([char.input_coord.row for char in self.characters]) else: self.lowest_row = self.effect.terminal.canvas.bottom self.set_character_coordinates() self.landed = False self.make_waypoints() self.make_gradients() def set_character_coordinates(self) -> None: """Set the coordinates of the characters in the bubble.""" for i, char in enumerate(self.characters): point = geometry.find_coords_on_circle( self.anchor_char.motion.current_coord, self.radius, len(self.characters), unique=False, )[i] char.motion.set_coordinate(point) if point.row == self.lowest_row: self.landed = True if self.effect.config.pop_condition == "anywhere" and random.random() < 0.002: self.landed = True def make_waypoints(self) -> None: """Make the waypoints for the bubble.""" waypoint_column = random.randint(self.effect.terminal.canvas.left, self.effect.terminal.canvas.right) floor_path = self.anchor_char.motion.new_path(speed=self.effect.config.bubble_speed) floor_path.new_waypoint(Coord(waypoint_column, self.lowest_row)) self.anchor_char.motion.activate_path(floor_path) def make_gradients(self) -> None: """Make the gradients for the bubble.""" if self.effect.config.rainbow: rainbow_gradient = list(self.effect.rainbow_gradient.spectrum) gradient_offset = 0 for character in self.characters: sheen_scene = character.animation.new_scene() for step in rainbow_gradient: sheen_scene.add_frame(character.input_symbol, 4, colors=ColorPair(fg=step)) gradient_offset += 2 gradient_offset %= len(rainbow_gradient) rainbow_gradient = rainbow_gradient[gradient_offset:] + rainbow_gradient[:gradient_offset] character.animation.activate_scene(sheen_scene) if character.animation.active_scene: character.animation.active_scene.is_looping = True else: bubble_color = random.choice(self.effect.config.bubble_colors) for character in self.characters: sheen_scene = character.animation.new_scene() sheen_scene.add_frame(character.input_symbol, 1, colors=ColorPair(fg=bubble_color)) character.animation.activate_scene(sheen_scene) def pop(self) -> None: """Pop the bubble.""" char: EffectCharacter point: Coord for char, point in zip( self.characters, geometry.find_coords_on_circle( self.anchor_char.motion.current_coord, self.radius + 3, len(self.characters), ), ): pop_out_path = char.motion.new_path(path_id="pop_out", speed=0.3, ease=easing.out_expo) pop_out_path.new_waypoint(point) char.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, pop_out_path, EventHandler.Action.ACTIVATE_PATH, char.motion.paths["final"], ) for character in self.characters: character.animation.activate_scene("pop_1") character.motion.activate_path("pop_out") def activate(self) -> None: """Activate the bubble.""" for char in self.characters: self.terminal.set_character_visibility(char, is_visible=True) def move(self) -> None: """Move the bubble.""" self.anchor_char.motion.move() self.set_character_coordinates() for character in self.characters: character.animation.step_animation() def __init__(self, effect: Bubbles) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.bubbles: list[BubblesIterator.Bubble] = [] red = Color("#e81416") orange = Color("#ffa500") yellow = Color("#faeb36") green = Color("#79c314") blue = Color("#487de7") indigo = Color("#4b369d") violet = Color("#70369d") self.rainbow_gradient = Gradient(red, orange, yellow, green, blue, indigo, violet, steps=5) self.character_final_color_map: dict[EffectCharacter, Color] = {} self.build() def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] character.layer = 1 pop_1_scene = character.animation.new_scene(scene_id="pop_1") pop_2_scene = character.animation.new_scene() pop_1_scene.add_frame("*", 9, colors=ColorPair(fg=self.config.pop_color)) pop_2_scene.add_frame("'", 9, colors=ColorPair(fg=self.config.pop_color)) final_scene = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(self.config.pop_color, character.animation.input_fg_color, steps=8) if character.animation.input_fg_color else None ) bg_gradient = ( Gradient(self.config.pop_color, character.animation.input_bg_color, steps=8) if character.animation.input_bg_color else None ) if fg_gradient or bg_gradient: final_scene.apply_gradient_to_symbols( character.input_symbol, 6, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: final_scene.add_frame(character.input_symbol, 6, colors=ColorPair()) else: char_final_gradient = Gradient( self.config.pop_color, self.character_final_color_map[character], steps=8, ) final_scene.apply_gradient_to_symbols(character.input_symbol, 6, fg_gradient=char_final_gradient) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, pop_1_scene, EventHandler.Action.ACTIVATE_SCENE, pop_2_scene, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, pop_2_scene, EventHandler.Action.ACTIVATE_SCENE, final_scene, ) final_path = character.motion.new_path( path_id="final", speed=0.3, ease=easing.in_out_expo, ) final_path.new_waypoint(character.input_coord) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, final_path, EventHandler.Action.SET_LAYER, 0, ) unbubbled_chars = [] for char_list in self.terminal.get_characters_grouped(grouping=argutils.CharacterGroup.ROW_BOTTOM_TO_TOP): unbubbled_chars.extend(char_list) self.bubbles = [] while unbubbled_chars: bubble_group = [] if len(unbubbled_chars) < 5: bubble_group.extend(unbubbled_chars) unbubbled_chars.clear() else: for _ in range(random.randint(5, min(len(unbubbled_chars), 20))): bubble_group.append(unbubbled_chars.pop(0)) # noqa: PERF401 bubble_origin = Coord( random.randint(self.terminal.canvas.left, self.terminal.canvas.right), self.terminal.canvas.top + 10, ) new_bubble = BubblesIterator.Bubble(self, bubble_origin, bubble_group, self.terminal) self.bubbles.append(new_bubble) self.animating_bubbles: list[BubblesIterator.Bubble] = [] self.steps_since_last_bubble = 0 def __next__(self) -> str: """Return the next frame in the animation.""" if self.animating_bubbles or self.active_characters or self.bubbles: if self.bubbles and self.steps_since_last_bubble >= self.config.bubble_delay: next_bubble = self.bubbles.pop(0) next_bubble.activate() self.animating_bubbles.append(next_bubble) self.steps_since_last_bubble = 0 self.steps_since_last_bubble += 1 for bubble in self.animating_bubbles: if bubble.landed: bubble.pop() self.active_characters = self.active_characters.union(bubble.characters) self.animating_bubbles = [bubble for bubble in self.animating_bubbles if not bubble.landed] for bubble in self.animating_bubbles: bubble.move() self.update() return self.frame raise StopIteration class Bubbles(BaseEffect[BubblesConfig]): """Forms bubbles with the characters. Bubbles float down and pop. Attributes: effect_config (BubblesConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[BubblesConfig]: return BubblesConfig @property def _iterator_cls(self) -> type[BubblesIterator]: return BubblesIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_burn.py000066400000000000000000000304331517776150200276440ustar00rootroot00000000000000"""Characters are ignited and burn up the screen. Classes: Burn: Characters are ignited and burn up the screen. BurnConfig: Configuration for the Burn effect. BurnIterator: Iterates over the Burn effect. Does not normally need to be called directly. """ from __future__ import annotations import random from collections import deque from dataclasses import dataclass from terminaltexteffects import Color, EffectCharacter, EventHandler, Gradient from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.graphics import ColorPair from terminaltexteffects.utils.spanningtree.algo.primssimple import PrimsSimple def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "burn", Burn, BurnConfig @dataclass class BurnConfig(BaseConfig): """Configuration for the Burn effect. Attributes: starting_color (Color): Color of the characters before they start to burn. burn_colors (tuple[Color, ...]): Colors transitioned through as the characters burn. smoke_chance (float): Chance a given character will produce smoke while burning. Use 0 for no smoke. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="burn", help="Burns vertically in the canvas.", description="burn | Burn the canvas.", epilog=( "Example: terminaltexteffects burn --starting-color 837373 --burn-colors ffffff fff75d fe650d 8a003c " "510100 --smoke-chance 0.5 --final-gradient-stops 00c3ff ffff1c --final-gradient-steps 12 " "--final-gradient-direction vertical" ), ) starting_color: Color = argutils.ArgSpec( name="--starting-color", type=argutils.ColorArg.type_parser, default=Color("#837373"), metavar=argutils.ColorArg.METAVAR, help="Color of the characters before they start to burn.", ) # pyright: ignore[reportAssignmentType] "Color : Color of the characters before they start to burn." burn_colors: tuple[Color, ...] = argutils.ArgSpec( name="--burn-colors", type=argutils.ColorArg.type_parser, default=(Color("#ffffff"), Color("#fff75d"), Color("#fe650d"), Color("#8A003C"), Color("#510100")), nargs="+", action=argutils.TupleAction, metavar=argutils.ColorArg.METAVAR, help="Colors transitioned through as the characters burn.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Colors transitioned through as the characters burn." smoke_chance: float = argutils.ArgSpec( name="--smoke-chance", type=argutils.NonNegativeRatio.type_parser, default=0.5, metavar=argutils.NonNegativeRatio.METAVAR, help="Chance a given character will produce smoke while burning. Use 0 for no smoke.", ) # pyright: ignore[reportAssignmentType] "float : Chance a given character will produce smoke while burning. Use 0 for no smoke." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#00c3ff"), Color("#ffff1c")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class BurnIterator(BaseEffectIterator[BurnConfig]): """Iterator for the Burn effect.""" def __init__(self, effect: Burn) -> None: """Initialize the Burn effect iterator. Args: effect (Burn): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, Color] = {} self.algo = PrimsSimple(self.terminal, limit_to_text_boundary=True) self.smoke_particles = self._make_smoke() self.pending_smoke: set[EffectCharacter] = set() self.build() @staticmethod def _has_input_colors(character: EffectCharacter) -> bool: return any((character.animation.input_fg_color, character.animation.input_bg_color)) def _is_burnable(self, character: EffectCharacter) -> bool: return character.input_symbol != " " or ( self.terminal.config.existing_color_handling != "ignore" and self._has_input_colors(character) ) def _make_smoke(self) -> deque[EffectCharacter]: smoke_particles: deque[EffectCharacter] = deque() for _ in range(2000): new_char = self.terminal.add_character(random.choice((".", ",", "'", "`", "#", "*")), Coord(0, 0)) smoke_scn = new_char.animation.new_scene(scene_id="smoke") for color in Gradient(Color("#504F4F"), Color("#C7C7C7"), steps=9): smoke_scn.add_frame( new_char.input_symbol, 10, colors=ColorPair(fg=color), ) new_char.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, smoke_scn, EventHandler.Action.CALLBACK, EventHandler.Callback(lambda c: self.terminal.set_character_visibility(c, is_visible=False)), ) new_char.layer = 2 smoke_particles.append(new_char) return smoke_particles def _emit_smoke(self, origin: Coord, smoke_chance: float) -> None: """Emit sparks from the laser beam. Sets up the spark character Path and activates the Path and Scene for each spark character. The spark characters are added to the effect active_characters set. Args: origin (Coord): Starting point for the smoke path. smoke_chance (float): Chance a character will emit smoke. """ if random.random() > smoke_chance: return next_particle = self.smoke_particles[-1] self.smoke_particles.rotate(1) next_particle.motion.set_coordinate(origin) if next_particle.animation.active_scene: next_particle.animation.active_scene.reset_scene() self.terminal.set_character_visibility(next_particle, is_visible=True) smoke_path = next_particle.motion.new_path(speed=0.5) rise_target_coord = Coord( random.randint(origin.column - 4, origin.column + 4), self.terminal.canvas.top + 1, ) smoke_path.new_waypoint( rise_target_coord, ) next_particle.motion.activate_path(smoke_path) next_particle.animation.activate_scene("smoke") self.pending_smoke.add(next_particle) def build(self) -> None: """Build the Burn effect.""" burn_char_order = [ "'", ".", "▖", "▙", "█", "▜", "▀", "▝", ".", ] final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] fire_gradient = Gradient(*self.config.burn_colors, steps=10) while not self.algo.complete: self.algo.step() for char in self.terminal.get_characters(): self.terminal.set_character_visibility(char, is_visible=True) char.animation.set_appearance( char.input_symbol, colors=ColorPair(fg=self.config.starting_color), ) burn_scn = char.animation.new_scene(scene_id="burn") burn_scn.apply_gradient_to_symbols(burn_char_order, 4, fg_gradient=fire_gradient) final_color_scn = char.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(fire_gradient.spectrum[-1], char.animation.input_fg_color, steps=8) if char.animation.input_fg_color else None ) bg_gradient = ( Gradient(fire_gradient.spectrum[-1], char.animation.input_bg_color, steps=8) if char.animation.input_bg_color else None ) if fg_gradient or bg_gradient: final_color_scn.apply_gradient_to_symbols( char.input_symbol, 4, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: final_color_scn.add_frame(char.input_symbol, 4, colors=ColorPair()) else: for color in Gradient(fire_gradient.spectrum[-1], self.character_final_color_map[char], steps=8): final_color_scn.add_frame(char.input_symbol, 4, colors=ColorPair(fg=color)) char.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, burn_scn, EventHandler.Action.ACTIVATE_SCENE, final_color_scn, ) char.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, burn_scn, EventHandler.Action.CALLBACK, EventHandler.Callback(lambda c: self._emit_smoke(c.input_coord, self.config.smoke_chance)), ) self.pending_chars.append(char) def __next__(self) -> str: """Return the next frame in the animation.""" if self.algo.char_link_order or self.active_characters: self.active_characters.update(self.pending_smoke) self.pending_smoke.clear() for _ in range(random.randint(2, 4)): if self.algo.char_link_order: next_char = self.algo.char_link_order.pop(0) if not self._is_burnable(next_char): continue next_char.animation.activate_scene("burn") self.active_characters.add(next_char) self.update() return self.frame raise StopIteration class Burn(BaseEffect[BurnConfig]): """Characters are ignited and burn up the screen. Attributes: effect_config (BurnConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[BurnConfig]: return BurnConfig @property def _iterator_cls(self) -> type[BurnIterator]: return BurnIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_colorshift.py000066400000000000000000000350401517776150200310510ustar00rootroot00000000000000"""Display a gradient that shifts colors across the terminal. Classes: ColorShift: Display a gradient that shifts colors across the terminal. ColorShiftConfig: Configuration for the ColorShift effect. ColorShiftIterator: Iterator for the ColorShift effect. Does not normally need to be called directly. """ from __future__ import annotations from dataclasses import dataclass from terminaltexteffects import Color, EffectCharacter, EventHandler, Gradient, geometry from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.graphics import ColorPair def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "colorshift", ColorShift, ColorShiftConfig @dataclass class ColorShiftConfig(BaseConfig): """Configuration for the ColorShift effect. Attributes: gradient_stops (tuple[Color, ...]): Tuple of colors for the gradient. If only one color is provided, the characters will be displayed in that color. gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the gradient animation. no_loop (bool): Do not loop the gradient. If not set, the gradient generation will loop the final gradient color back to the first gradient color. no_travel (bool): Do not display the gradient as a wave. travel_direction (Gradient.Direction): Direction the gradient travels across the canvas. reverse_travel_direction (bool): Reverse the gradient travel direction. cycles (int): Number of times to cycle the gradient. Use 0 for infinite. Valid values are n >= 0. skip_final_gradient (bool): Skip the final gradient. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use for the final gradient. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient across the canvas. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="colorshift", help="Display a gradient that shifts colors across the terminal.", description="Display a gradient that shifts colors across the terminal.", epilog=( "Example: terminaltexteffects colorshift --gradient-stops e81416 ffa500 faeb36 79c314 487de7 4b369d " "70369d --gradient-steps 12 --gradient-frames 2 [--no-loop] [--no-travel] --travel-direction radial " "[--reverse-travel-direction] --cycles 3 [--skip-final-gradient] " "--final-gradient-stops e81416 ffa500 faeb36 79c314 487de7 4b369d 70369d " "--final-gradient-steps 12 --final-gradient-direction vertical" ), ) gradient_stops: tuple[Color, ...] = argutils.ArgSpec( name="--gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=( Color("#e81416"), Color("#ffa500"), Color("#faeb36"), Color("#79c314"), Color("#487de7"), Color("#4b369d"), Color("#70369d"), ), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the gradient.", ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the gradient. If only one color is provided, the characters will " "be displayed in that color." ) gradient_steps: tuple[int, ...] | int = argutils.ArgSpec( name="--gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", action=argutils.TupleAction, default=12, metavar=argutils.PositiveInt.METAVAR, help="Number of gradient steps to use. More steps will create a smoother gradient animation.", ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) gradient_frames: int = argutils.ArgSpec( name="--gradient-frames", type=argutils.PositiveInt.type_parser, default=2, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to display each gradient step. Increase to slow down the gradient animation.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." no_travel: bool = argutils.ArgSpec( name="--no-travel", default=False, action="store_true", help="Do not display the gradient as a wave.", ) # pyright: ignore[reportAssignmentType] "bool : Do not display the gradient as a wave." travel_direction: Gradient.Direction = argutils.ArgSpec( name="--travel-direction", default=Gradient.Direction.RADIAL, type=argutils.GradientDirection.type_parser, metavar=argutils.GradientDirection.METAVAR, help="Direction the gradient travels across the canvas.", ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction the gradient travels across the canvas." reverse_travel_direction: bool = argutils.ArgSpec( name="--reverse-travel-direction", default=False, action="store_true", help="Reverse the gradient travel direction.", ) # pyright: ignore[reportAssignmentType] "bool : Reverse the gradient travel direction." no_loop: bool = argutils.ArgSpec( name="--no-loop", default=False, action="store_true", help="Do not loop the gradient. If not set, the gradient generation will loop the final gradient " "color back to the first gradient color.", ) # pyright: ignore[reportAssignmentType] ( "bool : Do not loop the gradient. If not set, the gradient generation will loop the final gradient color " "back to the first gradient color." ) cycles: int = argutils.ArgSpec( name="--cycles", type=argutils.PositiveInt.type_parser, default=3, metavar=argutils.PositiveInt.METAVAR, help="Number of times to cycle the gradient.", ) # pyright: ignore[reportAssignmentType] "int : Number of times to cycle the gradient. Use 0 for infinite." skip_final_gradient: bool = argutils.ArgSpec( name="--skip-final-gradient", default=False, action="store_true", help="Skip the final gradient.", ) # pyright: ignore[reportAssignmentType] "bool : Skip the final gradient." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=( Color("#e81416"), Color("#ffa500"), Color("#faeb36"), Color("#79c314"), Color("#487de7"), Color("#4b369d"), Color("#70369d"), ), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class ColorShiftIterator(BaseEffectIterator[ColorShiftConfig]): """Iterator for the ColorShift effect.""" def __init__(self, effect: ColorShift) -> None: """Initialize the iterator with the provided effect. Args: effect (ColorShift): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, Color] = {} self.loop_tracker_map: dict[EffectCharacter, int] = {} self.build() def loop_tracker(self, character: EffectCharacter) -> None: """Track the number of times a character has looped through the gradient.""" self.loop_tracker_map[character] = self.loop_tracker_map.get(character, 0) + 1 if self.config.cycles == 0 or (self.loop_tracker_map[character] < self.config.cycles): character.animation.activate_scene("gradient") elif not self.config.skip_final_gradient: character.animation.activate_scene("final_gradient") def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] gradient = Gradient(*self.config.gradient_stops, steps=self.config.gradient_steps, loop=not self.config.no_loop) for character in self.terminal.get_characters(): self.terminal.set_character_visibility(character, is_visible=True) gradient_scn = character.animation.new_scene(scene_id="gradient") if self.config.no_travel: colors = gradient.spectrum else: if self.config.travel_direction == Gradient.Direction.HORIZONTAL: direction_index = character.input_coord.column / self.terminal.canvas.right elif self.config.travel_direction == Gradient.Direction.VERTICAL: direction_index = character.input_coord.row / self.terminal.canvas.top elif self.config.travel_direction == Gradient.Direction.DIAGONAL: direction_index = (character.input_coord.row + character.input_coord.column) / ( self.terminal.canvas.right + self.terminal.canvas.top ) else: # radial direction_index = 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, ) shift_distance = int(len(gradient.spectrum) * direction_index) if self.config.reverse_travel_direction: shift_distance = shift_distance * -1 colors = gradient.spectrum[shift_distance:] + gradient.spectrum[:shift_distance] for color in colors: gradient_scn.add_frame( character.input_symbol, self.config.gradient_frames, colors=ColorPair(fg=color), ) final_color_scn = character.animation.new_scene(scene_id="final_gradient") if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(colors[-1], character.animation.input_fg_color, steps=8) if character.animation.input_fg_color else None ) bg_gradient = ( Gradient(colors[-1], character.animation.input_bg_color, steps=8) if character.animation.input_bg_color else None ) if fg_gradient or bg_gradient: final_color_scn.apply_gradient_to_symbols( character.input_symbol, self.config.gradient_frames, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: final_color_scn.add_frame( character.input_symbol, self.config.gradient_frames, colors=ColorPair(), ) else: for color in Gradient(colors[-1], self.character_final_color_map[character], steps=8): final_color_scn.add_frame( character.input_symbol, self.config.gradient_frames, colors=ColorPair(fg=color), ) character.animation.activate_scene(gradient_scn) self.active_characters.add(character) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, gradient_scn, EventHandler.Action.CALLBACK, EventHandler.Callback(self.loop_tracker), ) def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_chars or self.active_characters: # perform effect logic self.update() return self.frame raise StopIteration class ColorShift(BaseEffect[ColorShiftConfig]): """Display a gradient that shifts colors across the terminal.""" @property def _config_cls(self) -> type[ColorShiftConfig]: return ColorShiftConfig @property def _iterator_cls(self) -> type[ColorShiftIterator]: return ColorShiftIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_crumble.py000066400000000000000000000362621517776150200303350ustar00rootroot00000000000000"""Characters crumble into dust before being vacuumed up and reformed. Classes: Crumble: Characters crumble into dust before being vacuumed up and reformed. CrumbleConfig: Configuration for the Crumble effect. CrumbleIterator: Iterates over the Crumble effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, Coord, EffectCharacter, EventHandler, Gradient, 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 from terminaltexteffects.utils.graphics import ColorPair def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "crumble", Crumble, CrumbleConfig @dataclass class CrumbleConfig(BaseConfig): """Configuration for the Crumble effect. Attributes: final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="crumble", help="Characters lose color and crumble into dust, vacuumed up, and reformed.", description="crumble | Characters lose color and crumble into dust, vacuumed up, and reformed.", epilog=( "Example: terminaltexteffects crumble --final-gradient-stops 5CE1FF FF8C00 --final-gradient-steps 12 " "--final-gradient-direction diagonal" ), ) final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#5CE1FF"), Color("#FF8C00")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class CrumbleIterator(BaseEffectIterator[CrumbleConfig]): """Iterator for the Crumble effect.""" DYNAMIC_NEUTRAL_GRAY = Color("#808080") def __init__(self, effect: Crumble) -> None: """Initialize the iterator with the provided effect. Args: effect (Crumble): The effect to iterate over. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, Color] = {} self.build() def build(self) -> None: # noqa: PLR0915 """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] if self.terminal.config.existing_color_handling == "dynamic": has_existing_colors = any((character.animation.input_fg_color, character.animation.input_bg_color)) weak_fg_color = ( character.animation.adjust_color_brightness(character.animation.input_fg_color, 0.65) if character.animation.input_fg_color else ( character.animation.adjust_color_brightness(self.DYNAMIC_NEUTRAL_GRAY, 0.65) if not character.animation.input_bg_color else None ) ) weak_bg_color = ( character.animation.adjust_color_brightness(character.animation.input_bg_color, 0.65) if character.animation.input_bg_color else None ) dust_fg_color = ( character.animation.adjust_color_brightness(character.animation.input_fg_color, 0.55) if character.animation.input_fg_color else ( character.animation.adjust_color_brightness(self.DYNAMIC_NEUTRAL_GRAY, 0.55) if not character.animation.input_bg_color else None ) ) dust_bg_color = ( character.animation.adjust_color_brightness(character.animation.input_bg_color, 0.55) if character.animation.input_bg_color else None ) strengthen_flash_fg_gradient = ( Gradient(character.animation.input_fg_color, Color("#ffffff"), steps=6) if character.animation.input_fg_color else ( Gradient(self.DYNAMIC_NEUTRAL_GRAY, Color("#ffffff"), steps=6) if not has_existing_colors else None ) ) strengthen_flash_bg_gradient = ( Gradient(character.animation.input_bg_color, Color("#ffffff"), steps=6) if character.animation.input_bg_color else None ) strengthen_fg_gradient = ( Gradient(Color("#ffffff"), character.animation.input_fg_color, steps=9) if character.animation.input_fg_color else None ) strengthen_bg_gradient = ( Gradient(Color("#ffffff"), character.animation.input_bg_color, steps=9) if character.animation.input_bg_color else None ) else: weak_fg_color = character.animation.adjust_color_brightness( self.character_final_color_map[character], 0.65, ) weak_bg_color = None dust_fg_color = character.animation.adjust_color_brightness( self.character_final_color_map[character], 0.55, ) dust_bg_color = None strengthen_flash_fg_gradient = Gradient( self.character_final_color_map[character], Color("#ffffff"), steps=6, ) strengthen_flash_bg_gradient = None strengthen_fg_gradient = Gradient(Color("#ffffff"), self.character_final_color_map[character], steps=9) strengthen_bg_gradient = None weaken_fg_gradient = ( Gradient(weak_fg_color, dust_fg_color, steps=9) if weak_fg_color and dust_fg_color else None ) weaken_bg_gradient = ( Gradient(weak_bg_color, dust_bg_color, steps=9) if weak_bg_color and dust_bg_color else None ) self.terminal.set_character_visibility(character, is_visible=True) # set up initial and falling stage initial_scn = character.animation.new_scene() initial_scn.add_frame(character.input_symbol, 1, colors=ColorPair(fg=weak_fg_color, bg=weak_bg_color)) character.animation.activate_scene(initial_scn) fall_path = character.motion.new_path( speed=0.65, ease=easing.out_bounce, ) fall_path.new_waypoint(Coord(character.input_coord.column, self.terminal.canvas.bottom)) weaken_scn = character.animation.new_scene(scene_id="weaken") weaken_scn.apply_gradient_to_symbols( character.input_symbol, 4, fg_gradient=weaken_fg_gradient, bg_gradient=weaken_bg_gradient, ) top_path = character.motion.new_path(path_id="top", speed=1, ease=easing.out_quint) top_path.new_waypoint( Coord(character.input_coord.column, self.terminal.canvas.top), bezier_control=Coord(self.terminal.canvas.center_column, self.terminal.canvas.center_row), ) # set up reset stage input_path = character.motion.new_path(path_id="input", speed=1) input_path.new_waypoint(character.input_coord) strengthen_flash_scn = character.animation.new_scene() strengthen_flash_scn.apply_gradient_to_symbols( character.input_symbol, 4, fg_gradient=strengthen_flash_fg_gradient, bg_gradient=strengthen_flash_bg_gradient, ) strengthen_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic" and not any( (character.animation.input_fg_color, character.animation.input_bg_color), ): strengthen_scn.add_frame(character.input_symbol, 4, colors=ColorPair()) else: strengthen_scn.apply_gradient_to_symbols( character.input_symbol, 4, fg_gradient=strengthen_fg_gradient, bg_gradient=strengthen_bg_gradient, ) dust_scn = character.animation.new_scene(sync=Scene.SyncMetric.DISTANCE) for _ in range(5): dust_scn.add_frame( random.choice(["*", ".", ","]), 1, colors=ColorPair(fg=dust_fg_color, bg=dust_bg_color), ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, weaken_scn, EventHandler.Action.ACTIVATE_PATH, fall_path, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, weaken_scn, EventHandler.Action.SET_LAYER, 1, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, weaken_scn, EventHandler.Action.ACTIVATE_SCENE, dust_scn, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_path, EventHandler.Action.ACTIVATE_SCENE, strengthen_flash_scn, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, strengthen_flash_scn, EventHandler.Action.ACTIVATE_SCENE, strengthen_scn, ) self.pending_chars.append(character) random.shuffle(self.pending_chars) self.fall_delay = 12 self.max_fall_delay = 12 self.min_fall_delay = 9 self.reset = False self.fall_group_maxsize = 1 self.stage = "falling" self.unvacuumed_chars = list(self.terminal._input_characters) random.shuffle(self.unvacuumed_chars) def __next__(self) -> str: """Return the next frame in the animation.""" if self.stage != "complete": if self.stage == "falling": if self.pending_chars: if self.fall_delay == 0: # Determine the size of the next group of falling characters fall_group_size = random.randint(1, self.fall_group_maxsize) # Add the next group of falling characters to the animating characters list for _ in range(fall_group_size): if self.pending_chars: next_char = self.pending_chars.pop(0) next_char.animation.activate_scene("weaken") self.active_characters.add(next_char) # Reset the fall delay and adjust the fall group size and delay range self.fall_delay = random.randint(self.min_fall_delay, self.max_fall_delay) if random.randint(1, 10) > 4: # 60% chance to modify the fall delay and group size self.fall_group_maxsize += 1 self.min_fall_delay = max(0, self.min_fall_delay - 1) self.max_fall_delay = max(0, self.max_fall_delay - 1) else: self.fall_delay -= 1 if not self.pending_chars and not self.active_characters: self.stage = "vacuuming" elif self.stage == "vacuuming": if self.unvacuumed_chars: for _ in range(random.randint(3, 10)): if self.unvacuumed_chars: next_char = self.unvacuumed_chars.pop(0) next_char.motion.activate_path("top") self.active_characters.add(next_char) if not self.active_characters: self.stage = "resetting" elif self.stage == "resetting": if not self.reset: for character in self.terminal.get_characters(): character.motion.activate_path("input") self.active_characters.add(character) self.reset = True if not self.active_characters: self.stage = "complete" self.update() return self.frame raise StopIteration class Crumble(BaseEffect[CrumbleConfig]): """Characters crumble into dust before being vacuumed up and reformed. Attributes: effect_config (CrumbleConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[CrumbleConfig]: return CrumbleConfig @property def _iterator_cls(self) -> type[CrumbleIterator]: return CrumbleIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_decrypt.py000066400000000000000000000302171517776150200303500ustar00rootroot00000000000000"""Movie style text decryption effect. Classes: Decrypt: Movie style text decryption effect. DecryptConfig: Configuration for the Decrypt effect. DecryptIterator: Iterates over the Decrypt effect. Does not normally need to be called directly. """ from __future__ import annotations import random import typing from dataclasses import dataclass from typing import cast from terminaltexteffects import Color, ColorPair, EffectCharacter, EventHandler, Gradient, Scene 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "decrypt", Decrypt, DecryptConfig @dataclass class DecryptConfig(BaseConfig): """Configuration for the Decrypt effect. Attributes: typing_speed (int): Number of characters typed per keystroke. ciphertext_colors (tuple[Color, ...]): Colors for the ciphertext. Color will be randomly selected for each character. final_gradient_stops (tuple[Color, ...]): Colors for the character gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Number of gradient steps to use. More steps will create a smoother and longer gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="decrypt", help="Display a movie style decryption effect.", description="decrypt | Movie style decryption effect.", epilog=( "Example: terminaltexteffects decrypt --typing-speed 2 --ciphertext-colors 008000 00cb00 00ff00 " "--final-gradient-stops eda000 --final-gradient-steps 12 --final-gradient-direction vertical" ), ) typing_speed: int = argutils.ArgSpec( name="--typing-speed", type=argutils.PositiveInt.type_parser, default=2, metavar=argutils.PositiveInt.METAVAR, help="Number of characters typed per keystroke.", ) # pyright: ignore[reportAssignmentType] "int : Number of characters typed per keystroke." ciphertext_colors: tuple[Color, ...] = argutils.ArgSpec( name="--ciphertext-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#008000"), Color("#00cb00"), Color("#00ff00")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the ciphertext. Color will be randomly selected for " "each character.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Colors for the ciphertext. Color will be randomly selected for each character." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#eda000"),), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Colors for the character gradient. If only one color is provided, the characters " "will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Number of gradient steps to use. More steps will create a smoother and " "longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class DecryptIterator(BaseEffectIterator[DecryptConfig]): """Iterator for the Decrypt effect.""" @dataclass class _DecryptChars: keyboard: typing.ClassVar[list[int]] = list(range(33, 127)) blocks: typing.ClassVar[list[int]] = list(range(9608, 9632)) box_drawing: typing.ClassVar[list[int]] = list(range(9472, 9599)) misc: typing.ClassVar[list[int]] = list(range(174, 452)) def __init__(self, effect: Decrypt) -> None: """Initialize the iterator with the provided effect. Args: effect (Decrypt): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.typing_pending_chars: list[EffectCharacter] = [] self.decrypting_pending_chars: set[EffectCharacter] = set() self.phase = "typing" self.encrypted_symbols: list[str] = [] self.scenes: dict[str, Scene] = {} self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.make_encrypted_symbols() self.build() def make_encrypted_symbols(self) -> None: """Create a list of encrypted symbols.""" for n in DecryptIterator._DecryptChars.keyboard: self.encrypted_symbols.append(chr(n)) for n in DecryptIterator._DecryptChars.blocks: self.encrypted_symbols.append(chr(n)) for n in DecryptIterator._DecryptChars.box_drawing: self.encrypted_symbols.append(chr(n)) for n in DecryptIterator._DecryptChars.misc: self.encrypted_symbols.append(chr(n)) def make_decrypting_animation_scenes(self, character: EffectCharacter) -> None: """Create the animation scenes for decrypting the text.""" fast_decrypt_scene = character.animation.new_scene(scene_id="fast_decrypt") color = random.choice(self.config.ciphertext_colors) for _ in range(80): symbol = random.choice(self.encrypted_symbols) fast_decrypt_scene.add_frame(symbol, 2, colors=ColorPair(fg=color)) duration = 2 slow_decrypt_scene = character.animation.new_scene(scene_id="slow_decrypt") for _ in range(random.randint(1, 15)): # 1-15 longer duration units symbol = random.choice(self.encrypted_symbols) # 30% chance of extra long duration # wide duration range reduces 'waves' in the animation # shorter duration creates flipping effect duration = random.randrange(35, 60) if random.randint(0, 100) <= 30 else random.randrange(3, 6) slow_decrypt_scene.add_frame(symbol, duration, colors=ColorPair(fg=color)) discovered_scene = character.animation.new_scene(scene_id="discovered") if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(Color("#ffffff"), character.animation.input_fg_color, steps=10) if character.animation.input_fg_color else None ) bg_gradient = ( Gradient(Color("#ffffff"), character.animation.input_bg_color, steps=10) if character.animation.input_bg_color else None ) if fg_gradient or bg_gradient: discovered_scene.apply_gradient_to_symbols( character.input_symbol, 5, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: discovered_scene.add_frame(character.input_symbol, 5, colors=ColorPair()) else: discovered_gradient = Gradient( Color("#ffffff"), cast("Color", self.character_final_color_map[character].fg_color), steps=10, ) discovered_scene.apply_gradient_to_symbols(character.input_symbol, 5, fg_gradient=discovered_gradient) def prepare_data_for_type_effect(self) -> None: """Prepare the data for the typing effect.""" for character in self.terminal.get_characters(): typing_scene = character.animation.new_scene(scene_id="typing") for block_char in ["▉", "▓", "▒", "░"]: typing_scene.add_frame( block_char, 2, colors=ColorPair(fg=random.choice(self.config.ciphertext_colors)), ) typing_scene.add_frame( random.choice(self.encrypted_symbols), 1, colors=ColorPair(fg=random.choice(self.config.ciphertext_colors)), ) self.typing_pending_chars.append(character) def prepare_data_for_decrypt_effect(self) -> None: """Prepare the data for the decrypting effect.""" for character in self.terminal.get_characters(): self.make_decrypting_animation_scenes(character) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, "fast_decrypt", EventHandler.Action.ACTIVATE_SCENE, "slow_decrypt", ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, "slow_decrypt", EventHandler.Action.ACTIVATE_SCENE, "discovered", ) character.animation.activate_scene("fast_decrypt") self.decrypting_pending_chars.add(character) def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) self.prepare_data_for_type_effect() self.prepare_data_for_decrypt_effect() def __next__(self) -> str: """Return the next frame in the animation.""" if self.phase == "typing": if self.typing_pending_chars or self.active_characters: if self.typing_pending_chars and random.randint(0, 100) <= 75: for _ in range(self.config.typing_speed): if self.typing_pending_chars: next_character = self.typing_pending_chars.pop(0) self.terminal.set_character_visibility(next_character, is_visible=True) next_character.animation.activate_scene("typing") self.active_characters.add(next_character) self.update() return self.frame self.active_characters = self.decrypting_pending_chars for char in self.active_characters: char.animation.activate_scene("fast_decrypt") self.phase = "decrypting" if self.phase == "decrypting": if self.active_characters: self.update() return self.frame raise StopIteration raise StopIteration class Decrypt(BaseEffect[DecryptConfig]): """Movie style text decryption effect. Attributes: effect_config (DecryptConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[DecryptConfig]: return DecryptConfig @property def _iterator_cls(self) -> type[DecryptIterator]: return DecryptIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_errorcorrect.py000066400000000000000000000365771517776150200314300ustar00rootroot00000000000000"""Swaps characters from an incorrect initial position to the correct position. Classes: ErrorCorrect: Swaps characters from an incorrect initial position to the correct position. ErrorCorrectConfig: Configuration for the ErrorCorrect effect. ErrorCorrectIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from typing import cast from terminaltexteffects import Color, EffectCharacter, EventHandler, Gradient, Path, Scene from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.graphics import ColorPair def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "errorcorrect", ErrorCorrect, ErrorCorrectConfig @dataclass class ErrorCorrectConfig(BaseConfig): """Configuration for the ErrorCorrect effect. Attributes: error_pairs (float): Percent of characters that are in the wrong position. This is a float between 0 and 1.0. 0.2 means 20 percent of the characters will be in the wrong position. Valid values are 0 < n <= 1.0. swap_delay (int): Number of frames between swaps. Valid values are n >= 0. error_color (Color): Color for the characters that are in the wrong position. correct_color (Color): Color for the characters once corrected, this is a gradient from error-color and fades to final-color. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. movement_speed (float): Speed of the characters while moving to the correct position. Valid values are n > 0. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="errorcorrect", help="Some characters start in the wrong position and are corrected in sequence.", description="errorcorrect | Some characters start in the wrong position and are corrected in sequence.", epilog=( f"{argutils.EASING_EPILOG}" "Example: terminaltexteffects errorcorrect --error-pairs 0.1 --swap-delay 6 --error-color e74c3c " "--correct-color 45bf55 --movement-speed 0.9 --final-gradient-stops 8A008A 00D1FF ffffff " "--final-gradient-steps 12 --final-gradient-direction vertical" ), ) error_pairs: float = argutils.ArgSpec( name="--error-pairs", type=argutils.PositiveFloat.type_parser, default=0.1, metavar="(int > 0)", help="Percent of characters that are in the wrong position. This is a float between 0 and 1.0. 0.2 means " "20 percent of the characters will be in the wrong position.", ) # pyright: ignore[reportAssignmentType] ( "float : Percent of characters that are in the wrong position. This is a float between 0 and 1.0. 0.2 " "means 20 percent of the characters will be in the wrong position." ) swap_delay: int = argutils.ArgSpec( name="--swap-delay", type=argutils.PositiveInt.type_parser, default=6, metavar="(int > 0)", help="Number of frames between swaps.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames between swaps." error_color: Color = argutils.ArgSpec( name="--error-color", type=argutils.ColorArg.type_parser, default=Color("#e74c3c"), metavar="(XTerm [0-255] OR RGB Hex [000000-ffffff])", help="Color for the characters that are in the wrong position.", ) # pyright: ignore[reportAssignmentType] "Color : Color for the characters that are in the wrong position." correct_color: Color = argutils.ArgSpec( name="--correct-color", type=argutils.ColorArg.type_parser, default=Color("#45bf55"), metavar="(XTerm [0-255] OR RGB Hex [000000-ffffff])", help="Color for the characters once corrected, this is a gradient from error-color and fades to final-color.", ) # pyright: ignore[reportAssignmentType] "Color : Color for the characters once corrected, this is a gradient from error-color and fades to final-color." movement_speed: float = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=0.9, metavar="(float > 0)", help="Speed of the characters while moving to the correct position. ", ) # pyright: ignore[reportAssignmentType] "float : Speed of the characters while moving to the correct position. " final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." @classmethod def get_effect_class(cls) -> type[ErrorCorrect]: """Get the effect class associated with this configuration.""" return ErrorCorrect class ErrorCorrectIterator(BaseEffectIterator[ErrorCorrectConfig]): """Iterates over the ErrorCorrect effect.""" def __init__(self, effect: ErrorCorrect) -> None: """Initialize the iterator. Args: effect (ErrorCorrect): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.swapped: list[tuple[EffectCharacter, EffectCharacter]] = [] self.swap_delay = 0 self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def _get_dynamic_final_scene(self, character: EffectCharacter) -> Scene: """Build the dynamic final scene for a swapped character.""" final_scene = character.animation.new_scene() fg_gradient = ( Gradient(self.config.correct_color, character.animation.input_fg_color, steps=10) if character.animation.input_fg_color else None ) bg_gradient = ( Gradient(self.config.correct_color, character.animation.input_bg_color, steps=10) if character.animation.input_bg_color else None ) if fg_gradient or bg_gradient: final_scene.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: final_scene.add_frame(character.input_symbol, 3, colors=ColorPair()) return final_scene def _configure_swapped_character( self, character: EffectCharacter, correcting_gradient: Gradient, block_wipe_start: tuple[str, ...], block_wipe_end: tuple[str, ...], ) -> None: """Configure scenes, paths, and events for a swapped character.""" first_block_wipe = character.animation.new_scene() last_block_wipe = character.animation.new_scene() for block in block_wipe_start: first_block_wipe.add_frame(block, 3, colors=ColorPair(fg=self.config.error_color)) if self.terminal.config.existing_color_handling == "dynamic": for block in block_wipe_end[:-1]: last_block_wipe.add_frame(block, 3, colors=ColorPair(fg=self.config.correct_color)) last_block_wipe.add_frame(block_wipe_end[-1], 3, colors=self.character_final_color_map[character]) else: for block in block_wipe_end: last_block_wipe.add_frame(block, 3, colors=ColorPair(fg=self.config.correct_color)) initial_scene = character.animation.new_scene() initial_scene.add_frame(character.input_symbol, 1, colors=ColorPair(fg=self.config.error_color)) character.animation.activate_scene(initial_scene) error_scene = character.animation.new_scene(scene_id="error") for _ in range(10): error_scene.add_frame("▓", 3, colors=ColorPair(fg=self.config.error_color)) error_scene.add_frame(character.input_symbol, 3, colors=ColorPair("#ffffff")) correcting_scene = character.animation.new_scene(sync=Scene.SyncMetric.DISTANCE) correcting_scene.apply_gradient_to_symbols("█", 3, fg_gradient=correcting_gradient) if self.terminal.config.existing_color_handling == "dynamic": final_scene = self._get_dynamic_final_scene(character) else: final_scene = character.animation.new_scene() char_final_gradient = Gradient( self.config.correct_color, cast("Color", self.character_final_color_map[character].fg_color), steps=10, ) final_scene.apply_gradient_to_symbols(character.input_symbol, 3, fg_gradient=char_final_gradient) input_coord_path = character.motion.query_path("input_coord") assert isinstance(input_coord_path, Path) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, error_scene, EventHandler.Action.ACTIVATE_SCENE, first_block_wipe, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, first_block_wipe, EventHandler.Action.ACTIVATE_SCENE, correcting_scene, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, first_block_wipe, EventHandler.Action.ACTIVATE_PATH, "input_coord", ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, "input_coord", EventHandler.Action.SET_LAYER, 1, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, "input_coord", EventHandler.Action.SET_LAYER, 0, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, "input_coord", EventHandler.Action.ACTIVATE_SCENE, last_block_wipe, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, last_block_wipe, EventHandler.Action.ACTIVATE_SCENE, final_scene, ) def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) for character in self.terminal.get_characters(): spawn_scene = character.animation.new_scene() spawn_colors = self.character_final_color_map[character] spawn_scene.add_frame( character.input_symbol, 1, colors=spawn_colors, ) character.animation.activate_scene(spawn_scene) self.terminal.set_character_visibility(character, is_visible=True) all_characters: list[EffectCharacter] = list(self.terminal._input_characters) correcting_gradient = Gradient(self.config.error_color, self.config.correct_color, steps=10) block_wipe_start = ("▁", "▂", "▃", "▄", "▅", "▆", "▇", "█") block_wipe_end = ("▇", "▆", "▅", "▄", "▃", "▂", "▁") for _ in range(int(self.config.error_pairs * len(self.terminal.get_characters()))): if len(all_characters) < 2: break char1 = all_characters.pop(random.randrange(len(all_characters))) char2 = all_characters.pop(random.randrange(len(all_characters))) char1.motion.set_coordinate(char2.input_coord) char1_input_coord_path = char1.motion.new_path(path_id="input_coord", speed=self.config.movement_speed) char1_input_coord_path.new_waypoint(char1.input_coord) char2.motion.set_coordinate(char1.input_coord) char2_input_coord_path = char2.motion.new_path(path_id="input_coord", speed=self.config.movement_speed) char2_input_coord_path.new_waypoint(char2.input_coord) self.swapped.append((char1, char2)) for character in (char1, char2): self._configure_swapped_character(character, correcting_gradient, block_wipe_start, block_wipe_end) def __next__(self) -> str: """Return the next frame in the animation.""" if self.swapped and not self.swap_delay: next_pair = self.swapped.pop(0) for char in next_pair: char.animation.activate_scene("error") self.active_characters.add(char) self.swap_delay = self.config.swap_delay elif self.swap_delay: self.swap_delay -= 1 if self.active_characters: self.update() return self.frame raise StopIteration class ErrorCorrect(BaseEffect[ErrorCorrectConfig]): """Swaps characters from an incorrect initial position to the correct position. Attributes: effect_config (ErrorCorrectConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[ErrorCorrectConfig]: return ErrorCorrectConfig @property def _iterator_cls(self) -> type[ErrorCorrectIterator]: return ErrorCorrectIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_expand.py000066400000000000000000000210111517776150200301450ustar00rootroot00000000000000"""Characters expand from the center. Classes: Expand: Characters expand from the center. ExpandConfig: Configuration for the Expand effect. ExpandIterator: Iterates over the effect. """ from __future__ import annotations from dataclasses import dataclass from typing import cast from terminaltexteffects import Color, EffectCharacter, EventHandler, Gradient, 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 from terminaltexteffects.utils.graphics import ColorPair def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "expand", Expand, ExpandConfig @dataclass class ExpandConfig(BaseConfig): """Configuration for the Expand effect. Attributes: movement_speed (float): Movement speed of the characters. expand_easing (easing.EasingFunction): Easing function to use for character movement. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec = argutils.ParserSpec( name="expand", help="Expands the text from a single point.", description="expand | Expands the text from a single point.", epilog=( f"{argutils.EASING_EPILOG}" "Example: terminaltexteffects expand --final-gradient-stops 8A008A 00D1FF FFFFFF --final-gradient-steps 12 " "--final-gradient-direction vertical --movement-speed 0.35 --expand-easing IN_OUT_QUART" ), ) expand_easing: easing.EasingFunction = argutils.ArgSpec( name="--expand-easing", default=easing.in_out_quart, type=argutils.Ease.type_parser, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." movement_speed: float = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=0.35, metavar=argutils.PositiveFloat.METAVAR, help="Movement speed of the characters. ", ) # pyright: ignore[reportAssignmentType] "float : Movement speed of the characters. " final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class ExpandIterator(BaseEffectIterator[ExpandConfig]): """Iterates over the Expand effect.""" def __init__( self, effect: Expand, ) -> None: """Initialize the Expand effect iterator. Args: effect (Expand): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the Expand effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) for character in self.terminal.get_characters(): character.motion.set_coordinate(self.terminal.canvas.center) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.expand_easing, ) input_coord_path.new_waypoint(character.input_coord) self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, input_coord_path, EventHandler.Action.SET_LAYER, 1, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_coord_path, EventHandler.Action.SET_LAYER, 0, ) character.motion.activate_path(input_coord_path) gradient_scn = character.animation.new_scene(sync=Scene.SyncMetric.DISTANCE) if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(final_gradient.spectrum[0], character.animation.input_fg_color, steps=10) if character.animation.input_fg_color else None ) bg_gradient = ( Gradient(final_gradient.spectrum[0], character.animation.input_bg_color, steps=10) if character.animation.input_bg_color else None ) if fg_gradient or bg_gradient: gradient_scn.apply_gradient_to_symbols( character.input_symbol, 1, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: gradient_scn.add_frame( character.input_symbol, 1, colors=ColorPair(), ) else: gradient = Gradient( final_gradient.spectrum[0], cast("Color", self.character_final_color_map[character].fg_color), steps=10, ) gradient_scn.apply_gradient_to_symbols( character.input_symbol, 5, fg_gradient=gradient, ) character.animation.activate_scene(gradient_scn) def __next__(self) -> str: """Return the next frame in the animation.""" if self.active_characters: self.update() return self.frame raise StopIteration class Expand(BaseEffect[ExpandConfig]): """Characters expand from the center. Attributes: effect_config (ExpandConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[ExpandConfig]: return ExpandConfig @property def _iterator_cls(self) -> type[ExpandIterator]: return ExpandIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_fireworks.py000066400000000000000000000372561517776150200307230ustar00rootroot00000000000000"""Launches characters up the screen where they explode like fireworks and fall into place. Classes: Fireworks: Characters explode like fireworks and fall into place. FireworksConfig: Configuration for the Fireworks effect. FireworksIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from typing import cast from terminaltexteffects import ( Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, Scene, easing, geometry, ) 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "fireworks", Fireworks, FireworksConfig @dataclass class FireworksConfig(BaseConfig): """Configuration for the Fireworks effect. Attributes: explode_anywhere (bool): If set, fireworks explode anywhere in the canvas. Otherwise, fireworks explode above highest settled row of text. firework_colors (tuple[Color, ...]): Tuple of colors from which firework colors will be randomly selected. firework_symbol (str): Symbol to use for the firework shell. firework_volume (float): Percent of total characters in each firework shell. Valid values are 0 < n <= 1. launch_delay (int): Number of frames to wait between launching each firework shell. +/- 0-50 percent randomness is applied to this value. Valid values are n >= 0. explode_distance (float): Maximum distance from the firework shell origin to the explode waypoint as a percentage of the total canvas width. Valid values are 0 < n <= 1. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="fireworks", help="Characters launch and explode like fireworks and fall into place.", description="fireworks | Characters explode like fireworks and fall into place.", epilog=( "Example: terminaltexteffects fireworks [--explode-anywhere] --firework-colors 88F7E2 44D492 F5EB67 " "FFA15C FA233E --firework-symbol o --firework-volume 0.05 --launch-delay 45 --explode-distance 0.2 " "--final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 12 " "--final-gradient-direction horizontal" ), ) explode_anywhere: bool = argutils.ArgSpec( name="--explode-anywhere", action="store_true", default=False, help="If set, fireworks explode anywhere in the canvas. Otherwise, fireworks explode above highest settled " "row of text.", ) # pyright: ignore[reportAssignmentType] ( "bool : If set, fireworks explode anywhere in the canvas. Otherwise, fireworks explode above highest " "settled row of text." ) firework_colors: tuple[Color, ...] = argutils.ArgSpec( name="--firework-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#88F7E2"), Color("#44D492"), Color("#F5EB67"), Color("#FFA15C"), Color("#FA233E")), metavar=argutils.ColorArg.METAVAR, help="Space separated list of colors from which firework colors will be randomly selected.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors from which firework colors will be randomly selected." firework_symbol: str = argutils.ArgSpec( name="--firework-symbol", type=argutils.Symbol.type_parser, default="o", metavar=argutils.Symbol.METAVAR, help="Symbol to use for the firework shell.", ) # pyright: ignore[reportAssignmentType] "str : Symbol to use for the firework shell." firework_volume: float = argutils.ArgSpec( name="--firework-volume", type=argutils.NonNegativeRatio.type_parser, default=0.05, metavar=argutils.NonNegativeRatio.METAVAR, help="Percent of total characters in each firework shell.", ) # pyright: ignore[reportAssignmentType] "float : Percent of total characters in each firework shell." launch_delay: int = argutils.ArgSpec( name="--launch-delay", type=argutils.NonNegativeInt.type_parser, default=45, metavar=argutils.NonNegativeInt.METAVAR, help="Number of frames to wait between launching each firework shell. +/- 0-50 percent randomness is " "applied to this value.", ) # pyright: ignore[reportAssignmentType] ( "int : Number of frames to wait between launching each firework shell. +/- 0-50 percent randomness is " "applied to this value." ) explode_distance: float = argutils.ArgSpec( name="--explode-distance", default=0.2, type=argutils.NonNegativeRatio.type_parser, metavar=argutils.NonNegativeRatio.METAVAR, help="Maximum distance from the firework shell origin to the explode waypoint as a percentage of the " "total canvas width.", ) # pyright: ignore[reportAssignmentType] ( "float : Maximum distance from the firework shell origin to the explode waypoint as a percentage of " "the total canvas width." ) final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.HORIZONTAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class FireworksIterator(BaseEffectIterator[FireworksConfig]): """Iterator for the Fireworks effect.""" def __init__(self, effect: Fireworks) -> None: """Initialize the Fireworks effect iterator. Args: effect (Fireworks): The Fireworks effect to iterate over. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.shells: list[list[EffectCharacter]] = [] self.firework_volume = max(1, round(self.config.firework_volume * len(self.terminal._input_characters))) self.explode_distance = min(15, max(1, round(self.terminal.canvas.right * self.config.explode_distance))) self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.launch_delay: int = 0 self.build() def prepare_waypoints(self) -> None: """Prepare the waypoints for the characters.""" firework_shell: list[EffectCharacter] = [] for character in self.terminal.get_characters(): if len(firework_shell) == self.firework_volume or not firework_shell: origin_x = random.randrange(0, self.terminal.canvas.right) self.shells.append(firework_shell) firework_shell = [] min_row = character.input_coord.row if not self.config.explode_anywhere else self.terminal.canvas.bottom origin_y = random.randrange(min_row, self.terminal.canvas.top + 1) origin_coord = Coord(origin_x, origin_y) explode_waypoint_coords = geometry.find_coords_in_circle(origin_coord, self.explode_distance) character.motion.set_coordinate(Coord(origin_x, self.terminal.canvas.bottom)) # type: ignore[attr-defined] apex_path = character.motion.new_path(path_id="apex_pth", speed=0.35, ease=easing.out_expo, layer=2) apex_wpt = apex_path.new_waypoint(origin_coord) # type: ignore[attr-defined] explode_path = character.motion.new_path(speed=random.uniform(0.2, 0.4), ease=easing.out_circ, layer=2) explode_wpt = explode_path.new_waypoint(random.choice(explode_waypoint_coords)) # type: ignore[attr-defined] bloom_control_point = geometry.extrapolate_along_ray( apex_wpt.coord, explode_wpt.coord, self.explode_distance // 2, ) bloom_wpt = explode_path.new_waypoint( Coord(bloom_control_point.column, max(1, bloom_control_point.row - 7)), bezier_control=bloom_control_point, ) input_path = character.motion.new_path(path_id="input_pth", speed=0.6, ease=easing.in_out_quart, layer=2) input_control_point = Coord(bloom_wpt.coord.column, 1) input_path.new_waypoint(character.input_coord, bezier_control=input_control_point) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, apex_path, EventHandler.Action.ACTIVATE_PATH, explode_path, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, explode_path, EventHandler.Action.ACTIVATE_PATH, input_path, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_path, EventHandler.Action.SET_LAYER, 0, ) character.motion.activate_path(apex_path) firework_shell.append(character) if firework_shell: self.shells.append(firework_shell) def prepare_scenes(self) -> None: """Prepare the scenes for the characters.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) for firework_shell in self.shells: shell_color = random.choice(self.config.firework_colors) shell_gradient = Gradient(shell_color, Color("#FFFFFF"), shell_color, steps=5) for character in firework_shell: # launch scene launch_scn = character.animation.new_scene() launch_scn.add_frame(self.config.firework_symbol, 2, colors=ColorPair(fg=shell_color)) launch_scn.add_frame(self.config.firework_symbol, 1, colors=ColorPair("#FFFFFF")) launch_scn.is_looping = True # bloom scene bloom_scn = character.animation.new_scene(sync=Scene.SyncMetric.STEP) for color in shell_gradient: bloom_scn.add_frame(character.input_symbol, 2, colors=ColorPair(fg=color)) # fall scene fall_scn = character.animation.new_scene(scene_id="fall_scn") if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(shell_color, character.animation.input_fg_color, steps=15) if character.animation.input_fg_color else None ) bg_gradient = ( Gradient(shell_color, character.animation.input_bg_color, steps=15) if character.animation.input_bg_color else None ) if fg_gradient or bg_gradient: fall_scn.apply_gradient_to_symbols( character.input_symbol, 10, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: fall_scn.add_frame(character.input_symbol, 10, colors=ColorPair()) else: fall_gradient = Gradient( shell_color, cast("Color", self.character_final_color_map[character].fg_color), steps=15, ) fall_scn.apply_gradient_to_symbols(character.input_symbol, 10, fg_gradient=fall_gradient) character.animation.activate_scene(launch_scn) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, "apex_pth", EventHandler.Action.ACTIVATE_SCENE, bloom_scn, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, "input_pth", EventHandler.Action.ACTIVATE_SCENE, fall_scn, ) def build(self) -> None: """Build the Fireworks effect.""" self.prepare_waypoints() self.prepare_scenes() def __next__(self) -> str: """Return the next frame in the animation.""" if self.shells or self.active_characters: if self.shells and self.launch_delay <= 0: next_group = self.shells.pop() for character in next_group: self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) self.launch_delay = int(self.config.launch_delay * random.uniform(0.5, 1.5)) self.launch_delay -= 1 self.update() return self.frame raise StopIteration class Fireworks(BaseEffect[FireworksConfig]): """Launches characters up the screen where they explode like fireworks and fall into place. Attributes: effect_config (FireworksConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[FireworksConfig]: return FireworksConfig @property def _iterator_cls(self) -> type[FireworksIterator]: return FireworksIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_highlight.py000066400000000000000000000211571517776150200306500ustar00rootroot00000000000000"""Runs a specular highlight across the text. Classes: Highlight: Runs a specular highlight across the text. HighlightConfig: Configuration for the Highlight effect. HighlightIterator: Effect iterator for the Highlight effect. """ from __future__ import annotations from dataclasses import dataclass from terminaltexteffects import Animation, Color, ColorPair, EffectCharacter, Gradient, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "highlight", Highlight, HighlightConfig @dataclass class HighlightConfig(BaseConfig): """Configuration for the Highlight effect. Attributes: highlight_brightness (float): Brightness of the highlight color. Values less than 1 will darken the highlight color, while values greater than 1 will brighten the highlight color. highlight_direction (CharacterGroup): Direction the highlight will travel. highlight_width (int): Width of the highlight. n >= 1 final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Int or Tuple of ints for the number of gradient steps to use. More steps will create a smoother and longer gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="highlight", help="Run a specular highlight across the text.", description="highlight | Run a specular highlight across the text.", epilog=( f"{argutils.EASING_EPILOG}Example: terminaltexteffects highlight --highlight-brightness 1.75 " "--highlight-direction " "diagonal_bottom_left_to_top_right --highlight-width 8 --final-gradient-stops 8A008A 00D1FF FFFFFF " "--final-gradient-steps 12 --final-gradient-direction vertical" ), ) highlight_brightness: float = argutils.ArgSpec( name="--highlight-brightness", type=argutils.PositiveFloat.type_parser, default=1.75, metavar=argutils.PositiveFloat.METAVAR, help="Brightness of the highlight color. Values less than 1 will darken the highlight color, while values " "greater than 1 will brighten the highlight color.", ) # pyright: ignore[reportAssignmentType] ( "float : Brightness of the highlight color. Values less than 1 will darken the highlight color, while " "values greater than 1 will brighten the highlight color." ) highlight_direction: argutils.CharacterGroup = argutils.ArgSpec( name="--highlight-direction", default=argutils.CharacterGroup.DIAGONAL_BOTTOM_LEFT_TO_TOP_RIGHT, metavar=" ".join(argutils.CharacterGroupArg.METAVAR), help="Direction the highlight will travel.", type=argutils.CharacterGroupArg.type_parser, ) # pyright: ignore[reportAssignmentType] ("CharacterGroup : Direction the highlight will travel.") highlight_width: int = argutils.ArgSpec( name="--highlight-width", type=argutils.PositiveInt.type_parser, default=8, metavar=argutils.PositiveInt.METAVAR, help="Width of the highlight. n >= 1", ) # pyright: ignore[reportAssignmentType] "int : Width of the highlight. n >= 1" final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class HighlightIterator(BaseEffectIterator[HighlightConfig]): """Effect iterator for the Highlight effect.""" def __init__(self, effect: Highlight) -> None: """Initialize the Highlight effect iterator. Args: effect (Highlight): The Highlight effect to iterate over. """ super().__init__(effect) self.character_final_color_map: dict[EffectCharacter, Color | None] = {} self.pending_characters: list[list[EffectCharacter]] = [] self.easer = easing.SequenceEaser( sequence=self.terminal.get_characters_grouped(self.config.highlight_direction), easing_function=easing.in_out_circ, ) self.build() def build(self) -> None: """Build the effect.""" final_gradient = Gradient( *self.config.final_gradient_stops, steps=self.config.final_gradient_steps, ) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): input_bg_color = None if self.terminal.config.existing_color_handling == "dynamic": base_color = character.animation.input_fg_color input_bg_color = character.animation.input_bg_color else: base_color = final_gradient_mapping[character.input_coord] self.character_final_color_map[character] = base_color base_colors = ColorPair(fg=base_color, bg=input_bg_color) if base_color: highlight_color = Animation.adjust_color_brightness( base_color, self.config.highlight_brightness, ) highlight_gradient = Gradient( base_color, highlight_color, highlight_color, base_color, steps=(3, self.config.highlight_width, 3), ) character.animation.set_appearance(character.input_symbol, base_colors) else: highlight_gradient = None character.animation.set_appearance(character.input_symbol, base_colors) specular_highlight_scn = character.animation.new_scene(scene_id="highlight") if highlight_gradient: for color in highlight_gradient: specular_highlight_scn.add_frame( character.input_symbol, 2, colors=ColorPair(fg=color, bg=input_bg_color), ) else: specular_highlight_scn.add_frame( character.input_symbol, 2, colors=base_colors, ) self.terminal.set_character_visibility(character, is_visible=True) def __next__(self) -> str: """Return the next frame in the animation.""" if self.active_characters or not self.easer.is_complete(): self.easer.step() for group in self.easer.added: for character in group: character.animation.activate_scene("highlight") self.active_characters.add(character) self.update() return self.frame raise StopIteration class Highlight(BaseEffect[HighlightConfig]): """Run a specular highlight across the text.""" @property def _config_cls(self) -> type[HighlightConfig]: return HighlightConfig @property def _iterator_cls(self) -> type[HighlightIterator]: return HighlightIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_laseretch.py000066400000000000000000000534051517776150200306540ustar00rootroot00000000000000"""A laser etches characters onto the terminal. Classes: LaserEtch: A laser etches characters onto the terminal. LaserEtchConfig: Configuration for the LaserEtch effect. LaserEtchIterator: Iterator for the LaserEtch effect. """ from __future__ import annotations import random from collections import deque from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.spanningtree.algo.recursivebacktracker import RecursiveBacktracker def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "laseretch", LaserEtch, LaserEtchConfig def _etch_pattern_type_parser(value: str) -> argutils.CharacterGroup | str: if value == "algorithm": return "algorithm" return argutils.CharacterGroupArg.type_parser(value) @dataclass class LaserEtchConfig(BaseConfig): """LaserEtch effect configuration dataclass. Attributes: etch_direction (typing.Literal['column_left_to_right','row_top_to_bottom','row_bottom_to_top',diagonal_top_left_to_bottom_right','diagonal_bottom_left_to_top_right','diagonal_top_right_to_bottom_left','diagonal_bottom_right_to_top_left']): Pattern used to etch the text. etch_speed (int): Along with etch_delay, determines the speed at which the characters are etched onto the terminal. This value specifies the number of characters to etch simultaneously. etch_delay (int): Along with etch_speed, determines the speed at which the characters are etched onto the terminal. This values specifies the number of frames to wait before etching the next group of characters. cool_gradient_stops (tuple[tte.Color, ...]): Space separated, unquoted, list of colors for the gradient used to cool the characters after etching. If only one color is provided, the characters will be displayed in that color. laser_gradient_stops (tuple[tte.Color, ...]): Space separated, unquoted, list of colors for the laser gradient. If only one color is provided, the characters will be displayed in that color. spark_gradient_stops (tuple[tte.Color, ...]): Space separated, unquoted, list of colors for the spark cooling gradient. If only one color is provided, the characters will be displayed in that color. spark_cooling_frames (int): Number of frames to display each spark cooling gradient step. Increase to slow down the rate of cooling. final_gradient_stops (tuple[tte.Color, ...]): 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. final_gradient_steps (tuple[int, ...] | int): Space separated, unquoted, list of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the gradient animation. final_gradient_direction (tte.Gradient.Direction): Direction of the final gradient. """ # noqa: E501 parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="laseretch", help="A laser etches characters onto the terminal.", description="A laser etches characters onto the terminal.", epilog=( "Example: terminaltexteffects laseretch --etch-pattern algorithm --etch-speed 1 --etch-delay 1 " "--cool-gradient-stops ffe680 ff7b00 --laser-gradient-stops ffffff 376cff " "--spark-gradient-stops ffffff ffe680 ff7b00 1a0900 --spark-cooling-frames 7 --final-gradient-stops " "8A008A 00D1FF ffffff --final-gradient-steps 8 --final-gradient-frames 4 " "--final-gradient-direction vertical" ), ) etch_pattern: argutils.CharacterGroup = argutils.ArgSpec( name="--etch-pattern", default="algorithm", type=_etch_pattern_type_parser, metavar="algorithm " + " ".join(argutils.CharacterGroupArg.METAVAR), help="Pattern used to etch the text.", ) # pyright: ignore[reportAssignmentType] "CharacterGroup: Pattern used to etch the text." etch_speed: int = argutils.ArgSpec( name="--etch-speed", type=argutils.PositiveInt.type_parser, default=1, metavar=argutils.PositiveInt.METAVAR, help="Along with etch_delay, determines the speed at which the characters are etched onto the terminal. " "This value specifies the number of characters to etch simultaneously.", ) # pyright: ignore[reportAssignmentType] ( "int: Along with etch_delay, determines the speed at which the characters are etched onto the terminal. " "This value specifies the number of characters to etch simultaneously." ) etch_delay: int = argutils.ArgSpec( name="--etch-delay", type=argutils.NonNegativeInt.type_parser, default=1, metavar=argutils.NonNegativeInt.METAVAR, help="Along with etch_speed, determines the speed at which the characters are etched onto the terminal. " "This values specifies the number of frames to wait before etching the next set of characters.", ) # pyright: ignore[reportAssignmentType] ( "int: Along with etch_speed, determines the speed at which the characters are etched onto the terminal. " "This values specifies the number of frames to wait before etching the next set of characters." ) cool_gradient_stops: tuple[tte.Color, ...] = argutils.ArgSpec( name="--cool-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#ffe680"), tte.Color("#ff7b00")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the gradient used to cool the characters after etching. " "If only one color is provided, the characters will be displayed in that color.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...]: Space separated, unquoted, list of colors for the cooling gradient " "If only one color is provided, the characters will be displayed in that color." laser_gradient_stops: tuple[tte.Color, ...] = argutils.ArgSpec( name="--laser-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#ffffff"), tte.Color("#376cff")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the laser gradient. " "If only one color is provided, the characters will be displayed in that color.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...]: Space separated, unquoted, list of colors for the laser gradient. " "If only one color is provided, the characters will be displayed in that color." spark_gradient_stops: tuple[tte.Color, ...] = argutils.ArgSpec( name="--spark-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#ffffff"), tte.Color("#ffe680"), tte.Color("#ff7b00"), tte.Color("#1a0900")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the spark cooling gradient. " "If only one color is provided, the characters will be displayed in that color.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...]: Space separated, unquoted, list of colors for the spark cooling gradient. " "If only one color is provided, the characters will be displayed in that color." spark_cooling_frames: int = argutils.ArgSpec( name="--spark-cooling-frames", type=argutils.PositiveInt.type_parser, default=7, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to display each spark cooling gradient step. Increase to slow down the rate of cooling.", ) # pyright: ignore[reportAssignmentType] "int: Number of frames to display each spark cooling gradient step. Increase to slow down the rate of cooling." final_gradient_stops: tuple[tte.Color, ...] = FinalGradientStopsArg( default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#ffffff")), ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...]: 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." final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=8, ) # pyright: ignore[reportAssignmentType] "tuple[int, ...] | int: Space separated, unquoted, list of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." final_gradient_frames: int = FinalGradientFramesArg( default=4, ) # pyright: ignore[reportAssignmentType] "int: Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: tte.Gradient.Direction = FinalGradientDirectionArg( default=tte.Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class LaserEtchIterator(BaseEffectIterator[LaserEtchConfig]): """Iterator for the LaserEtch effect.""" class Laser: """A class to represent a laser beam effect in a terminal. The Laser class is responsible for creating and managing a laser beam effect in a terminal. It handles the initialization of the laser beam, the creation of spark effects, repositioning of the laser beam, emitting sparks, and disabling the laser beam. Methods: reposition(target: Coord) -> None: Repositions the laser beam to the target coordinate. emit(spark_count: int = 1) -> None: Emits a specified number of sparks from the laser beam. disable() -> None: Disables the laser beam by setting the visibility of the beam characters to False. """ def __init__( self, terminal: tte.Terminal, config: LaserEtchConfig, active_chars: set[tte.EffectCharacter], ) -> None: """Initialize the laser beam. Args: terminal (Terminal): The effect terminal. config (LaserEtchConfig): The effect configuration. active_chars (set[EffectCharacter]): The set of active characters in the effect. """ self.terminal = terminal self.config = config self.active_chars = active_chars self.position: tte.Coord = tte.Coord(0, 0) row = 0 col = 0 self.beam_chars: list[tte.EffectCharacter] = [] laser_gradient = deque(tte.Gradient(*config.laser_gradient_stops, steps=6, loop=True)) self.spark_gradient = tte.Gradient( *config.spark_gradient_stops, steps=(3, 8), ) self.sparks = self._make_sparks() while row <= self.terminal.canvas.top: symbol = "*" if not self.beam_chars else "/" char = self.terminal.add_character(symbol, tte.Coord(col, row)) char.layer = 2 self.terminal.set_character_visibility(char, is_visible=True) row += 1 col += 1 self.beam_chars.append(char) laser_scn = char.animation.new_scene(scene_id="laser", is_looping=True) for color in laser_gradient: laser_scn.add_frame(char.input_symbol, 3, colors=tte.ColorPair(fg=color)) laser_gradient.rotate(-1) char.animation.activate_scene(laser_scn) def _make_sparks(self) -> deque[tte.EffectCharacter]: sparks: deque[tte.EffectCharacter] = deque() for _ in range(2000): new_char = self.terminal.add_character(random.choice((".", ",", "*")), self.position) spark_scn = new_char.animation.new_scene(scene_id="spark") for color in self.spark_gradient: spark_scn.add_frame( new_char.input_symbol, self.config.spark_cooling_frames, colors=tte.ColorPair(fg=color), ) new_char.event_handler.register_event( tte.EventHandler.Event.SCENE_COMPLETE, spark_scn, tte.EventHandler.Action.CALLBACK, tte.EventHandler.Callback(lambda c: self.terminal.set_character_visibility(c, is_visible=False)), ) new_char.layer = 2 sparks.append(new_char) return sparks def reposition(self, target: tte.Coord) -> None: """Reposition the laser beam to the target coordinate. Set the coordinate of the laser beam characters based on the target coordinate to create the appearance of a laser beam. Args: target (Coord): The target coordinate for the laser beam. """ self.position = target row = target.row col = target.column for char in self.beam_chars: char.motion.set_coordinate(tte.Coord(col, row)) row += 1 col += 1 self.emit_sparks() def emit_sparks(self, spark_count: int = 1) -> None: """Emit sparks from the laser beam. Sets up the spark character Path and activates the Path and Scene for each spark character. The spark characters are added to the effect active_characters set. Args: spark_count (int, optional): Number of spark characters to emit. Defaults to 1. """ for _ in range(spark_count): next_spark = self.sparks[-1] self.sparks.rotate(1) next_spark.motion.set_coordinate(self.position) if next_spark.animation.active_scene: next_spark.animation.active_scene.reset_scene() self.terminal.set_character_visibility(next_spark, is_visible=True) spark_path = next_spark.motion.new_path(ease=tte.easing.out_sine, speed=0.3) fall_target_coord = tte.Coord( random.randint(self.position.column - 20, self.position.column + 20), self.terminal.canvas.bottom, ) spark_path.new_waypoint( fall_target_coord, bezier_control=tte.Coord(fall_target_coord.column, self.position.row + random.randint(-10, 20)), ) next_spark.motion.activate_path(spark_path) next_spark.animation.activate_scene("spark") self.active_chars.add(next_spark) def disable(self) -> None: """Disable the laser beam by setting the visibility of the beam characters to False.""" for char in self.beam_chars: self.terminal.set_character_visibility(char, is_visible=False) def __init__(self, effect: LaserEtch) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.character_final_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.pending_chars: list[tte.EffectCharacter] = [] self.build() self.char_delay = 0 self.laser = LaserEtchIterator.Laser(self.terminal, self.config, self.active_characters) self.active_characters.update(self.laser.beam_chars) self.color_shifted_chars: set[tte.EffectCharacter] = set() @staticmethod def _has_input_colors(character: tte.EffectCharacter) -> bool: return any((character.animation.input_fg_color, character.animation.input_bg_color)) def build(self) -> None: """Build the effect.""" final_fg_gradient = tte.Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_fg_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 self.terminal.get_characters(): final_fg_color: tte.Color | None final_bg_color: tte.Color | None if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = tte.ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color cool_gradient = tte.Gradient( *self.config.cool_gradient_stops, steps=8, ) else: self.character_final_color_map[character] = tte.ColorPair( fg=final_gradient_mapping[character.input_coord], ) final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color cool_gradient = tte.Gradient( *self.config.cool_gradient_stops, final_gradient_mapping[character.input_coord], steps=8, ) spawn_scn = character.animation.new_scene(scene_id="spawn") spawn_scn.add_frame("^", duration=3, colors=tte.ColorPair("#ffe680")) for color in cool_gradient: spawn_scn.add_frame(character.input_symbol, 3, colors=tte.ColorPair(fg=color)) if self.terminal.config.existing_color_handling == "dynamic": if final_fg_color or final_bg_color: fg_gradient = ( tte.Gradient(cool_gradient.spectrum[-1], final_fg_color, steps=8) if final_fg_color else None ) bg_gradient = ( tte.Gradient(cool_gradient.spectrum[-1], final_bg_color, steps=8) if final_bg_color else None ) spawn_scn.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: white_cooldown = tte.Gradient(cool_gradient.spectrum[-1], tte.Color("#ffffff"), steps=8) spawn_scn.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=white_cooldown, ) spawn_scn.add_frame(character.input_symbol, 3, colors=tte.ColorPair()) character.animation.activate_scene(spawn_scn) if self.config.etch_pattern in argutils.CharacterGroup._member_names_: for n, char_list in enumerate( self.terminal.get_characters_grouped(self.config.etch_pattern), ): if n % 2: self.pending_chars.extend(char_list[::-1]) else: self.pending_chars.extend(char_list) elif self.config.etch_pattern == "algorithm": algo = RecursiveBacktracker(self.terminal, limit_to_text_boundary=True) while not algo.complete: algo.step() self.pending_chars = algo.char_link_order def __next__(self) -> str: """Return the next frame in the effect.""" while self.pending_chars or self.active_characters: if not self.char_delay: for _ in range(self.config.etch_speed): if not self.pending_chars: break next_char = self.pending_chars.pop(0) while next_char.input_symbol == " " and not self._has_input_colors(next_char): if self.pending_chars: next_char = self.pending_chars.pop(0) else: break self.terminal.set_character_visibility(next_char, is_visible=True) self.active_characters.add(next_char) self.laser.reposition(next_char.input_coord) self.char_delay = self.config.etch_delay else: self.char_delay -= 1 if self.pending_chars: self.active_characters.update(self.laser.beam_chars) else: self.laser.disable() self.update() return self.frame raise StopIteration class LaserEtch(BaseEffect[LaserEtchConfig]): """A laser etches characters onto the terminal.""" @property def _config_cls(self) -> type: return LaserEtchConfig @property def _iterator_cls(self) -> type: return LaserEtchIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_matrix.py000066400000000000000000000631141517776150200302040ustar00rootroot00000000000000"""Matrix digital rain effect. Classes: Matrix: Matrix digital rain effect. MatrixConfig: Configuration for the Matrix effect. MatrixIterator: Iterator for the Matrix effect. Does not normally need to be called directly. """ from __future__ import annotations import random import time from dataclasses import dataclass from terminaltexteffects import Animation, Color, ColorPair, Coord, EffectCharacter, Gradient, Terminal from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils MATRIX_SYMBOLS_COMMON = ( "2", "5", "9", "8", "Z", "*", ")", ":", ".", '"', "=", "+", "-", "¦", "|", "_", ) MATRIX_SYMBOLS_KATA = ( "ヲ", "ア", "ウ", "エ", "オ", "カ", "キ", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", "タ", "ツ", "テ", "ナ", "ニ", "ヌ", "ネ", "ハ", "ヒ", "ホ", "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ラ", "リ", "ワ", ) def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "matrix", Matrix, MatrixConfig @dataclass class MatrixConfig(BaseConfig): """Configuration for the Matrix effect. Attributes: highlight_color (Color): Color for the bottom of the rain column. rain_color_gradient (tuple[Color, ...]): Tuple of colors for the rain gradient. If only one color is " "provided, the characters will be displayed in that color. rain_symbols (tuple[str, ...]): Tuple of symbols to use for the rain. rain_fall_delay_range (tuple[int, int]): Speed of the falling rain as determined by the delay between rows. " "Actual delay is randomly selected from the range. rain_column_delay_range (tuple[int, int]): Range of frames to wait between adding new rain columns. rain_time (int): Time, in seconds, to display the rain effect before transitioning to the input text. symbol_swap_chance (float): Chance of swapping a character's symbol on each tick. color_swap_chance (float): Chance of swapping a character's color on each tick. resolve_delay (int): Number of frames to wait between resolving the next group of characters. This is used " "to adjust the speed of the final resolve phase. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color " "is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Int or Tuple of ints for the number of gradient steps to use. " "More steps will create a smoother and longer gradient animation. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the " "gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="matrix", help="Matrix digital rain effect.", description="matrix | Matrix digital rain effect.", epilog=( "Example: terminaltexteffects matrix --highlight-color dbffdb --rain-color-gradient 92be92 185318 " '--rain-symbols 2 5 9 8 Z * ) : . " = + - ¦ | _ ヲ ア ウ エ オ カ キ ケ コ サ シ ス セ ソ タ チ ツ テ ト ナ ニ ヌ ネ ' "ノ ハ ヒ フ ヘ ホ マ ミ ム メ モ ヤ ユ ヨ ラ リ ル レ ロ ワ ン ゙ ゚ --rain-fall-delay-range 2-15 " "--rain-column-delay-range 3-9 --rain-time 15 --symbol-swap-chance 0.005 --color-swap-chance 0.001 " "--resolve-delay 3 --final-gradient-stops 92be92 336b33 --final-gradient-steps 12 " "--final-gradient-frames 3 --final-gradient-direction radial" ), ) highlight_color: Color = argutils.ArgSpec( name="--highlight-color", type=argutils.ColorArg.type_parser, default=Color("#dbffdb"), metavar=argutils.ColorArg.METAVAR, help="Color for the bottom of the rain column.", ) # pyright: ignore[reportAssignmentType] "Color : Color for the bottom of the rain column." rain_color_gradient: tuple[Color, ...] = argutils.ArgSpec( name="--rain-color-gradient", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#92be92"), Color("#185318")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the rain gradient. Colors are selected from the " "gradient randomly. If only one color is provided, the characters will be displayed in that color.", ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the rain gradient. If only one color is provided, the characters " "will be displayed in that color." ) rain_symbols: tuple[str, ...] = argutils.ArgSpec( name="--rain-symbols", nargs="+", action=argutils.TupleAction, type=argutils.Symbol.type_parser, default=MATRIX_SYMBOLS_COMMON + MATRIX_SYMBOLS_KATA, metavar=argutils.Symbol.METAVAR, help="Space separated, unquoted, list of symbols to use for the rain.", ) # pyright: ignore[reportAssignmentType] "tuple[str, ...] : Tuple of symbols to use for the rain." rain_fall_delay_range: tuple[int, int] = argutils.ArgSpec( name="--rain-fall-delay-range", type=argutils.PositiveIntRange.type_parser, default=(2, 15), metavar=argutils.PositiveIntRange.METAVAR, help="Range for the speed of the falling rain as determined by the delay between rows. Actual delay is " "randomly selected from the range.", ) # pyright: ignore[reportAssignmentType] ( "tuple[int, int] : Speed of the falling rain as determined by the delay between rows. Actual delay is " "randomly selected from the range." ) rain_column_delay_range: tuple[int, int] = argutils.ArgSpec( name="--rain-column-delay-range", type=argutils.PositiveIntRange.type_parser, default=(3, 9), metavar=argutils.PositiveIntRange.METAVAR, help="Range of frames to wait between adding new rain columns.", ) # pyright: ignore[reportAssignmentType] "tuple[int, int] : Range of frames to wait between adding new rain columns." rain_time: int = argutils.ArgSpec( name="--rain-time", type=argutils.PositiveInt.type_parser, default=15, metavar=argutils.PositiveInt.METAVAR, help="Time, in seconds, to display the rain effect before transitioning to the input text.", ) # pyright: ignore[reportAssignmentType] "int : Time, in seconds, to display the rain effect before transitioning to the input text." symbol_swap_chance: float = argutils.ArgSpec( name="--symbol-swap-chance", type=argutils.PositiveFloat.type_parser, default=0.005, metavar=argutils.PositiveFloat.METAVAR, help="Chance of swapping a character's symbol on each tick.", ) # pyright: ignore[reportAssignmentType] "float : Chance of swapping a character's symbol on each tick." color_swap_chance: float = argutils.ArgSpec( name="--color-swap-chance", type=argutils.PositiveFloat.type_parser, default=0.001, metavar=argutils.PositiveFloat.METAVAR, help="Chance of swapping a character's color on each tick.", ) # pyright: ignore[reportAssignmentType] "float : Chance of swapping a character's color on each tick." resolve_delay: int = argutils.ArgSpec( name="--resolve-delay", type=argutils.PositiveInt.type_parser, default=3, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to wait between resolving the next group of characters. " "This is used to adjust the speed of the final resolve phase.", ) # pyright: ignore[reportAssignmentType] ( "int : Number of frames to wait between resolving the next group of characters. This is used to " "adjust the speed of the final resolve phase." ) final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#92be92"), Color("#336b33")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_frames: int = FinalGradientFramesArg( default=3, ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.RADIAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class MatrixIterator(BaseEffectIterator[MatrixConfig]): """Iterator for the Matrix effect.""" class RainColumn: """Rain column for the Matrix effect.""" def __init__( self, characters: list[EffectCharacter], terminal: Terminal, config: MatrixConfig, rain_colors: Gradient, ) -> None: """Initialize the rain column.""" self.terminal = terminal self.config = config self.characters: list[EffectCharacter] = characters self.pending_characters: list[EffectCharacter] = [] self.matrix_symbols: tuple[str, ...] = config.rain_symbols self.rain_colors = rain_colors self.column_drop_chance = 0.08 self.setup_column("rain") def setup_column(self, phase: str) -> None: """Set up the rain column for the specified phase.""" self.pending_characters.clear() self.phase = phase for character in self.characters: self.terminal.set_character_visibility(character, is_visible=False) self.pending_characters.append(character) character.motion.current_coord = character.input_coord self.visible_characters: list[EffectCharacter] = [] if self.phase == "fill": self.base_rain_fall_delay = random.randint( max(self.config.rain_fall_delay_range[0] // 3, 1), max(self.config.rain_fall_delay_range[1] // 3, 1), ) else: self.base_rain_fall_delay = random.randint( self.config.rain_fall_delay_range[0], self.config.rain_fall_delay_range[1], ) self.active_rain_fall_delay = 0 if self.phase == "rain": self.length = random.randint(max(1, int(len(self.characters) * 0.1)), len(self.characters)) else: self.length = len(self.characters) self.hold_time = 0 if self.length == len(self.characters): self.hold_time = random.randint(20, 45) def trim_column(self) -> None: """Trim the rain column.""" if not self.visible_characters: return popped_char = self.visible_characters.pop(0) self.terminal.set_character_visibility(popped_char, is_visible=False) if len(self.visible_characters) > 1: self.fade_last_character() def drop_column(self) -> None: """Drop the rain column.""" out_of_canvas = [] for character in self.visible_characters: character.motion.current_coord = Coord( character.motion.current_coord.column, character.motion.current_coord.row - 1, ) if character.motion.current_coord.row < self.terminal.canvas.bottom: self.terminal.set_character_visibility(character, is_visible=False) out_of_canvas.append(character) self.visible_characters = [char for char in self.visible_characters if char not in out_of_canvas] def fade_last_character(self) -> None: """Fade the last character in the rain column.""" darker_color = Animation.adjust_color_brightness(random.choice(self.rain_colors[-3:]), 0.65) # type: ignore[arg-type] self.visible_characters[0].animation.set_appearance( self.visible_characters[0].animation.current_character_visual.symbol, colors=ColorPair(fg=darker_color), ) def resolve_char(self) -> EffectCharacter: """Resolve a character in the rain column. Returns: EffectCharacter: The resolved character. """ return self.visible_characters.pop(random.randint(0, len(self.visible_characters) - 1)) def tick(self) -> None: """Advance the rain column by one tick.""" if not self.active_rain_fall_delay: if self.pending_characters: next_char = self.pending_characters.pop(0) next_char.animation.set_appearance( random.choice(self.matrix_symbols), colors=ColorPair(fg=self.config.highlight_color), ) previous_character = self.visible_characters[-1] if self.visible_characters else None # if there is a previous character, remove the highlight if previous_character: previous_character.animation.set_appearance( previous_character.animation.current_character_visual.symbol, colors=ColorPair( fg=random.choice(self.rain_colors), ), ) self.terminal.set_character_visibility(next_char, is_visible=True) self.visible_characters.append(next_char) # if no pending characters, but still visible characters, trim the column # unless the column is the full height of the canvas, then respect the hold # time before trimming elif self.visible_characters: # adjust the bottom character color to remove the lightlight. # always do this on the first hold frame, then # randomly adjust the bottom character's color # this is separately handled from the rest to prevent the # highlight color from being replaced before appropriate if ( self.visible_characters[-1].animation.current_character_visual.colors and self.visible_characters[-1].animation.current_character_visual.colors.fg_color == self.config.highlight_color ): self.visible_characters[-1].animation.set_appearance( self.visible_characters[-1].animation.current_character_visual.symbol, colors=ColorPair(fg=random.choice(self.rain_colors)), ) if self.hold_time: self.hold_time -= 1 elif self.phase == "rain": if random.random() < self.column_drop_chance: self.drop_column() self.trim_column() # if the column is longer than the preset length while still adding characters, trim it if len(self.visible_characters) > self.length: self.trim_column() self.active_rain_fall_delay = self.base_rain_fall_delay else: self.active_rain_fall_delay -= 1 # randomly change the symbol and/or color of the characters next_color: Color | None for character in self.visible_characters: if random.random() < self.config.symbol_swap_chance: next_symbol = random.choice(self.matrix_symbols) else: next_symbol = character.animation.current_character_visual.symbol if random.random() < self.config.color_swap_chance: next_color = random.choice(self.rain_colors) elif character.animation.current_character_visual.colors: next_color = character.animation.current_character_visual.colors.fg_color else: next_color = None character.animation.set_appearance(next_symbol, colors=ColorPair(fg=next_color)) def __init__(self, effect: Matrix) -> None: """Initialize the Matrix effect iterator.""" super().__init__(effect) self.pending_columns: list[MatrixIterator.RainColumn] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.active_columns: list[MatrixIterator.RainColumn] = [] self.full_columns: list[MatrixIterator.RainColumn] = [] self.rain_colors = Gradient(*self.config.rain_color_gradient, steps=6) self.column_delay = 0 self.resolve_delay = self.config.resolve_delay self.final_frame_shown = False self.rain_complete = False self.phase = "rain" self.build() self.rain_start = time.time() @staticmethod def _has_input_colors(character: EffectCharacter) -> bool: return any((character.animation.input_fg_color, character.animation.input_bg_color)) def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color resolve_scn = character.animation.new_scene(scene_id="resolve") if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = ( Gradient(self.config.highlight_color, final_fg_color, steps=8) if final_fg_color else None ) bg_gradient = ( Gradient(self.config.highlight_color, final_bg_color, steps=8) if final_bg_color else None ) if fg_gradient or bg_gradient: resolve_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: resolve_scn.add_frame( character.input_symbol, self.config.final_gradient_frames, colors=ColorPair(), ) else: assert final_fg_color is not None for color in Gradient( self.config.highlight_color, final_fg_color, steps=8, ): resolve_scn.add_frame( character.input_symbol, self.config.final_gradient_frames, colors=ColorPair(fg=color), ) for column_chars in self.terminal.get_characters_grouped( argutils.CharacterGroup.COLUMN_LEFT_TO_RIGHT, outer_fill_chars=True, inner_fill_chars=True, ): column_chars.reverse() self.pending_columns.append( MatrixIterator.RainColumn(column_chars, self.terminal, self.config, self.rain_colors), ) random.shuffle(self.pending_columns) def __next__(self) -> str: # noqa: PLR0915 """Return the next frame in the animation.""" if self.phase in ("rain", "fill"): if not self.column_delay: if self.phase == "rain": for _ in range(random.randint(1, 3)): if self.pending_columns: self.active_columns.append(self.pending_columns.pop(0)) else: while self.pending_columns: self.active_columns.append(self.pending_columns.pop(0)) if self.phase == "rain": self.column_delay = random.randint( self.config.rain_column_delay_range[0], self.config.rain_column_delay_range[1], ) else: self.column_delay = 1 else: self.column_delay -= 1 for column in self.active_columns: column.tick() if not column.pending_characters: if column.phase == "fill" and column not in self.full_columns: self.full_columns.append(column) elif not column.visible_characters: column.setup_column(self.phase) self.pending_columns.append(column) self.active_columns = [column for column in self.active_columns if column.visible_characters] if ( self.phase == "fill" and not self.pending_columns and all((not column.pending_characters and column.phase == "fill") for column in self.active_columns) ): self.phase = "resolve" self.active_columns.clear() if ( self.phase == "rain" and self.config.rain_time > 0 and time.time() - self.rain_start > self.config.rain_time ): self.rain_complete = True self.phase = "fill" for column in self.active_columns: column.hold_time = 0 column.column_drop_chance = 1 for column in self.pending_columns: column.setup_column(self.phase) elif self.phase == "resolve": for column in self.full_columns: column.tick() if column.visible_characters: if not self.resolve_delay: for _ in range(random.randint(1, 4)): if column.visible_characters: next_char = column.resolve_char() if next_char.input_symbol != " " or self._has_input_colors(next_char): next_char.animation.activate_scene("resolve") self.active_characters.add(next_char) else: self.terminal.set_character_visibility(next_char, is_visible=False) self.resolve_delay = self.config.resolve_delay else: self.resolve_delay -= 1 self.full_columns = [column for column in self.full_columns if column.visible_characters] if ( self.full_columns or self.active_columns or self.active_characters or self.pending_columns or not self.rain_complete ): self.update() return self.frame if not self.final_frame_shown: self.final_frame_shown = True self.update() return self.frame raise StopIteration class Matrix(BaseEffect[MatrixConfig]): """Matrix digital rain effect. Attributes: effect_config (MatrixConfig): Configuration for the Matrix effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[MatrixConfig]: return MatrixConfig @property def _iterator_cls(self) -> type[MatrixIterator]: return MatrixIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_middleout.py000066400000000000000000000264251517776150200306720ustar00rootroot00000000000000"""Text expands in a single row or column in the middle of the canvas then out. Classes: MiddleOut: Text expands in a single row or column in the middle of the canvas then out. MiddleOutConfig: Configuration for the Middleout effect. MiddleOutIterator: Iterates over the effect's frames. Does not normally need to be called directly. """ from __future__ import annotations import typing from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, Gradient, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "middleout", MiddleOut, MiddleOutConfig @dataclass class MiddleOutConfig(BaseConfig): """Configuration for the Middleout effect. Attributes: starting_color (Color): Color for the initial text in the center of the canvas. expand_direction (typing.Literal["vertical", "horizontal"]): Direction the text will expand. Choices: " "vertical, horizontal. center_movement_speed (float): Speed of the characters during the initial expansion of the center " "vertical/horiztonal. Valid values are n > 0. full_movement_speed (float): Speed of the characters during the final full expansion. Valid values are n > 0. center_easing (easing.EasingFunction): Easing function to use for initial expansion. full_easing (easing.EasingFunction): Easing function to use for full expansion. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color " "is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps " "will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="middleout", help="Text expands in a single row or column in the middle of the canvas then out.", description="middleout | Text expands in a single row or column in the middle of the canvas then out.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects middleout --starting-color ffffff " "--expand-direction vertical --center-movement-speed 0.6 --full-movement-speed 0.6 " "--center-easing IN_OUT_SINE --full-easing IN_OUT_SINE --final-gradient-stops 8A008A 00D1FF ffffff " "--final-gradient-steps 12 --final-gradient-direction vertical" ), ) starting_color: Color = argutils.ArgSpec( name="--starting-color", type=argutils.ColorArg.type_parser, default=Color("#ffffff"), metavar=argutils.ColorArg.METAVAR, help="Color for the initial text in the center of the canvas.", ) # pyright: ignore[reportAssignmentType] """Color : Color for the initial text in the center of the canvas.""" expand_direction: typing.Literal["vertical", "horizontal"] = argutils.ArgSpec( name="--expand-direction", default="vertical", choices=["vertical", "horizontal"], help="Direction the text will expand.", ) # pyright: ignore[reportAssignmentType] """str : Direction the text will expand.""" center_movement_speed: float = argutils.ArgSpec( name="--center-movement-speed", type=argutils.PositiveFloat.type_parser, default=0.6, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the characters during the initial expansion of the center vertical/horiztonal line. ", ) # pyright: ignore[reportAssignmentType] """float : Speed of the characters during the initial expansion of the center vertical/horiztonal line. """ full_movement_speed: float = argutils.ArgSpec( name="--full-movement-speed", type=argutils.PositiveFloat.type_parser, default=0.6, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the characters during the final full expansion. ", ) # pyright: ignore[reportAssignmentType] """float : Speed of the characters during the final full expansion. """ center_easing: easing.EasingFunction = argutils.ArgSpec( name="--center-easing", default=easing.in_out_sine, type=argutils.Ease.type_parser, help="Easing function to use for initial expansion.", ) # pyright: ignore[reportAssignmentType] """easing.EasingFunction : Easing function to use for initial expansion.""" full_easing: easing.EasingFunction = argutils.ArgSpec( name="--full-easing", default=easing.in_out_sine, type=argutils.Ease.type_parser, help="Easing function to use for full expansion.", ) # pyright: ignore[reportAssignmentType] """easing.EasingFunction : Easing function to use for full expansion.""" final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] """Gradient.Direction : Direction of the final gradient.""" class MiddleOutIterator(BaseEffectIterator[MiddleOutConfig]): """Iterates over the frames of the MiddleOut effect.""" def __init__(self, effect: MiddleOut) -> None: """Initialize the MiddleOut effect iterator. Args: effect (MiddleOut): The MiddleOut effect to iterate over. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.phase = "center" self.build() def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) character.motion.set_coordinate(self.terminal.canvas.center) # setup waypoints if self.config.expand_direction == "vertical": column = character.input_coord.column row = self.terminal.canvas.center_row else: column = self.terminal.canvas.center_column row = character.input_coord.row center_path = character.motion.new_path( speed=self.config.center_movement_speed, ease=self.config.center_easing, ) center_path.new_waypoint(Coord(column, row)) full_path = character.motion.new_path( path_id="full", speed=self.config.full_movement_speed, ease=self.config.full_easing, ) full_path.new_waypoint(character.input_coord, waypoint_id="full") # setup scenes full_scene = character.animation.new_scene(scene_id="full") final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color if self.terminal.config.existing_color_handling == "dynamic": fg_gradient = Gradient(self.config.starting_color, final_fg_color, steps=10) if final_fg_color else None bg_gradient = Gradient(self.config.starting_color, final_bg_color, steps=10) if final_bg_color else None if fg_gradient or bg_gradient: full_scene.apply_gradient_to_symbols( character.input_symbol, 6, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: full_scene.add_frame(character.input_symbol, 6, colors=ColorPair()) else: assert final_fg_color is not None full_gradient = Gradient(self.config.starting_color, final_fg_color, steps=10) full_scene.apply_gradient_to_symbols(character.input_symbol, 6, fg_gradient=full_gradient) # initialize character state character.motion.activate_path(center_path) character.animation.set_appearance(character.input_symbol, ColorPair(fg=self.config.starting_color)) self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) def __next__(self) -> str: """Return the next frame in the animation.""" if self.phase == "center" and not self.active_characters: self.phase = "full" self.active_characters = set(self.terminal.get_characters()) for character in self.active_characters: character.motion.activate_path("full") character.animation.activate_scene("full") if self.active_characters: self.update() return self.frame raise StopIteration class MiddleOut(BaseEffect[MiddleOutConfig]): """Text expands in a single row or column in the middle of the canvas then out. Attributes: effect_config (MiddleOutConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[MiddleOutConfig]: return MiddleOutConfig @property def _iterator_cls(self) -> type[MiddleOutIterator]: return MiddleOutIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_orbittingvolley.py000066400000000000000000000437341517776150200321420ustar00rootroot00000000000000"""Four launchers orbit the canvas firing volleys of characters inward to build the input text from the center out. Classes: OrbittingVolley: Four launchers orbit the canvas firing volleys of characters inward to build the input text from the center out. OrbittingVolleyConfig: Configuration for the OrbittingVolley effect. OrbittingVolleyIterator: Effect iterator for OrbittingVolley. Does not normally need to be called directly. """ from __future__ import annotations from dataclasses import dataclass from itertools import cycle from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, Terminal, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "orbittingvolley", OrbittingVolley, OrbittingVolleyConfig @dataclass class OrbittingVolleyConfig(BaseConfig): """Configuration for the OrbittingVolley effect. Attributes: top_launcher_symbol (str): Symbol for the top launcher. right_launcher_symbol (str): Symbol for the right launcher. bottom_launcher_symbol (str): Symbol for the bottom launcher. left_launcher_symbol (str): Symbol for the left launcher. launcher_movement_speed (float): Orbitting speed of the launchers. Valid values are n > 0. character_movement_speed (float): Speed of the launched characters. Valid values are n > 0. volley_size (float): Percent of total input characters each launcher will fire per volley. Lower limit of " "one character. Valid values are 0 < n <= 1. launch_delay (int): Number of animation ticks to wait between volleys of characters. Valid values are n >= 0. character_easing (easing.EasingFunction): Easing function to use for launched character movement. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one " "color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps " "will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="orbittingvolley", help=( "Four launchers orbit the canvas firing volleys of characters inward to build the input text " "from the center out." ), description=( "orbittingvolley | Four launchers orbit the canvas firing volleys of characters inward to build " "the input text from the center out." ), epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects orbittingvolley --top-launcher-symbol █ " "--right-launcher-symbol █ --bottom-launcher-symbol █ --left-launcher-symbol █ " "--launcher-movement-speed 0.8 --character-movement-speed 1.5 --volley-size 0.03 --launch-delay 30 " "--character-easing OUT_SINE --final-gradient-stops FFA15C 44D492 --final-gradient-steps 12 " "--final-gradient-direction radial" ), ) top_launcher_symbol: str = argutils.ArgSpec( name="--top-launcher-symbol", type=argutils.Symbol.type_parser, default="█", metavar=argutils.Symbol.METAVAR, help="Symbol for the top launcher.", ) # pyright: ignore[reportAssignmentType] "str : Symbol for the top launcher." right_launcher_symbol: str = argutils.ArgSpec( name="--right-launcher-symbol", type=argutils.Symbol.type_parser, default="█", metavar=argutils.Symbol.METAVAR, help="Symbol for the right launcher.", ) # pyright: ignore[reportAssignmentType] "str : Symbol for the right launcher." bottom_launcher_symbol: str = argutils.ArgSpec( name="--bottom-launcher-symbol", type=argutils.Symbol.type_parser, default="█", metavar=argutils.Symbol.METAVAR, help="Symbol for the bottom launcher.", ) # pyright: ignore[reportAssignmentType] "str : Symbol for the bottom launcher." left_launcher_symbol: str = argutils.ArgSpec( name="--left-launcher-symbol", type=argutils.Symbol.type_parser, default="█", metavar=argutils.Symbol.METAVAR, help="Symbol for the left launcher.", ) # pyright: ignore[reportAssignmentType] "str : Symbol for the left launcher." launcher_movement_speed: float = argutils.ArgSpec( name="--launcher-movement-speed", type=argutils.PositiveFloat.type_parser, default=0.8, metavar=argutils.PositiveFloat.METAVAR, help="Orbitting speed of the launchers.", ) # pyright: ignore[reportAssignmentType] "float : Orbitting speed of the launchers." character_movement_speed: float = argutils.ArgSpec( name="--character-movement-speed", type=argutils.PositiveFloat.type_parser, default=1.5, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the launched characters.", ) # pyright: ignore[reportAssignmentType] "float : Speed of the launched characters." volley_size: float = argutils.ArgSpec( name="--volley-size", type=argutils.NonNegativeRatio.type_parser, default=0.03, metavar=argutils.NonNegativeRatio.METAVAR, help="Percent of total input characters each launcher will fire per volley. Lower limit of one character.", ) # pyright: ignore[reportAssignmentType] "float : Percent of total input characters each launcher will fire per volley. Lower limit of one character." launch_delay: int = argutils.ArgSpec( name="--launch-delay", type=argutils.NonNegativeInt.type_parser, default=30, metavar=argutils.NonNegativeInt.METAVAR, help="Number of animation ticks to wait between volleys of characters.", ) # pyright: ignore[reportAssignmentType] "int : Number of animation ticks to wait between volleys of characters." character_easing: easing.EasingFunction = argutils.ArgSpec( name="--character-easing", default=easing.out_sine, type=argutils.Ease.type_parser, metavar=argutils.Ease.METAVAR, help="Easing function to use for launched character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for launched character movement." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#FFA15C"), Color("#44D492")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.RADIAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class OrbittingVolleyIterator(BaseEffectIterator[OrbittingVolleyConfig]): """Effect iterator for OrbittingVolley.""" class Launcher: """A launcher that fires characters inward to build the input text from the center out.""" def __init__( self, terminal: Terminal, args: OrbittingVolleyConfig, starting_edge_coord: Coord, symbol: str, ) -> None: """Initialize the launcher. Args: terminal (Terminal): The effect Terminal. args (OrbittingVolleyConfig): Configuration for the effect. starting_edge_coord (Coord): The starting coordinate for the launcher. symbol (str): The symbol to use for the launcher. """ self.terminal = terminal self.args = args self.character = self.terminal.add_character(symbol, starting_edge_coord) self.magazine: list[EffectCharacter] = [] def build_paths(self) -> None: """Build the paths for the launcher.""" waypoints = [ Coord(self.terminal.canvas.left, self.terminal.canvas.top), Coord(self.terminal.canvas.right, self.terminal.canvas.top), ] waypoint_start_index = waypoints.index(self.character.input_coord) perimeter_path = self.character.motion.new_path( speed=self.args.launcher_movement_speed, path_id="perimeter", layer=2, ) for waypoint in waypoints[waypoint_start_index:] + waypoints[:waypoint_start_index]: perimeter_path.new_waypoint(waypoint) def launch(self) -> EffectCharacter | None: """Launch a character from the magazine.""" if self.magazine: next_char = self.magazine.pop(0) next_char.motion.set_coordinate(self.character.motion.current_coord) next_char.motion.activate_path("input_path") self.terminal.set_character_visibility(next_char, is_visible=True) else: next_char = None return next_char def __init__(self, effect: OrbittingVolley) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.final_gradient_coordinate_map: dict[Coord, Color] = self.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, ) self.launcher_gradient_coordinate_map: dict[Coord, Color] = self.final_gradient.build_coordinate_color_mapping( self.terminal.canvas.bottom, self.terminal.canvas.top, self.terminal.canvas.left, self.terminal.canvas.right, self.config.final_gradient_direction, ) self.complete = False self.build() def build(self) -> None: """Build the initial state of the effect.""" for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=self.final_gradient_coordinate_map[character.input_coord], ) input_path = character.motion.new_path( speed=self.config.character_movement_speed, ease=self.config.character_easing, path_id="input_path", layer=1, ) input_path.new_waypoint(character.input_coord) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_path, EventHandler.Action.SET_LAYER, 0, ) character.animation.set_appearance( character.input_symbol, self.character_final_color_map[character], ) self._launchers: list[OrbittingVolleyIterator.Launcher] = [] for coord, symbol in ( ( Coord(self.terminal.canvas.left, self.terminal.canvas.top), self.config.top_launcher_symbol, ), ( Coord(self.terminal.canvas.right, self.terminal.canvas.top), self.config.right_launcher_symbol, ), ( Coord(self.terminal.canvas.right, self.terminal.canvas.bottom), self.config.bottom_launcher_symbol, ), ( Coord(self.terminal.canvas.left, self.terminal.canvas.bottom), self.config.left_launcher_symbol, ), ): launcher = OrbittingVolleyIterator.Launcher(self.terminal, self.config, coord, symbol) launcher.character.layer = 2 self.terminal.set_character_visibility(launcher.character, is_visible=True) self.active_characters.add(launcher.character) self._launchers.append(launcher) self._main_launcher = self._launchers[0] self._main_launcher.character.animation.set_appearance( self._main_launcher.character.input_symbol, ColorPair(fg=self.final_gradient.spectrum[-1]), ) self._main_launcher.build_paths() self._main_launcher.character.motion.activate_path("perimeter") self._sorted_chars = [] for char_list in self.terminal.get_characters_grouped(argutils.CharacterGroup.CENTER_TO_OUTSIDE): self._sorted_chars.extend(char_list) for launcher, character in zip(cycle(self._launchers), self._sorted_chars): launcher.magazine.append(character) self._delay = 0 def _set_launcher_coordinates(self, parent: Launcher, child: Launcher) -> None: parent_progress = parent.character.motion.current_coord.column / self.terminal.canvas.right if child.character.input_coord == Coord(self.terminal.canvas.right, self.terminal.canvas.top): child_row = self.terminal.canvas.top - int(self.terminal.canvas.top * parent_progress) child.character.motion.set_coordinate(Coord(self.terminal.canvas.right, max(1, child_row))) elif child.character.input_coord == Coord(self.terminal.canvas.right, self.terminal.canvas.bottom): child_column = self.terminal.canvas.right - int(self.terminal.canvas.right * parent_progress) child.character.motion.set_coordinate(Coord(max(1, child_column), self.terminal.canvas.bottom)) elif child.character.input_coord == Coord(self.terminal.canvas.left, self.terminal.canvas.bottom): child_row = self.terminal.canvas.bottom + int(self.terminal.canvas.top * parent_progress) child.character.motion.set_coordinate( Coord(self.terminal.canvas.left, min(self.terminal.canvas.top, child_row)), ) color = self.launcher_gradient_coordinate_map[child.character.motion.current_coord] child.character.animation.set_appearance(child.character.input_symbol, ColorPair(fg=color)) def __next__(self) -> str: """Return the next frame in the animation.""" if any(launcher.magazine for launcher in self._launchers) or len(self.active_characters) > 1: if self._main_launcher.character.motion.active_path is None: perimeter_path = self._main_launcher.character.motion.query_path("perimeter") self._main_launcher.character.motion.set_coordinate(perimeter_path.waypoints[0].coord) # pyright: ignore[reportOptionalMemberAccess] self._main_launcher.character.motion.activate_path(perimeter_path) # pyright: ignore[reportArgumentType] self.active_characters.add(self._main_launcher.character) self._main_launcher.character.animation.set_appearance( self.config.top_launcher_symbol, ColorPair( fg=self.launcher_gradient_coordinate_map[self._main_launcher.character.motion.current_coord], ), ) for launcher in self._launchers[1:]: self._set_launcher_coordinates(self._main_launcher, launcher) if not self._delay: for launcher in self._launchers: characters_to_launch = max( int((self.config.volley_size * len(self.terminal._input_characters)) / 4), 1, ) for _ in range(characters_to_launch): next_char = launcher.launch() if next_char: self.active_characters.add(next_char) self._delay = self.config.launch_delay else: self._delay -= 1 self.update() return self.frame if not self.complete: self.complete = True for launcher in self._launchers: self.terminal.set_character_visibility(launcher.character, is_visible=False) return self.frame raise StopIteration class OrbittingVolley(BaseEffect[OrbittingVolleyConfig]): """Four launchers orbit the canvas firing volleys of characters inward to build the input text from the center out. Attributes: effect_config (OrbittingVolleyConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[OrbittingVolleyConfig]: return OrbittingVolleyConfig @property def _iterator_cls(self) -> type[OrbittingVolleyIterator]: return OrbittingVolleyIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_overflow.py000066400000000000000000000314471517776150200305470ustar00rootroot00000000000000"""Input text overflows and scrolls the terminal in a random order until eventually appearing ordered. Classes: Overflow: Input text overflows and scrolls the terminal in a random order until eventually appearing ordered. OverflowConfig: Configuration for the Overflow effect. OverflowIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, Gradient from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "overflow", Overflow, OverflowConfig @dataclass class OverflowConfig(BaseConfig): """Configuration for the Overflow effect. Attributes: overflow_gradient_stops (tuple[Color, ...]): Tuple of colors for the overflow gradient. overflow_cycles_range (tuple[int, int]): Lower and upper range of the number of cycles to overflow the text. " "Valid values are n >= 0. overflow_speed (int): Speed of the overflow effect. Valid values are n > 0. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color " "is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps " "will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="overflow", help="Input text overflows and scrolls the terminal in a random order until eventually appearing ordered.", description="overflow | Input text overflows and scrolls the terminal in a random order until eventually " "appearing ordered.", epilog=( "Example: terminaltexteffects overflow --final-gradient-stops 8A008A 00D1FF FFFFFF " "--final-gradient-steps 12 --final-gradient-direction vertical --overflow-gradient-stops f2ebc0 8dbfb3 " "f2ebc0 --overflow-cycles-range 2-4 --overflow-speed 3" ), ) overflow_gradient_stops: tuple[Color, ...] = argutils.ArgSpec( name="--overflow-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#f2ebc0"), Color("#8dbfb3"), Color("#f2ebc0")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the overflow gradient.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the overflow gradient." overflow_cycles_range: tuple[int, int] = argutils.ArgSpec( name="--overflow-cycles-range", type=argutils.PositiveIntRange.type_parser, default=(2, 4), metavar=argutils.PositiveIntRange.METAVAR, help="Number of cycles to overflow the text.", ) # pyright: ignore[reportAssignmentType] "tuple[int, int] : Lower and upper range of the number of cycles to overflow the text." overflow_speed: int = argutils.ArgSpec( name="--overflow-speed", type=argutils.PositiveInt.type_parser, default=3, metavar=argutils.PositiveInt.METAVAR, help="Speed of the overflow effect.", ) # pyright: ignore[reportAssignmentType] "int : Speed of the overflow effect." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class OverflowIterator(BaseEffectIterator[OverflowConfig]): """Iterates over the effect.""" class Row: """Represents a row of characters in the overflow effect.""" def __init__(self, characters: list[EffectCharacter], *, final: bool = False) -> None: """Initialize the row. Args: characters (list[EffectCharacter]): The characters in the row. final (bool, optional): This is the final state of the row. Defaults to False. """ self.characters = characters self.current_index = 0 self.final = final def move_up(self) -> None: """Move the row up by one row.""" for character in self.characters: current_row = character.motion.current_coord.row character.motion.set_coordinate(Coord(character.motion.current_coord.column, current_row + 1)) def setup(self) -> None: """Set up the row for display.""" for character in self.characters: character.motion.set_coordinate(Coord(character.input_coord.column, 0)) def set_color(self, fg_color: Color | None = None, bg_color: Color | None = None) -> None: """Set the color of the row.""" for character in self.characters: character.animation.set_appearance( character.input_symbol, ColorPair(fg=fg_color, bg=bg_color), ) def __init__(self, effect: Overflow) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.pending_rows: list[OverflowIterator.Row] = [] self.active_rows: list[OverflowIterator.Row] = [] self.character_final_color_map: dict[EffectCharacter, Color] = {} self.build() def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(outer_fill_chars=True, inner_fill_chars=True): self.character_final_color_map[character] = final_gradient_mapping.get( character.input_coord, Color("#000000"), ) lower_range, upper_range = self.config.overflow_cycles_range rows = self.terminal.get_characters_grouped(argutils.CharacterGroup.ROW_TOP_TO_BOTTOM) if upper_range > 0: for _ in range(random.randint(lower_range, upper_range)): random.shuffle(rows) for row in rows: copied_characters = [] # copy the character attributes to new characters for character in row: character_copy = self.terminal.add_character(character.input_symbol, character.input_coord) character_copy.animation.existing_color_handling = self.terminal.config.existing_color_handling character_copy.uses_input_preexisting_colors = True character_copy._input_ansi_sequences = character._input_ansi_sequences character_copy.animation.no_color = character.animation.no_color character_copy.animation.use_xterm_colors = character.animation.use_xterm_colors character_copy.animation.input_fg_color = character.animation.input_fg_color character_copy.animation.input_bg_color = character.animation.input_bg_color copied_characters.append(character_copy) self.pending_rows.append(OverflowIterator.Row(copied_characters)) # add rows in correct order to the end of self.pending_rows for row in self.terminal.get_characters_grouped( argutils.CharacterGroup.ROW_TOP_TO_BOTTOM, outer_fill_chars=True, inner_fill_chars=True, ): next_row = OverflowIterator.Row(row) for character in next_row.characters: if self.terminal.config.existing_color_handling == "dynamic": if any((character.animation.input_fg_color, character.animation.input_bg_color)): character.animation.set_appearance( character.animation.current_character_visual.symbol, ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ), ) else: character.animation.set_appearance( character.animation.current_character_visual.symbol, ColorPair(), ) else: character.animation.set_appearance( character.animation.current_character_visual.symbol, ColorPair(fg=self.character_final_color_map[character]), ) self.pending_rows.append(OverflowIterator.Row(row, final=True)) self._delay = 0 self._overflow_gradient = Gradient( *self.config.overflow_gradient_stops, steps=max((self.terminal.canvas.top // max(1, len(self.config.overflow_gradient_stops) - 1)), 1), ) def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_rows: if not self._delay: for _ in range(random.randint(1, self.config.overflow_speed)): if self.pending_rows: for row in self.active_rows: row.move_up() if not row.final: row.set_color( self._overflow_gradient.spectrum[ min( row.characters[0].motion.current_coord.row, len(self._overflow_gradient.spectrum) - 1, ) ], ) next_row = self.pending_rows.pop(0) next_row.setup() next_row.move_up() if not next_row.final: next_row.set_color(self._overflow_gradient.spectrum[0]) for character in next_row.characters: self.terminal.set_character_visibility(character, is_visible=True) self.active_rows.append(next_row) self._delay = random.randint(0, 3) else: self._delay -= 1 self.active_rows = [ row for row in self.active_rows if row.characters[0].motion.current_coord.row <= self.terminal.canvas.top ] self.update() return self.frame raise StopIteration class Overflow(BaseEffect[OverflowConfig]): """Input text overflows and scrolls the terminal in a random order until eventually appearing ordered. Attributes: effect_config (OverflowConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[OverflowConfig]: return OverflowConfig @property def _iterator_cls(self) -> type[OverflowIterator]: return OverflowIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_pour.py000066400000000000000000000334041517776150200276640ustar00rootroot00000000000000"""Pours the characters back and forth from the top, bottom, left, or right. Classes: Pour: Pours the characters back and forth from the top, bottom, left, or right. PourConfig: Configuration for the Pour effect. PourIterator: Iterates over the frames of the Pour effect. Does not normally need to be called directly. """ from __future__ import annotations import random import typing from dataclasses import dataclass from enum import Enum, auto from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, Gradient, easing from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "pour", Pour, PourConfig @dataclass class PourConfig(BaseConfig): """Configuration for the Pour effect. Attributes: pour_direction (str): Direction the text will pour. Valid values are "up", "down", "left", and "right". pour_speed (int): Number of characters poured in per tick. Increase to speed up the effect. " "Valid values are n > 0. movement_speed (float): Movement speed of the characters. Valid values are n > 0. gap (int): Number of frames to wait between each character in the pour effect. Increase to slow down effect " "and create a more defined back and forth motion. Valid values are n >= 0. starting_color (Color): Color of the characters before the gradient starts. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the character gradient. If only one color " "is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Number of gradient steps to use. More steps will create a " "smoother and longer gradient animation. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the " "gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. easing (easing.EasingFunction): Easing function to use for character movement. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="pour", help="Pours the characters into position from the given direction.", description="pour | Pours the characters into position from the given direction.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects pour --pour-direction down --pour-speed 2 " "--movement-speed-range 0.4-0.6 --gap 1 --starting-color ffffff --movement-easing IN_QUAD " "--final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 12 --final-gradient-frames 6 " "--final-gradient-direction vertical" ), ) pour_direction: typing.Literal["up", "down", "left", "right"] = argutils.ArgSpec( name="--pour-direction", default="down", choices=["up", "down", "left", "right"], help="Direction the text will pour.", ) # pyright: ignore[reportAssignmentType] "typing.Literal['up', 'down', 'left', 'right'] : Direction the text will pour." pour_speed: int = argutils.ArgSpec( name="--pour-speed", type=argutils.PositiveInt.type_parser, default=2, metavar=argutils.PositiveInt.METAVAR, help="Number of characters poured in per tick. Increase to speed up the effect.", ) # pyright: ignore[reportAssignmentType] "int : Number of characters poured in per tick. Increase to speed up the effect." movement_speed_range: tuple[float, float] = argutils.ArgSpec( name="--movement-speed-range", type=argutils.PositiveFloatRange.type_parser, default=(0.4, 0.6), metavar=argutils.PositiveFloat.METAVAR, help="Movement speed range of the characters. ", ) # pyright: ignore[reportAssignmentType] "tuple[float, float] : Movement speed range of the characters." gap: int = argutils.ArgSpec( name="--gap", type=argutils.NonNegativeInt.type_parser, default=1, metavar=argutils.NonNegativeInt.METAVAR, help="Number of frames to wait between each character in the pour effect. Increase to slow down effect " "and create a more defined back and forth motion.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames to wait between each character in the pour effect." starting_color: Color = argutils.ArgSpec( name="--starting-color", type=argutils.ColorArg.type_parser, default=Color("#ffffff"), metavar=argutils.ColorArg.METAVAR, help="Color of the characters before the gradient starts.", ) # pyright: ignore[reportAssignmentType] "Color : Color of the characters before the gradient starts." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the character gradient." final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use." final_gradient_frames: int = FinalGradientFramesArg( default=6, ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", default=easing.in_quad, type=argutils.Ease.type_parser, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." class PourIterator(BaseEffectIterator[PourConfig]): """Iterator for the Pour effect.""" class PourDirection(Enum): """Pour direction enumeration.""" UP = auto() DOWN = auto() LEFT = auto() RIGHT = auto() def __init__(self, effect: Pour) -> None: """Initialize the iterator with the provided effect. Args: effect (Pour): The effect to use for the iterator. """ super().__init__(effect) self.pending_groups: list[list[EffectCharacter]] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the initial state of the effect.""" self._pour_direction = { "down": PourIterator.PourDirection.DOWN, "up": PourIterator.PourDirection.UP, "left": PourIterator.PourDirection.LEFT, "right": PourIterator.PourDirection.RIGHT, }.get(self.config.pour_direction, PourIterator.PourDirection.DOWN) final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) sort_map: dict[PourIterator.PourDirection, argutils.CharacterGroup] = { PourIterator.PourDirection.DOWN: argutils.CharacterGroup.ROW_BOTTOM_TO_TOP, PourIterator.PourDirection.UP: argutils.CharacterGroup.ROW_TOP_TO_BOTTOM, PourIterator.PourDirection.LEFT: argutils.CharacterGroup.COLUMN_LEFT_TO_RIGHT, PourIterator.PourDirection.RIGHT: argutils.CharacterGroup.COLUMN_RIGHT_TO_LEFT, } groups = self.terminal.get_characters_grouped(grouping=sort_map[self._pour_direction]) for i, group in enumerate(groups): for character in group: self.terminal.set_character_visibility(character, is_visible=False) if self._pour_direction == PourIterator.PourDirection.DOWN: character.motion.set_coordinate(Coord(character.input_coord.column, self.terminal.canvas.top)) elif self._pour_direction == PourIterator.PourDirection.UP: character.motion.set_coordinate(Coord(character.input_coord.column, self.terminal.canvas.bottom)) elif self._pour_direction == PourIterator.PourDirection.LEFT: character.motion.set_coordinate(Coord(self.terminal.canvas.right, character.input_coord.row)) elif self._pour_direction == PourIterator.PourDirection.RIGHT: character.motion.set_coordinate(Coord(self.terminal.canvas.left, character.input_coord.row)) input_coord_path = character.motion.new_path( speed=random.uniform(*self.config.movement_speed_range), ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) pour_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color fg_gradient = ( Gradient(self.config.starting_color, final_fg_color, steps=10) if final_fg_color else None ) bg_gradient = ( Gradient(self.config.starting_color, final_bg_color, steps=10) if final_bg_color else None ) if fg_gradient or bg_gradient: pour_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: pour_scn.add_frame( character.input_symbol, self.config.final_gradient_frames, colors=ColorPair(), ) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None pour_gradient = Gradient( self.config.starting_color, final_fg_color, steps=self.config.final_gradient_steps, ) pour_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=pour_gradient, ) character.animation.activate_scene(pour_scn) if i % 2 == 0: self.pending_groups.append(group) else: self.pending_groups.append(group[::-1]) self.gap = 0 self.current_group = self.pending_groups.pop(0) def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_groups or self.active_characters or self.current_group: if not self.current_group and self.pending_groups: self.current_group = self.pending_groups.pop(0) if self.current_group: if not self.gap: for _ in range(self.config.pour_speed): if self.current_group: next_character = self.current_group.pop(0) self.terminal.set_character_visibility(next_character, is_visible=True) self.active_characters.add(next_character) self.gap = self.config.gap else: self.gap -= 1 self.update() return self.frame raise StopIteration class Pour(BaseEffect[PourConfig]): """Pours the characters back and forth from the top, bottom, left, or right. Attributes: effect_config (PourConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[PourConfig]: return PourConfig @property def _iterator_cls(self) -> type[PourIterator]: return PourIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_print.py000066400000000000000000000353441517776150200300400ustar00rootroot00000000000000"""Prints the input data one line at at time with a carriage return and line feed. Classes: Print: Prints the input data one line at at time with a carriage return and line feed. PrintConfig: Configuration for the Print effect. PrintIterator: Effect iterator for the Print effect. Does not normally need to be called directly. """ from __future__ import annotations import contextlib from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, easing from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.exceptions import DuplicateEventRegistrationError def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "print", Print, PrintConfig @dataclass class PrintConfig(BaseConfig): """Configuration for the Print effect. Attributes: print_head_return_speed (float): Speed of the print head when performing a carriage return. print_speed (int): Speed of the print head when printing characters. print_head_easing (easing.EasingFunction): Easing function to use for print head movement. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one " "color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps " "will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="print", help="Lines are printed one at a time following a print head. Print head performs line feed, carriage return.", description="print | Lines are printed one at a time following a print head. Print head performs line feed, " "carriage return.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects print --final-gradient-stops 02b8bd " "c1f0e3 00ffa0 --final-gradient-steps 12 --final-gradient-direction diagonal " "--print-head-return-speed 1.5 --print-speed 2 --print-head-easing IN_OUT_QUAD" ), ) print_head_return_speed: float = argutils.ArgSpec( name="--print-head-return-speed", type=argutils.PositiveFloat.type_parser, default=1.5, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the print head when performing a carriage return.", ) # pyright: ignore[reportAssignmentType] "float : Speed of the print head when performing a carriage return." print_speed: int = argutils.ArgSpec( name="--print-speed", type=argutils.PositiveInt.type_parser, default=2, metavar=argutils.PositiveInt.METAVAR, help="Speed of the print head when printing characters.", ) # pyright: ignore[reportAssignmentType] "int : Speed of the print head when printing characters." print_head_easing: easing.EasingFunction = argutils.ArgSpec( name="--print-head-easing", default=easing.in_out_quad, type=argutils.Ease.type_parser, help="Easing function to use for print head movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for print head movement." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#02b8bd"), Color("#c1f0e3"), Color("#00ffa0")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class PrintIterator(BaseEffectIterator[PrintConfig]): """Effect iterator for the Print effect.""" class Row: """Row of characters to print.""" def __init__( self, characters: list[EffectCharacter], character_final_color_map: dict[EffectCharacter, ColorPair], typing_head_color: Color, existing_color_handling: str, ) -> None: """Initialize the row of characters to print. Args: characters (list[EffectCharacter]): List of characters to print. character_final_color_map (dict[EffectCharacter, ColorPair]): Mapping of characters to their final colors. typing_head_color (Color): Color of the typing head. existing_color_handling (str): Existing color handling mode for the terminal. """ self.untyped_chars: list[EffectCharacter] = [] self.typed_chars: list[EffectCharacter] = [] if all(character.input_symbol == " " for character in characters): characters = characters[:1] else: right_extent = max( character.input_coord.column for character in characters if not character.is_fill_character ) characters = [char for char in characters if char.input_coord.column <= right_extent] for character in characters: character.motion.set_coordinate(Coord(character.input_coord.column, 1)) typed_animation = character.animation.new_scene() if existing_color_handling == "dynamic": final_fg_color = character_final_color_map[character].fg_color final_bg_color = character_final_color_map[character].bg_color fg_gradient = Gradient(typing_head_color, final_fg_color, steps=5) if final_fg_color else None bg_gradient = Gradient(typing_head_color, final_bg_color, steps=5) if final_bg_color else None if fg_gradient or bg_gradient: typed_animation.apply_gradient_to_symbols( ("█", "▓", "▒", "░", character.input_symbol), 3, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: typed_animation.apply_gradient_to_symbols( ("█", "▓", "▒", "░"), 3, fg_gradient=Gradient(typing_head_color, typing_head_color, steps=4), ) typed_animation.add_frame(character.input_symbol, 3, colors=ColorPair()) else: final_fg_color = character_final_color_map[character].fg_color assert final_fg_color is not None color_gradient = Gradient(typing_head_color, final_fg_color, steps=5) typed_animation.apply_gradient_to_symbols( ("█", "▓", "▒", "░", character.input_symbol), 3, fg_gradient=color_gradient, ) character.animation.activate_scene(typed_animation) self.untyped_chars.append(character) def move_up(self) -> None: """Move the row up one row.""" for character in self.typed_chars: current_row = character.motion.current_coord.row character.motion.set_coordinate(Coord(character.motion.current_coord.column, current_row + 1)) def type_char(self) -> EffectCharacter | None: """Type the next character in the row.""" if self.untyped_chars: next_char = self.untyped_chars.pop(0) self.typed_chars.append(next_char) return next_char return None def __init__(self, effect: Print) -> None: """Initialize the iterator with the Print effect. Args: effect (Print): Print effect to apply to the input data. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.pending_rows: list[PrintIterator.Row] = [] self.processed_rows: list[PrintIterator.Row] = [] self.typing_head = self.terminal.add_character("█", Coord(1, 1)) self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the initial state of the effect.""" self.final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = self.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 self.terminal.get_characters(outer_fill_chars=True, inner_fill_chars=True): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping.get( character.input_coord, Color("#ffffff"), ), ) input_rows = self.terminal.get_characters_grouped( grouping=argutils.CharacterGroup.ROW_TOP_TO_BOTTOM, outer_fill_chars=True, inner_fill_chars=True, ) for input_row in input_rows: self.pending_rows.append( PrintIterator.Row( input_row, self.character_final_color_map, Color("#ffffff"), self.terminal.config.existing_color_handling, ), ) self._current_row: PrintIterator.Row = self.pending_rows.pop(0) self._typing = True self._last_column = 0 def __next__(self) -> str: """Return the next frame in the animation.""" if self.active_characters or self._typing: if self.typing_head.motion.active_path: pass elif self._current_row.untyped_chars: for _ in range(min(len(self._current_row.untyped_chars), self.config.print_speed)): next_char = self._current_row.type_char() if next_char: self.terminal.set_character_visibility(next_char, is_visible=True) self.active_characters.add(next_char) self._last_column = next_char.input_coord.column else: self.processed_rows.append(self._current_row) if self.pending_rows: for row in self.processed_rows: row.move_up() self._current_row = self.pending_rows.pop(0) if not all( character.is_fill_character for character in self.processed_rows[-1].typed_chars ) and not all(character.is_fill_character for character in self._current_row.untyped_chars): left_extent = min( [ character.input_coord.column for character in self._current_row.untyped_chars if not character.is_fill_character ], ) self._current_row.untyped_chars = [ char for char in self._current_row.untyped_chars if left_extent <= char.input_coord.column <= self.terminal.canvas.text_right ] self.typing_head.motion.set_coordinate(Coord(self._last_column, 1)) self.terminal.set_character_visibility(self.typing_head, is_visible=True) self.typing_head.motion.paths.clear() carriage_return_path = self.typing_head.motion.new_path( speed=self.config.print_head_return_speed, ease=self.config.print_head_easing, path_id="carriage_return_path", ) carriage_return_path.new_waypoint( Coord(self._current_row.untyped_chars[0].input_coord.column, 1), ) self.typing_head.motion.activate_path(carriage_return_path) with contextlib.suppress(DuplicateEventRegistrationError): self.typing_head.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, carriage_return_path, EventHandler.Action.CALLBACK, EventHandler.Callback(self.terminal.set_character_visibility, False), # noqa: FBT003 ) self.active_characters.add(self.typing_head) else: self._typing = False self.update() return self.frame raise StopIteration class Print(BaseEffect[PrintConfig]): """Prints the input data one line at at time with a carriage return and line feed. Attributes: effect_config (PrintConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal """ @property def _config_cls(self) -> type[PrintConfig]: return PrintConfig @property def _iterator_cls(self) -> type[PrintIterator]: return PrintIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_rain.py000066400000000000000000000263261517776150200276350ustar00rootroot00000000000000"""Rain characters from the top of the canvas. Classes: Rain: Rain characters from the top of the canvas. RainConfig: Configuration for the Rain effect. RainIterator: Iterator for the Rain effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, Gradient, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "rain", Rain, RainConfig @dataclass class RainConfig(BaseConfig): """Configuration for the Rain effect. Attributes: rain_colors (tuple[Color, ...]): Tuple of colors for the rain drops. Colors are randomly chosen from the tuple. movement_speed (tuple[float, float]): Falling speed range of the rain drops. Valid values are n > 0. rain_symbols (tuple[str, ...] | str): Tuple of symbols to use for the rain drops. Symbols are randomly chosen " "from the tuple. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is " "provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. movement_easing (easing.EasingFunction): Easing function to use for character movement. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="rain", help="Rain characters from the top of the canvas.", description="rain | Rain characters from the top of the canvas.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects rain --rain-symbols o . , '*' '|' " "--rain-colors 00315C 004C8F 0075DB 3F91D9 78B9F2 9AC8F5 B8D8F8 E3EFFC --movement-speed 0.33-0.57 " "--movement-easing IN_QUART --final-gradient-stops 488bff b2e7de 57eaf7 --final-gradient-steps 12 " "--final-gradient-direction diagonal" ), ) rain_colors: tuple[Color, ...] = argutils.ArgSpec( name="--rain-colors", type=argutils.ColorArg.type_parser, metavar=argutils.ColorArg.METAVAR, nargs="+", action=argutils.TupleAction, default=( Color("#00315C"), Color("#004C8F"), Color("#0075DB"), Color("#3F91D9"), Color("#78B9F2"), Color("#9AC8F5"), Color("#B8D8F8"), Color("#E3EFFC"), ), help="List of colors for the rain drops. Colors are randomly chosen from the list.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the rain drops. Colors are randomly chosen from the tuple." movement_speed: tuple[float, float] = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloatRange.type_parser, default=(0.33, 0.57), metavar=argutils.PositiveFloatRange.METAVAR, help="Falling speed range of the rain drops.", ) # pyright: ignore[reportAssignmentType] "tuple[float, float] : Falling speed range of the rain drops." rain_symbols: tuple[str, ...] = argutils.ArgSpec( name="--rain-symbols", type=argutils.Symbol.type_parser, nargs="+", action=argutils.TupleAction, default=("o", ".", ",", "*", "|"), metavar=argutils.Symbol.METAVAR, help="Space separated list of symbols to use for the rain drops. Symbols are randomly chosen from the list.", ) # pyright: ignore[reportAssignmentType] "tuple[str, ...] : Tuple of symbols to use for the rain drops. Symbols are randomly chosen from the tuple." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#488bff"), Color("#b2e7de"), Color("#57eaf7")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", default=easing.in_quart, type=argutils.Ease.type_parser, metavar=argutils.Ease.METAVAR, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." @classmethod def get_effect_class(cls) -> type[Rain]: """Get the effect class associated with this configuration.""" return Rain class RainIterator(BaseEffectIterator[RainConfig]): """Iterator for the Rain effect.""" def __init__(self, effect: Rain) -> None: """Initialize the iterator with the provided effect. Args: effect (Rain): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.group_by_row: dict[int, list[EffectCharacter | None]] = {} self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the rain effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) for character in self.terminal.get_characters(): raindrop_color = random.choice(self.config.rain_colors) rain_scn = character.animation.new_scene() rain_scn.add_frame(random.choice(self.config.rain_symbols), 1, colors=ColorPair(fg=raindrop_color)) fade_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color fg_gradient = Gradient(raindrop_color, final_fg_color, steps=7) if final_fg_color else None bg_gradient = Gradient(raindrop_color, final_bg_color, steps=7) if final_bg_color else None if fg_gradient or bg_gradient: fade_scn.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: fade_scn.add_frame(character.input_symbol, 3, colors=ColorPair()) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None raindrop_gradient = Gradient(raindrop_color, final_fg_color, steps=7) fade_scn.apply_gradient_to_symbols(character.input_symbol, 3, fg_gradient=raindrop_gradient) character.animation.activate_scene(rain_scn) character.motion.set_coordinate(Coord(character.input_coord.column, self.terminal.canvas.top)) input_path = character.motion.new_path( speed=random.uniform(self.config.movement_speed[0], self.config.movement_speed[1]), ease=self.config.movement_easing, ) input_path.new_waypoint(character.input_coord) character.event_handler.register_event( character.event_handler.Event.PATH_COMPLETE, input_path, character.event_handler.Action.ACTIVATE_SCENE, fade_scn, ) character.motion.activate_path(input_path) self.pending_chars.append(character) for character in sorted(self.pending_chars, key=lambda c: c.input_coord.row): if character.input_coord.row not in self.group_by_row: self.group_by_row[character.input_coord.row] = [] self.group_by_row[character.input_coord.row].append(character) self.pending_chars.clear() def __next__(self) -> str: """Return the next frame in the animation.""" if self.group_by_row or self.active_characters or self.pending_chars: if not self.pending_chars and self.group_by_row: self.pending_chars.extend(self.group_by_row.pop(min(self.group_by_row.keys()))) # type: ignore[arg-type] if self.pending_chars: for _ in range(random.randint(1, 2)): if self.pending_chars: next_character = self.pending_chars.pop(random.randint(0, len(self.pending_chars) - 1)) self.terminal.set_character_visibility(next_character, is_visible=True) self.active_characters.add(next_character) else: break self.update() return self.frame raise StopIteration class Rain(BaseEffect[RainConfig]): """Rain characters from the top of the canvas. Attributes: effect_config (PourConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[RainConfig]: return RainConfig @property def _iterator_cls(self) -> type[RainIterator]: return RainIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_random_sequence.py000066400000000000000000000217561517776150200320560ustar00rootroot00000000000000"""Prints the input data in a random sequence, one character at a time. Classes: RandomSequence: Prints the input data in a random sequence. RandomSequenceConfig: Configuration for the RandomSequence effect. RandomSequenceIterator: Iterator for the RandomSequence effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, EffectCharacter, Gradient from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "randomsequence", RandomSequence, RandomSequenceConfig @dataclass class RandomSequenceConfig(BaseConfig): """Configuration for the RandomSequence effect. Attributes: speed (float): Speed of the animation as a percentage of the total number of characters to reveal in each tick. Valid values are 0 < n <= 1. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="randomsequence", help="Prints the input data in a random sequence.", description="randomsequence | Prints the input data in a random sequence.", epilog=( "Example: terminaltexteffects randomsequence --speed 0.007 --final-gradient-stops 8A008A 00D1FF " "ffffff --final-gradient-steps 12 --final-gradient-frames 8 --final-gradient-direction vertical" ), ) speed: float = argutils.ArgSpec( name="--speed", type=argutils.PositiveFloat.type_parser, default=0.007, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the animation as a percentage of the total number of characters to reveal in each tick.", ) # pyright: ignore[reportAssignmentType] "float : Speed of the animation as a percentage of the total number of characters to reveal in each tick." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. " "If only one color is provided, the characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. " "More steps will create a smoother and longer gradient animation." ) final_gradient_frames: int = FinalGradientFramesArg( default=8, ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class RandomSequenceIterator(BaseEffectIterator[RandomSequenceConfig]): """Iterator for the RandomSequence effect.""" DYNAMIC_NEUTRAL_GRAY = Color("#808080") def __init__(self, effect: RandomSequence) -> None: """Initialize the effect iterator. Args: effect (RandomSequence): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.characters_per_tick = max(int(self.config.speed * len(self.terminal._input_characters)), 1) self.build() def build(self) -> None: """Build the initial state of the effect.""" terminal_background_color = self.terminal.config.terminal_background_color final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) self.terminal.set_character_visibility(character, is_visible=False) gradient_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color if final_fg_color or final_bg_color: fg_gradient = ( Gradient(terminal_background_color, final_fg_color, steps=7) if final_fg_color else None ) bg_gradient = ( Gradient(terminal_background_color, final_bg_color, steps=7) if final_bg_color else None ) gradient_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=fg_gradient, bg_gradient=bg_gradient, ) else: gradient_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=Gradient(terminal_background_color, self.DYNAMIC_NEUTRAL_GRAY, steps=7), ) gradient_scn.add_frame( character.input_symbol, self.config.final_gradient_frames, colors=ColorPair(), ) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None gradient = Gradient(terminal_background_color, final_fg_color, steps=7) gradient_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=gradient, ) character.animation.activate_scene(gradient_scn) self.pending_chars.append(character) random.shuffle(self.pending_chars) def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_chars or self.active_characters: for _ in range(self.characters_per_tick): if self.pending_chars: next_char = self.pending_chars.pop() self.terminal.set_character_visibility(next_char, is_visible=True) self.active_characters.add(next_char) self.update() return self.frame raise StopIteration class RandomSequence(BaseEffect[RandomSequenceConfig]): """Prints the input data in a random sequence, one character at a time. Attributes: effect_config (PourConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[RandomSequenceConfig]: return RandomSequenceConfig @property def _iterator_cls(self) -> type[RandomSequenceIterator]: return RandomSequenceIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_rings.py000066400000000000000000000516151517776150200300250ustar00rootroot00000000000000"""Characters are dispersed and form into spinning rings. Classes: Rings: Characters are dispersed and form into spinning rings. RingsConfig: Configuration for the Rings effect. RingsIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import random import typing from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, easing, geometry from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils if typing.TYPE_CHECKING: from terminaltexteffects.engine import motion def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "rings", Rings, RingsConfig @dataclass class RingsConfig(BaseConfig): """Configurations for the RingsEffect. Attributes: ring_colors (tuple[Color, ...]): Tuple of colors for the rings. ring_gap (float): Distance between rings as a percent of the smallest canvas dimension. " "Valid values are 0 < n <= 1. spin_duration (int): Number of frames for each cycle of the spin phase. Valid values are n >= 0. spin_speed (tuple[float, float]): Range of speeds for the rotation of the rings. The speed is randomly " "selected from this range for each ring. Valid values are n > 0. disperse_duration (int): Number of frames spent in the dispersed state between spinning cycles. " "Valid values are n >= 0. spin_disperse_cycles (int): Number of times the animation will cycle between spinning rings and " "dispersed characters. Valid values are n > 0. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color " "is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Number of gradient steps to use. More steps will create a " "smoother and longer gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="rings", help="Characters are dispersed and form into spinning rings.", description="rings | Characters are dispersed and form into spinning rings.", epilog=( "Example: terminaltexteffects rings --ring-colors ab48ff e7b2b2 fffebd --final-gradient-stops ab48ff " "e7b2b2 fffebd --final-gradient-steps 12 --final-gradient-direction vertical --ring-gap 0.1 " "--spin-duration 200 --spin-speed 0.25-1.0 --disperse-duration 200 --spin-disperse-cycles 3" ), ) ring_colors: tuple[Color, ...] = argutils.ArgSpec( name="--ring-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#ab48ff"), Color("#e7b2b2"), Color("#fffebd")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the rings.", ) # pyright: ignore[reportAssignmentType] "tuple[Color] : Tuple of colors for the rings." ring_gap: float = argutils.ArgSpec( name="--ring-gap", type=argutils.PositiveFloat.type_parser, default=0.1, help="Distance between rings as a percent of the smallest canvas dimension.", ) # pyright: ignore[reportAssignmentType] "float : Distance between rings as a percent of the smallest canvas dimension." spin_duration: int = argutils.ArgSpec( name="--spin-duration", type=argutils.PositiveInt.type_parser, default=200, help="Number of frames for each cycle of the spin phase.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames for each cycle of the spin phase." spin_speed: tuple[float, float] = argutils.ArgSpec( name="--spin-speed", type=argutils.PositiveFloatRange.type_parser, default=(0.25, 1.0), metavar=argutils.PositiveFloatRange.METAVAR, help="Range of speeds for the rotation of the rings. The speed is randomly selected from this " "range for each ring.", ) # pyright: ignore[reportAssignmentType] ( "tuple[float, float] : Range of speeds for the rotation of the rings. The speed is randomly selected " "from this range for each ring." ) disperse_duration: int = argutils.ArgSpec( name="--disperse-duration", type=argutils.PositiveInt.type_parser, default=200, help="Number of frames spent in the dispersed state between spinning cycles.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames spent in the dispersed state between spinning cycles." spin_disperse_cycles: int = argutils.ArgSpec( name="--spin-disperse-cycles", type=argutils.PositiveInt.type_parser, default=3, help="Number of times the animation will cycles between spinning rings and dispersed characters.", ) # pyright: ignore[reportAssignmentType] "int : Number of times the animation will cycles between spinning rings and dispersed characters." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#ab48ff"), Color("#e7b2b2"), Color("#fffebd")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color] : Tuple of colors for the final color gradient. If only one color is provided, the characters " "will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Number of gradient steps to use. More steps will create a smoother and longer " "gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class RingsIterator(BaseEffectIterator[RingsConfig]): """Iterator for the Rings effect.""" class Ring: """A ring of characters that spin around a central point.""" def __init__( self, config: RingsConfig, radius: int, origin: Coord, ring_coords: list[Coord], ring_gap: int, ring_color: Color, character_color_map: dict[EffectCharacter, ColorPair], existing_color_handling: str, ) -> None: """Initialize the ring. Args: config (RingsConfig): Configuration for the effect. radius (int): Radius of the ring. origin (Coord): Center of the ring. ring_coords (list[Coord]): Coordinates of the ring. ring_gap (int): Distance between rings. ring_color (Color): Color of the ring. character_color_map (dict[EffectCharacter, ColorPair]): Mapping of characters to colors. existing_color_handling (str): Existing color handling mode for the terminal. """ self.config = config self._built = False self.radius = radius self.origin: Coord = origin self.counter_clockwise_coords = ring_coords self.clockwise_coords = ring_coords[::-1] self.ring_gap = ring_gap self.ring_color = ring_color self.characters: list[EffectCharacter] = [] self.character_last_ring_path: dict[EffectCharacter, motion.Path] = {} self.rotations = 0 self.rotation_speed = random.uniform(self.config.spin_speed[0], self.config.spin_speed[1]) self.character_color_map = character_color_map self.existing_color_handling = existing_color_handling def add_character(self, character: EffectCharacter, clockwise: int) -> None: """Add a character to the ring.""" # make gradient scene gradient_scn = character.animation.new_scene(scene_id="gradient") if self.existing_color_handling == "dynamic": gradient_scn.add_frame(character.input_symbol, 1, colors=self.character_color_map[character]) else: final_fg_color = self.character_color_map[character].fg_color assert final_fg_color is not None char_gradient = Gradient(final_fg_color, self.ring_color, steps=8) gradient_scn.apply_gradient_to_symbols(character.input_symbol, 3, fg_gradient=char_gradient) # make rotation waypoints ring_paths: list[motion.Path] = [] character_starting_index = len(self.characters) coords = self.clockwise_coords if clockwise else self.counter_clockwise_coords for coord in coords[character_starting_index:] + coords[:character_starting_index]: ring_path = character.motion.new_path(path_id=str(len(ring_paths)), speed=self.rotation_speed) ring_path.new_waypoint(coord, waypoint_id=str(len(ring_path.waypoints))) ring_paths.append(ring_path) self.character_last_ring_path[character] = ring_paths[0] # make disperse scene disperse_scn = character.animation.new_scene(is_looping=False, scene_id="disperse") if self.existing_color_handling == "dynamic": disperse_scn.add_frame(character.input_symbol, 1, colors=self.character_color_map[character]) else: final_fg_color = self.character_color_map[character].fg_color assert final_fg_color is not None disperse_gradient = Gradient(self.ring_color, final_fg_color, steps=8) disperse_scn.apply_gradient_to_symbols(character.input_symbol, 10, fg_gradient=disperse_gradient) character.motion.chain_paths(ring_paths, loop=True) self.characters.append(character) def make_disperse_waypoints(self, character: EffectCharacter, origin: Coord) -> motion.Path: """Make waypoints for the disperse path. Args: character (EffectCharacter): Character to disperse. origin (Coord): Origin of the disperse path. Returns: motion.Path: Disperse path. """ disperse_coords = geometry.find_coords_in_rect(origin, self.ring_gap) character.motion.paths.pop("disperse", None) disperse_path = character.motion.new_path(speed=0.14, loop=True, path_id="disperse") for _ in range(5): disperse_path.new_waypoint(disperse_coords[random.randrange(0, len(disperse_coords))]) return disperse_path def disperse(self) -> None: """Disperse the characters.""" for character in self.characters: if character.motion.active_path is not None: self.character_last_ring_path[character] = character.motion.active_path else: self.character_last_ring_path[character] = character.motion.paths["0"] character.motion.activate_path(self.make_disperse_waypoints(character, character.motion.current_coord)) character.animation.activate_scene("disperse") def spin(self) -> None: """Spin the ring.""" for character in self.characters: condense_path = character.motion.new_path(speed=0.1) condense_path.new_waypoint(self.character_last_ring_path[character].waypoints[0].coord) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, condense_path, EventHandler.Action.ACTIVATE_PATH, self.character_last_ring_path[character], ) character.motion.activate_path(condense_path) character.animation.activate_scene("gradient") def __init__(self, effect: Rings) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.ring_chars: list[EffectCharacter] = [] self.non_ring_chars: list[EffectCharacter] = [] self.rings: dict[int, RingsIterator.Ring] = {} self.ring_gap = int( max(round(min(self.terminal.canvas.top, self.terminal.canvas.right) * self.config.ring_gap), 1), ) self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the initial state of the effect.""" self.ring_gap = int( max(round(min(self.terminal.canvas.top, self.terminal.canvas.right) * self.config.ring_gap), 1), ) final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) start_scn = character.animation.new_scene() start_scn.add_frame( character.input_symbol, 1, colors=self.character_final_color_map[character], ) home_path = character.motion.new_path(speed=0.8, ease=easing.out_quad, path_id="home") home_path.new_waypoint(character.input_coord) character.animation.activate_scene(start_scn) self.terminal.set_character_visibility(character, is_visible=True) self.pending_chars.append(character) random.shuffle(self.pending_chars) # make rings for radius in range(1, max(self.terminal.canvas.right, self.terminal.canvas.top), self.ring_gap): ring_coords = geometry.find_coords_on_circle(self.terminal.canvas.center, radius, 7 * radius, unique=True) # check if any part of the ring is in the canvas, if not, stop creating rings if ( len([coord for coord in ring_coords if self.terminal.canvas.coord_is_in_canvas(coord)]) / len(ring_coords) < 0.25 ): break self.rings[radius] = RingsIterator.Ring( self.config, radius, self.terminal.canvas.center, ring_coords, self.ring_gap, self.config.ring_colors[len(self.rings) % len(self.config.ring_colors)], self.character_final_color_map, self.terminal.config.existing_color_handling, ) # assign characters to rings for ring_count, ring in enumerate(self.rings.values()): for _ in ring.counter_clockwise_coords: if self.pending_chars: next_character = self.pending_chars.pop(0) # set rings to rotate in opposite directions ring.add_character(next_character, clockwise=ring_count % 2) self.ring_chars.append(next_character) # make external waypoints for characters not in rings for character in self.terminal.get_characters(): if character not in self.ring_chars: external_path = character.motion.new_path(path_id="external", speed=0.8, ease=easing.out_sine) external_path.new_waypoint(self.terminal.canvas.random_coord(outside_scope=True)) self.non_ring_chars.append(character) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, external_path, EventHandler.Action.CALLBACK, EventHandler.Callback(self.terminal.set_character_visibility, False), # noqa: FBT003 ) self._rings_list = list(self.rings.values()) self._phase = "start" self._initial_disperse_complete = False self._spin_time_remaining = self.config.spin_duration self._disperse_time_remaining = self.config.disperse_duration self._cycles_remaining = self.config.spin_disperse_cycles self._initial_phase_time_remaining = 100 def __next__(self) -> str: """Return the next frame in the animation.""" if self._phase != "complete": if self._phase == "start": if not self._initial_phase_time_remaining: self._phase = "disperse" else: self._initial_phase_time_remaining -= 1 elif self._phase == "disperse": if not self._initial_disperse_complete: self._initial_disperse_complete = True for ring in self._rings_list: for character in ring.characters: disperse_path = ring.make_disperse_waypoints( character, character.motion.paths["0"].waypoints[0].coord, ) initial_path = character.motion.new_path(speed=0.3, ease=easing.out_cubic) initial_path.new_waypoint(disperse_path.waypoints[0].coord) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, initial_path, EventHandler.Action.ACTIVATE_PATH, disperse_path, ) character.animation.activate_scene("disperse") character.motion.activate_path(initial_path) self.active_characters.add(character) for character in self.non_ring_chars: character.motion.activate_path("external") self.active_characters.add(character) elif not self._disperse_time_remaining: self._phase = "spin" self._cycles_remaining -= 1 self._spin_time_remaining = self.config.spin_duration for ring in self._rings_list: ring.spin() else: self._disperse_time_remaining -= 1 elif self._phase == "spin": if not self._spin_time_remaining: if not self._cycles_remaining: self._phase = "final" for character in self.terminal.get_characters(): self.terminal.set_character_visibility(character, is_visible=True) character.motion.activate_path("home") self.active_characters.add(character) if "external" in character.motion.paths: continue character.animation.activate_scene("disperse") else: self._disperse_time_remaining = self.config.disperse_duration for ring in self._rings_list: ring.disperse() self._phase = "disperse" else: self._spin_time_remaining -= 1 elif self._phase == "final" and not self.active_characters: self._phase = "complete" self.update() return self.frame raise StopIteration class Rings(BaseEffect[RingsConfig]): """Characters are dispersed and form into spinning rings. Attributes: effect_config (RingsConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[RingsConfig]: return RingsConfig @property def _iterator_cls(self) -> type[RingsIterator]: return RingsIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_scattered.py000066400000000000000000000214471517776150200306610ustar00rootroot00000000000000"""Text is scattered across the canvas and moves into position. Classes: Scattered: Move the characters into place from random starting locations. ScatteredConfig: Configuration for the Scattered effect. ScatteredIterator: Effect iterator for the effect. Does not normally need to be called directly. """ from __future__ import annotations from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, Scene, easing from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "scattered", Scattered, ScatteredConfig @dataclass class ScatteredConfig(BaseConfig): """Configuration for the effect. Attributes: movement_speed (float): Movement speed of the characters. Valid values are n > 0. movement_easing (easing.EasingFunction): Easing function to use for character movement. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the character gradient. If only one color is " "provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the " "gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="scattered", help="Text is scattered across the canvas and moves into position.", description="scattered | Text is scattered across the canvas and moves into position.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects scattered --final-gradient-stops ff9048 " "ab9dff bdffea --final-gradient-steps 12 --final-gradient-frames 9 --final-gradient-direction vertical " "--movement-speed 0.5 " "--movement-easing IN_OUT_BACK" ), ) movement_speed: float = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=0.5, metavar=argutils.PositiveFloat.METAVAR, help="Movement speed of the characters. ", ) # pyright: ignore[reportAssignmentType] "float : Movement speed of the characters. " movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", default=easing.in_out_back, type=argutils.Ease.type_parser, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#ff9048"), Color("#ab9dff"), Color("#bdffea")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the character gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will create " "a smoother and longer gradient animation." ) final_gradient_frames: int = FinalGradientFramesArg( default=9, ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." @classmethod def get_effect_class(cls) -> type[Scattered]: """Get the effect class associated with this configuration.""" return Scattered class ScatteredIterator(BaseEffectIterator[ScatteredConfig]): """Effect iterator for the effect.""" def __init__(self, effect: Scattered) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) if self.terminal.canvas.right < 2 or self.terminal.canvas.top < 2: character.motion.set_coordinate(Coord(1, 1)) else: character.motion.set_coordinate(self.terminal.canvas.random_coord()) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, input_coord_path, EventHandler.Action.SET_LAYER, 1, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_coord_path, EventHandler.Action.SET_LAYER, 0, ) character.motion.activate_path(input_coord_path) self.terminal.set_character_visibility(character, is_visible=True) gradient_scn = character.animation.new_scene(sync=Scene.SyncMetric.DISTANCE) if self.terminal.config.existing_color_handling == "dynamic": gradient_scn.add_frame( character.input_symbol, self.config.final_gradient_frames, colors=self.character_final_color_map[character], ) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None char_gradient = Gradient(final_gradient.spectrum[0], final_fg_color, steps=10) gradient_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=char_gradient, ) character.animation.activate_scene(gradient_scn) self.active_characters.add(character) self._initial_hold_frames = 25 def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_chars or self.active_characters: if self._initial_hold_frames: self._initial_hold_frames -= 1 return self.frame self.update() return self.frame raise StopIteration class Scattered(BaseEffect[ScatteredConfig]): """Text is scattered across the canvas and moves into position. Attributes: effect_config (ScatteredConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[ScatteredConfig]: return ScatteredConfig @property def _iterator_cls(self) -> type[ScatteredIterator]: return ScatteredIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_slice.py000066400000000000000000000325571517776150200300060ustar00rootroot00000000000000"""Slices the input in half and slides it into place from opposite directions. Classes: Slice: Slices the input in half and slides it into place from opposite directions. SliceConfig: Configuration for the Slice effect. SliceIterator: Effect iterator for the effect. Does not normally need to be called directly. """ from __future__ import annotations import typing from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, Gradient, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "slice", Slice, SliceConfig @dataclass class SliceConfig(BaseConfig): """Configuration for the Slice effect. Attributes: slice_direction (typing.Literal["vertical", "horizontal", "diagonal"]): Direction of the slice. movement_speed (float): Movement speed of the characters. Valid values are n > 0. movement_easing (easing.EasingFunction): Easing function to use for character movement. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="slice", help="Slices the input in half and slides it into place from opposite directions.", description="slice | Slices the input in half and slides it into place from opposite directions.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects slice --final-gradient-stops 8A008A 00D1FF " "FFFFFF --final-gradient-steps 12 --slice-direction vertical--movement-speed 0.15 " "--movement-easing IN_OUT_EXPO" ), ) slice_direction: typing.Literal["vertical", "horizontal", "diagonal"] = argutils.ArgSpec( name="--slice-direction", default="vertical", choices=["vertical", "horizontal", "diagonal"], help="Direction of the slice.", ) # pyright: ignore[reportAssignmentType] "typing.Literal['vertical', 'horizontal', 'diagonal'] : Direction of the slice." movement_speed: float = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=0.25, metavar=argutils.PositiveFloat.METAVAR, help="Movement speed of the characters. ", ) # pyright: ignore[reportAssignmentType] "float : Movement speed of the characters. Doubled for horizontal slices." movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", type=argutils.Ease.type_parser, default=easing.in_out_expo, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class SliceIterator(BaseEffectIterator[SliceConfig]): """Effect iterator for the Slice effect.""" def __init__(self, effect: Slice) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.pending_groups: list[list[EffectCharacter]] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: # noqa: PLR0915 """Build the effect.""" slice_direction_map = { "vertical": argutils.CharacterGroup.ROW_BOTTOM_TO_TOP, "horizontal": argutils.CharacterGroup.COLUMN_RIGHT_TO_LEFT, "diagonal": argutils.CharacterGroup.DIAGONAL_BOTTOM_LEFT_TO_TOP_RIGHT, } final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) character.animation.set_appearance( character.input_symbol, self.character_final_color_map[character], ) if self.config.slice_direction == "vertical": self.rows = self.terminal.get_characters_grouped(grouping=slice_direction_map[self.config.slice_direction]) for row_index, row in enumerate(self.rows): new_row = [] left_half = [ character for character in row if character.input_coord.column <= self.terminal.canvas.text_center_column ] for character in left_half: character.motion.set_coordinate(Coord(character.input_coord.column, self.terminal.canvas.top + 1)) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) opposite_row = self.rows[-(row_index + 1)] right_half = [c for c in opposite_row if c.input_coord.column > self.terminal.canvas.text_center_column] for character in right_half: character.motion.set_coordinate( Coord(character.input_coord.column, self.terminal.canvas.bottom - 1), ) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) new_row.extend(left_half) new_row.extend(right_half) self.active_characters = self.active_characters.union(new_row) elif self.config.slice_direction == "horizontal": self.config.movement_speed *= 2 self.columns = self.terminal.get_characters_grouped( grouping=slice_direction_map[self.config.slice_direction], outer_fill_chars=True, inner_fill_chars=True, ) trimmed_columns = [] for column in self.columns: new_column = [ character for character in column if ( self.terminal.canvas.text_left <= character.input_coord.column <= self.terminal.canvas.text_right ) and (self.terminal.canvas.text_bottom <= character.input_coord.row <= self.terminal.canvas.text_top) ] if new_column: trimmed_columns.append(new_column) self.columns = trimmed_columns mid_point = self.terminal.canvas.text_center_row for column_index, column in enumerate(self.columns): new_column = [] bottom_half = [character for character in column if character.input_coord.row <= mid_point] for character in bottom_half: character.motion.set_coordinate(Coord(self.terminal.canvas.left - 1, character.input_coord.row)) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) opposite_column = self.columns[-(column_index + 1)] top_half = [c for c in opposite_column if c.input_coord.row > mid_point] for character in top_half: character.motion.set_coordinate(Coord(self.terminal.canvas.right + 1, character.input_coord.row)) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) new_column.extend(bottom_half) new_column.extend(top_half) self.active_characters = self.active_characters.union(new_column) elif self.config.slice_direction == "diagonal": self.diagonals = self.terminal.get_characters_grouped( grouping=slice_direction_map[self.config.slice_direction], ) left = self.diagonals[: len(self.diagonals) // 2] right = self.diagonals[len(self.diagonals) // 2 :] while left or right: new_group = [] if left: left_group = left.pop(0) origin_coord = Coord(left_group[0].input_coord.column, self.terminal.canvas.bottom - 1) for character in left_group: character.motion.set_coordinate(origin_coord) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) new_group.extend(left_group) if right: right_group = right.pop(0) origin_coord = Coord(right_group[-1].input_coord.column, self.terminal.canvas.top + 1) for character in right_group: character.motion.set_coordinate(origin_coord) input_coord_path = character.motion.new_path( speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.motion.activate_path(input_coord_path) new_group.extend(right_group) self.active_characters = self.active_characters.union(new_group) for character in self.active_characters: self.terminal.set_character_visibility(character, is_visible=True) def __next__(self) -> str: """Return the next frame in the animation.""" if self.active_characters: self.update() return self.frame raise StopIteration class Slice(BaseEffect[SliceConfig]): """Slices the input in half and slides it into place from opposite directions. Attributes: effect_config (SliceConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[SliceConfig]: return SliceConfig @property def _iterator_cls(self) -> type[SliceIterator]: return SliceIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_slide.py000066400000000000000000000344411517776150200300010ustar00rootroot00000000000000"""Slide characters into view from outside the terminal. Classes: Slide: Slide characters into view from outside the terminal. SlideConfig: Configuration for the Slide effect. SlideIterator: Effect iterator for the Slide effect. Does not normally need to be called directly. """ from __future__ import annotations import typing from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, EffectCharacter, Gradient, easing, geometry from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "slide", Slide, SlideConfig @dataclass class SlideConfig(BaseConfig): """Configuration for the Slide effect. Attributes: movement_speed (float): Speed of the characters. Valid values are n > 0. grouping (typing.Literal["row", "column", "diagonal"]): Direction to group characters. Valid values are 'row', 'column', 'diagonal'. gap (int): Number of frames to wait before adding the next group of characters. Increasing this value creates a more staggered effect. Valid values are n >= 0. reverse_direction (bool): Reverse the direction of the characters. merge (bool): Merge the character groups originating. movement_easing (easing.EasingFunction): Easing function to use for character movement. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the character gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the gradient animation. final_gradient_direction (Gradient.Direction): Direction of the gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="slide", help="Slide characters into view from outside the terminal.", description=( "slide | Slide characters into view from outside the terminal, grouped by row, column, or diagonal." ), epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects slide --movement-speed 0.8 --grouping row " "--gap 2 [--reverse-direction] [--merge] --movement-easing IN_OUT_QUAD " "--final-gradient-stops 833ab4 fd1d1d fcb045 --final-gradient-steps 12 --final-gradient-frames 6 " "--final-gradient-direction vertical" ), ) movement_speed: float = argutils.ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=0.8, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the characters.", ) # pyright: ignore[reportAssignmentType] "float : Speed of the characters." grouping: typing.Literal["row", "column", "diagonal"] = argutils.ArgSpec( name="--grouping", default="row", choices=["row", "column", "diagonal"], help="Direction to group characters.", ) # pyright: ignore[reportAssignmentType] ( "typing.Literal['row', 'column', 'diagonal'] : Direction to group characters. Valid values are " "Literal['row', 'column', 'diagonal']." ) gap: int = argutils.ArgSpec( name="--gap", type=argutils.NonNegativeInt.type_parser, default=2, metavar=argutils.NonNegativeInt.METAVAR, help="Number of frames to wait before adding the next group of characters. Increasing this value creates a " "more staggered effect.", ) # pyright: ignore[reportAssignmentType] ( "int : Number of frames to wait before adding the next group of characters. Increasing this value creates a " "more staggered effect." ) reverse_direction: bool = argutils.ArgSpec( name="--reverse-direction", default=False, action="store_true", help="Reverse the direction of the characters.", ) # pyright: ignore[reportAssignmentType] "bool : Reverse the direction of the characters." merge: bool = argutils.ArgSpec( name="--merge", default=False, action="store_true", help="Merge the character groups originating from either side of the terminal. (--reverse-direction is " "ignored when merging)", ) # pyright: ignore[reportAssignmentType] "bool : Merge the character groups originating from either side of the terminal." movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", default=easing.in_out_quad, type=argutils.Ease.type_parser, metavar=argutils.Ease.METAVAR, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#833ab4"), Color("#fd1d1d"), Color("#fcb045")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the character gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_frames: int = FinalGradientFramesArg( default=6, ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the gradient." class SlideIterator(BaseEffectIterator[SlideConfig]): """Effect iterator for the Slide effect.""" def __init__(self, effect: Slide) -> None: """Initialize the Slide effect iterator.""" super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.pending_groups: list[list[EffectCharacter]] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: # noqa: PLR0915 """Build the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) groups: list[list[EffectCharacter]] = [] if self.config.grouping == "row": groups = self.terminal.get_characters_grouped(argutils.CharacterGroup.ROW_TOP_TO_BOTTOM) elif self.config.grouping == "column": groups = self.terminal.get_characters_grouped(argutils.CharacterGroup.COLUMN_LEFT_TO_RIGHT) elif self.config.grouping == "diagonal": groups = self.terminal.get_characters_grouped( argutils.CharacterGroup.DIAGONAL_TOP_LEFT_TO_BOTTOM_RIGHT, ) for group in groups: for character in group: input_path = character.motion.new_path( path_id="input_path", speed=self.config.movement_speed, ease=self.config.movement_easing, ) input_path.new_waypoint(character.input_coord) for group_index, group in enumerate(groups): if self.config.grouping == "row": if self.config.merge and group_index % 2 == 0: starting_column = self.terminal.canvas.right + 1 else: groups[group_index] = groups[group_index][::-1] starting_column = self.terminal.canvas.left - 1 if self.config.reverse_direction and not self.config.merge: groups[group_index] = groups[group_index][::-1] starting_column = self.terminal.canvas.right + 1 for character in groups[group_index]: character.motion.set_coordinate(geometry.Coord(starting_column, character.input_coord.row)) elif self.config.grouping == "column": if self.config.merge and group_index % 2 == 0: starting_row = self.terminal.canvas.bottom - 1 else: groups[group_index] = groups[group_index][::-1] starting_row = self.terminal.canvas.top + 1 if self.config.reverse_direction and not self.config.merge: groups[group_index] = groups[group_index][::-1] starting_row = self.terminal.canvas.bottom - 1 for character in groups[group_index]: character.motion.set_coordinate(geometry.Coord(character.input_coord.column, starting_row)) if self.config.grouping == "diagonal": distance_from_outside_bottom = group[-1].input_coord.row - (self.terminal.canvas.bottom - 1) starting_coord = geometry.Coord( group[-1].input_coord.column - distance_from_outside_bottom, group[-1].input_coord.row - distance_from_outside_bottom, ) if self.config.merge and group_index % 2 == 0: groups[group_index] = groups[group_index][::-1] distance_from_outside = (self.terminal.canvas.top + 1) - group[0].input_coord.row starting_coord = geometry.Coord( group[0].input_coord.column + distance_from_outside, group[0].input_coord.row + distance_from_outside, ) if self.config.reverse_direction and not self.config.merge: groups[group_index] = groups[group_index][::-1] distance_from_outside = (self.terminal.canvas.top + 1) - group[0].input_coord.row starting_coord = geometry.Coord( group[0].input_coord.column + distance_from_outside, group[0].input_coord.row + distance_from_outside, ) for character in groups[group_index]: character.motion.set_coordinate(starting_coord) for character in group: gradient_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": gradient_scn.add_frame( character.input_symbol, self.config.final_gradient_frames, colors=self.character_final_color_map[character], ) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None char_gradient = Gradient( self.config.final_gradient_stops[0], final_fg_color, steps=10, ) gradient_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=char_gradient, ) character.animation.activate_scene(gradient_scn) self.pending_groups = groups self._active_groups: list[list[EffectCharacter]] = [] self._current_gap = 0 def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_groups or self.active_characters or self._active_groups: if self._current_gap == self.config.gap and self.pending_groups: self._active_groups.append(self.pending_groups.pop(0)) self._current_gap = 0 elif self.pending_groups: self._current_gap += 1 for group in self._active_groups: if group: next_char = group.pop(0) self.terminal.set_character_visibility(next_char, is_visible=True) next_char.motion.activate_path(next_char.motion.paths["input_path"]) self.active_characters.add(next_char) self._active_groups = [group for group in self._active_groups if group] self.update() return self.frame raise StopIteration class Slide(BaseEffect[SlideConfig]): """Slides characters into view from outside the terminal. Attributes: effect_config (SlideConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[SlideConfig]: return SlideConfig @property def _iterator_cls(self) -> type[SlideIterator]: return SlideIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_smoke.py000066400000000000000000000236461517776150200300240ustar00rootroot00000000000000"""Smoke floods the canvas colorizing any characters it crosses. Classes: Smoke: Smoke effect class. SmokeConfig: Configuration dataclass for the Smoke effect. SmokeIterator: Effect iterator for the Smoke effect. """ from __future__ import annotations from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.spanningtree.algo.breadthfirst import BreadthFirst from terminaltexteffects.utils.spanningtree.algo.primsweighted import PrimsWeighted def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "smoke", Smoke, SmokeConfig @dataclass class SmokeConfig(BaseConfig): """Effect configuration dataclass.""" parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="smoke", help="Smoke floods the canvas colorizing any characters it crosses.", description="Smoke floods the canvas colorizing any characters it crosses.", epilog="Example: terminaltexteffects smoke --starting-color 7A7A7A --smoke-symbols ░ ▒ ▓ ▒ ░ " "--smoke-gradient-stops 242424 ffffff [--use-whole-canvas] --final-gradient-stops 8A008A 00D1FF ffffff " "--final-gradient-steps 12 --final-gradient-direction vertical ", ) starting_color: tte.Color = argutils.ArgSpec( name="--starting-color", type=argutils.ColorArg.type_parser, default=tte.Color("#7A7A7A"), metavar=argutils.ColorArg.METAVAR, help="Color of the text before being colorized by the smoke.", ) # pyright: ignore[reportAssignmentType] "tte.Color : Color of the text before being colorized by the smoke." smoke_symbols: tuple[str, ...] = argutils.ArgSpec( name="--smoke-symbols", type=argutils.Symbol.type_parser, nargs="+", action=argutils.TupleAction, default=("░", "▒", "▓", "▒", "░"), metavar=argutils.Symbol.METAVAR, help=("Symbols to use for the smoke. Strings will be used in sequence to create an animation."), ) # pyright: ignore[reportAssignmentType] ("tuple[str, ...]: Symbols to use for the smoke. Strings will be used in sequence to create an animation.") smoke_gradient_stops: tuple[tte.Color, ...] = argutils.ArgSpec( name="--smoke-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#242424"), tte.Color("#FFFFFF")), metavar=argutils.ColorArg.METAVAR, help=( "Space separated, unquoted, list of colors for the smoke gradient. " "Smoke will transition through this gradient before moving through the final gradient stops. " ), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...]: Space separated, unquoted, list of colors for the smoke gradient. " "Smoke will transition through this gradient before moving through the final gradient stops. " ) use_whole_canvas: bool = argutils.ArgSpec( name="--use-whole-canvas", action="store_true", default=False, help="If True, the entire canvas will be flooded. Otherwise the effect is limited to the text boundary.", ) # pyright: ignore[reportAssignmentType] "bool : If True, the entire canvas will be flooded. Otherwise the effect is limited to the text boundary." final_gradient_stops: tuple[tte.Color, ...] = FinalGradientStopsArg( default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...]: 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." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=(12,), ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int: Space separated, unquoted, list of the number of gradient steps to use. More " "steps will create a smoother and longer gradient animation." ) final_gradient_direction: tte.Gradient.Direction = FinalGradientDirectionArg( default=tte.Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class SmokeIterator(BaseEffectIterator[SmokeConfig]): """Effect iterator for the Smoke effect.""" def __init__(self, effect: Smoke) -> None: """Initialize the effect iterator. Args: effect (NamedEffect): The effect to iterate over. """ super().__init__(effect) self.pending_chars: list[tte.EffectCharacter] = [] self.character_final_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.gen_alg = PrimsWeighted(self.terminal, limit_to_text_boundary=not self.config.use_whole_canvas) self.fill_alg = BreadthFirst( self.terminal, starting_char=self.terminal.get_character_by_input_coord( self.terminal.canvas.random_coord(within_text_boundary=not self.config.use_whole_canvas), ), limit_to_text_boundary=not self.config.use_whole_canvas, ) self.build() def build(self) -> None: """Build the effect.""" final_gradient = tte.Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) blk = tte.Color("#000000") smoke_gradient = tte.Gradient( *self.config.smoke_gradient_stops, *self.config.final_gradient_stops[::-1], steps=(3, 4), ) for character in self.terminal.get_characters(inner_fill_chars=True, outer_fill_chars=True): self.terminal.set_character_visibility(character=character, is_visible=True) if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = tte.ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) base_colors = tte.ColorPair(fg=blk) else: self.character_final_color_map[character] = tte.ColorPair( fg=final_gradient_mapping.get( character.input_coord, blk, ), ) base_colors = tte.ColorPair(fg=self.config.starting_color) paint_chars = (character.input_symbol,) paint_scn = character.animation.new_scene(scene_id="paint") if self.terminal.config.existing_color_handling == "dynamic": paint_scn.add_frame(character.input_symbol, 5, colors=self.character_final_color_map[character]) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None paint_gradient = tte.Gradient( *self.config.final_gradient_stops, final_fg_color, steps=5, ) paint_scn.apply_gradient_to_symbols(paint_chars, duration=5, fg_gradient=paint_gradient) smoke_scn = character.animation.new_scene(scene_id="smoke") if self.terminal.config.existing_color_handling == "dynamic": for smoke_symbol in self.config.smoke_symbols: smoke_scn.add_frame(smoke_symbol, 10, colors=self.character_final_color_map[character]) else: smoke_scn.apply_gradient_to_symbols(self.config.smoke_symbols, 3, fg_gradient=smoke_gradient) character.event_handler.register_event( event=tte.Event.SCENE_COMPLETE, caller=smoke_scn, action=tte.Action.ACTIVATE_SCENE, target=paint_scn, ) character.animation.set_appearance( character.input_symbol, colors=base_colors, ) while not self.gen_alg.complete: self.gen_alg.step() # trigger effects on starting char since it will not be 'explored' if self.fill_alg.starting_char: self.fill_alg.starting_char.animation.activate_scene("smoke") self.active_characters.add(self.fill_alg.starting_char) def __next__(self) -> str: """Return the next frame of the effect.""" if not self.fill_alg.complete or self.active_characters: if not self.fill_alg.complete: self.fill_alg.step() if self.fill_alg.explored_last_step: for char in self.fill_alg.explored_last_step: char.animation.activate_scene("smoke") self.active_characters.add(char) self.update() return self.frame raise StopIteration class Smoke(BaseEffect[SmokeConfig]): """Smoke floods the canvas colorizing any characters it crosses.""" @property def _config_cls(self) -> type[SmokeConfig]: return SmokeConfig @property def _iterator_cls(self) -> type[SmokeIterator]: return SmokeIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_spotlights.py000066400000000000000000000437011517776150200311000ustar00rootroot00000000000000"""Spotlights search the text area, illuminating characters, before converging in the center and expanding. Classes: Spotlights: Spotlights search the text area, illuminating characters, before converging in the center and expanding. SpotlightsConfig: Configuration for the Spotlights effect. SpotlightsIterator: Effect iterator for the Spotlights effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, Gradient, easing, geometry from terminaltexteffects.engine import animation, motion 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "spotlights", Spotlights, SpotlightsConfig @dataclass class SpotlightsConfig(BaseConfig): """Configuration for the Spotlights effect. Attributes: beam_width_ratio (float): Width of the beam of light as min(width, height) // n of the input text. Valid values are n > 0. Values where n < 1 are raised to 1. beam_falloff (float): Distance from the edge of the beam where the brightness begins to fall off, as a percentage of total beam width. Valid values are 0 <= n <= 1. search_duration (int): Duration of the search phase, in frames, before the spotlights converge in the center. Valid values are n > 0. search_speed_range (tuple[float, float]): Range of speeds for the spotlights during the search phase. The speed is a random value between the two provided values. Valid values are n > 0. spotlight_count (int): Number of spotlights to use. Valid values are n > 0. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="spotlights", help="Spotlights search the text area, illuminating characters, before converging in the center and expanding.", description="spotlights | Spotlights search the text area, illuminating characters, before converging in the " "center and expanding.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects spotlights --final-gradient-stops ab48ff " "e7b2b2 fffebd --final-gradient-steps 12 --final-gradient-direction vertical --beam-width-ratio 2.0 " "--beam-falloff 0.3 --search-duration 550 --search-speed-range 0.35-0.75 --spotlight-count 3" ), ) beam_width_ratio: float = argutils.ArgSpec( name="--beam-width-ratio", type=argutils.PositiveFloat.type_parser, default=2.0, metavar=argutils.PositiveFloat.METAVAR, help="Width of the beam of light as min(width, height) // n of the input text. Values less than 1 " "are raised to 1.", ) # pyright: ignore[reportAssignmentType] ( "float : Width of the beam of light as min(width, height) // n of the input text. Values less than 1 " "are raised to 1." ) beam_falloff: float = argutils.ArgSpec( name="--beam-falloff", type=argutils.NonNegativeFloat.type_parser, default=0.3, metavar=argutils.NonNegativeFloat.METAVAR, help="Distance from the edge of the beam where the brightness begins to fall off, as a percentage of " "total beam width.", ) # pyright: ignore[reportAssignmentType] ( "float : Distance from the edge of the beam where the brightness begins to fall off, as a percentage " "of total beam width." ) search_duration: int = argutils.ArgSpec( name="--search-duration", type=argutils.PositiveInt.type_parser, default=550, metavar=argutils.PositiveInt.METAVAR, help="Duration of the search phase, in frames, before the spotlights converge in the center.", ) # pyright: ignore[reportAssignmentType] "int : Duration of the search phase, in frames, before the spotlights converge in the center." search_speed_range: tuple[float, float] = argutils.ArgSpec( name="--search-speed-range", type=argutils.PositiveFloatRange.type_parser, default=(0.35, 0.75), metavar=argutils.PositiveFloatRange.METAVAR, help="Range of speeds for the spotlights during the search phase. The speed is a random value between the " "two provided values.", ) # pyright: ignore[reportAssignmentType] ( "tuple[float, float] : Range of speeds for the spotlights during the search phase. The speed is a random " "value between the two provided values." ) spotlight_count: int = argutils.ArgSpec( name="--spotlight-count", type=argutils.PositiveInt.type_parser, default=3, metavar=argutils.PositiveInt.METAVAR, help="Number of spotlights to use.", ) # pyright: ignore[reportAssignmentType] "int : Number of spotlights to use." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#ab48ff"), Color("#e7b2b2"), Color("#fffebd")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class SpotlightsIterator(BaseEffectIterator[SpotlightsConfig]): """Effect iterator for the Spotlights effect.""" DYNAMIC_NEUTRAL_GRAY = Color("#808080") def __init__(self, effect: Spotlights) -> None: """Initialize the effect iterator. Args: effect (Spotlights): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.illuminated_chars: set[EffectCharacter] = set() self.character_color_map: dict[EffectCharacter, tuple[ColorPair, ColorPair]] = {} self.build() @staticmethod def _adjust_color_pair_brightness(colors: ColorPair, brightness_factor: float) -> ColorPair: return ColorPair( fg=( animation.Animation.adjust_color_brightness(colors.fg_color, brightness_factor) if colors.fg_color else None ), bg=( animation.Animation.adjust_color_brightness(colors.bg_color, brightness_factor) if colors.bg_color else None ), ) @staticmethod def _has_input_colors(character: EffectCharacter) -> bool: return any((character.animation.input_fg_color, character.animation.input_bg_color)) def _is_spotlightable(self, character: EffectCharacter) -> bool: return character.input_symbol != " " or self._has_input_colors(character) def _get_expand_color_override(self, character: EffectCharacter) -> ColorPair | None: if self.terminal.config.existing_color_handling != "dynamic" or not self.expanding: return None if character.animation.input_fg_color is None and character.animation.input_bg_color is not None: return ColorPair(bg=character.animation.input_bg_color) if not self._has_input_colors(character): return ColorPair() return None def make_spotlights(self, num_spotlights: int) -> list[EffectCharacter]: """Create the spotlights. Args: num_spotlights (int): The number of spotlights to create. Returns: list[EffectCharacter]: The spotlights as a list of EffectCharacter instances. """ spotlights: list[EffectCharacter] = [] minimum_distance = self.terminal.canvas.right // 4 for _ in range(num_spotlights): spotlight = self.terminal.add_character("O", self.terminal.canvas.random_coord(outside_scope=True)) spotlights.append(spotlight) spotlight_target_coords: list[Coord] = [] last_coord = self.terminal.canvas.random_coord() spotlight_target_coords.append(last_coord) for _ in range(10): next_coord = self.find_coord_at_minimum_distance(last_coord, minimum_distance) spotlight_target_coords.append(next_coord) last_coord = next_coord paths: list[motion.Path] = [] for coord in spotlight_target_coords: path = spotlight.motion.new_path( speed=random.uniform(self.config.search_speed_range[0], self.config.search_speed_range[1]), ease=easing.in_out_quad, path_id=str(len(paths)), ) path.new_waypoint(coord, bezier_control=self.terminal.canvas.random_coord(outside_scope=True)) paths.append(path) spotlight.motion.chain_paths(paths, loop=True) path = spotlight.motion.new_path(speed=0.5, ease=easing.in_out_sine, path_id="center") path.new_waypoint(self.terminal.canvas.center) return spotlights def find_coord_at_minimum_distance(self, origin_coord: Coord, minimum_distance: int) -> Coord: """Find a coordinate at a minimum distance from the origin. Args: origin_coord (Coord): Origin coordinate. minimum_distance (int): Minimum distance from the origin. Returns: Coord: The coordinate found. """ coord_found = False while not coord_found: coord = self.terminal.canvas.random_coord() distance = geometry.find_length_of_line(origin_coord, coord) if distance >= minimum_distance: coord_found = True return coord # type: ignore[arg-type] def illuminate_chars(self, range_: int) -> None: """Illuminate characters within a range of the spotlights. Args: range_ (int): The range of the spotlights. """ coords_in_range: list[Coord] = [] for spotlight in self.spotlights: coords_in_range.extend(geometry.find_coords_in_circle(spotlight.motion.current_coord, range_)) chars_in_range: set[EffectCharacter] = set() for coord in coords_in_range: character = self.terminal.get_character_by_input_coord(coord) if character and self._is_spotlightable(character): chars_in_range.add(character) chars_no_longer_in_range = self.illuminated_chars - chars_in_range for character in chars_no_longer_in_range: expand_override = self._get_expand_color_override(character) if expand_override is None: character.animation.set_appearance( character.input_symbol, self.character_color_map[character][1], ) else: character.animation.set_appearance(character.input_symbol, expand_override) for character in chars_in_range: distance = min( [ geometry.find_length_of_line( spotlight.motion.current_coord, character.input_coord, double_row_diff=True, ) for spotlight in self.spotlights ], ) if distance > range_ * (1 - self.config.beam_falloff): brightness_factor = max( 1 - (distance - range_ * (1 - self.config.beam_falloff)) / (range_ * self.config.beam_falloff), 0.2, ) adjusted_color = self._adjust_color_pair_brightness( self.character_color_map[character][0], brightness_factor, ) else: adjusted_color = self.character_color_map[character][0] expand_override = self._get_expand_color_override(character) if expand_override is None: character.animation.set_appearance(character.input_symbol, adjusted_color) else: character.animation.set_appearance(character.input_symbol, expand_override) self.illuminated_chars = chars_in_range def build(self) -> None: """Build the initial state of the effect.""" self.spotlights: list[EffectCharacter] = self.make_spotlights(self.config.spotlight_count) final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": if character.animation.input_fg_color or character.animation.input_bg_color: bright_fg = character.animation.input_fg_color if bright_fg is None and character.animation.input_bg_color is not None: bright_fg = self.DYNAMIC_NEUTRAL_GRAY bright_pair = ColorPair( fg=bright_fg, bg=character.animation.input_bg_color, ) dark_pair = ColorPair( fg=( animation.Animation.adjust_color_brightness(bright_fg, 0.2) if bright_fg else None ), bg=( animation.Animation.adjust_color_brightness(character.animation.input_bg_color, 0.2) if character.animation.input_bg_color else None ), ) else: bright_pair = ColorPair(fg=self.DYNAMIC_NEUTRAL_GRAY) dark_pair = ColorPair( fg=animation.Animation.adjust_color_brightness(self.DYNAMIC_NEUTRAL_GRAY, 0.2), ) else: color_bright = final_gradient_mapping[character.input_coord] bright_pair = ColorPair(fg=color_bright) dark_pair = ColorPair(fg=animation.Animation.adjust_color_brightness(color_bright, 0.2)) self.terminal.set_character_visibility(character, is_visible=True) self.character_color_map[character] = (bright_pair, dark_pair) character.animation.set_appearance(character.input_symbol, dark_pair) smallest_dimension = min(self.terminal.canvas.right, self.terminal.canvas.top) self.illuminate_range = max( int( min( smallest_dimension // self.config.beam_width_ratio, smallest_dimension, ), ), 1, ) self.search_duration = self.config.search_duration self.searching = True self.expanding = False self.complete = False for spotlight in self.spotlights: spotlight.motion.activate_path("0") self.active_characters.add(spotlight) def __next__(self) -> str: """Return the next frame in the animation.""" if not self.complete: self.illuminate_chars(self.illuminate_range) if self.searching: self.search_duration -= 1 if not self.search_duration: for spotlight in self.spotlights: spotlight.motion.activate_path("center") self.searching = False if not any(spotlight.motion.active_path for spotlight in self.spotlights): while len(self.spotlights) > 1: self.spotlights.pop() self.expanding = True self.illuminate_range += 1 if self.illuminate_range > max(self.terminal.canvas.right, self.terminal.canvas.top) // 1.5: self.complete = True self.update() return self.frame raise StopIteration class Spotlights(BaseEffect[SpotlightsConfig]): """Spotlights search the text area, illuminating characters, before converging in the center and expanding. Attributes: effect_config (SpotlightsConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[SpotlightsConfig]: return SpotlightsConfig @property def _iterator_cls(self) -> type[SpotlightsIterator]: return SpotlightsIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_spray.py000066400000000000000000000264711517776150200300430ustar00rootroot00000000000000"""Sprays the characters from a single point. Classes: Spray: Sprays the characters from a single point. SprayConfig: Configuration for the Spray effect. SprayIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import random import typing from dataclasses import dataclass from enum import Enum, auto from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "spray", Spray, SprayConfig @dataclass class SprayConfig(BaseConfig): """Configuration for the Spray effect. Attributes: spray_position (typing.Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"]): Position for the spray origin. Valid values are n, ne, e, se, s, sw, w, nw, center. spray_volume (float): Number of characters to spray per tick as a percent of the total number of characters. Valid values are 0 < n <= 1. movement_speed (tuple[float, float]): Movement speed of the characters. Valid values are n > 0. movement_easing (easing.EasingFunction): Easing function to use for character movement. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="spray", help="Draws the characters spawning at varying rates from a single point.", description="spray | Draws the characters spawning at varying rates from a single point.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects spray --final-gradient-stops 8A008A 00D1FF " "ffffff --final-gradient-steps 12 --final-gradient-direction vertical --spray-position e " "--spray-volume 0.005 --movement-speed-range 0.6-1.4 --movement-easing OUT_EXPO" ), ) spray_position: typing.Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"] = argutils.ArgSpec( name="--spray-position", choices=["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"], default="e", help="Position for the spray origin.", ) # pyright: ignore[reportAssignmentType] "typing.Literal['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'center'] : Position for the spray origin." spray_volume: float = argutils.ArgSpec( name="--spray-volume", type=argutils.PositiveRatio.type_parser, default=0.005, metavar=argutils.PositiveRatio.METAVAR, help="Number of characters to spray per tick as a percent of the total number of characters.", ) # pyright: ignore[reportAssignmentType] "float : Number of characters to spray per tick as a percent of the total number of characters." movement_speed_range: tuple[float, float] = argutils.ArgSpec( name="--movement-speed-range", type=argutils.PositiveFloatRange.type_parser, default=(0.6, 1.4), metavar=argutils.PositiveFloatRange.METAVAR, help="Movement speed range of the characters.", ) # pyright: ignore[reportAssignmentType] "tuple[float, float] : Movement speed range of the characters." movement_easing: easing.EasingFunction = argutils.ArgSpec( name="--movement-easing", type=argutils.Ease.type_parser, default=easing.out_expo, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class SprayIterator(BaseEffectIterator[SprayConfig]): """Iterator for the Spray effect.""" class SprayPosition(Enum): """Enum for the spray position.""" N = auto() NE = auto() E = auto() SE = auto() S = auto() SW = auto() W = auto() NW = auto() CENTER = auto() def __init__(self, effect: Spray) -> None: """Initialize the effect iterator. Args: effect (Spray): The effect to iterate over. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the effect.""" self._spray_position = { "n": SprayIterator.SprayPosition.N, "ne": SprayIterator.SprayPosition.NE, "e": SprayIterator.SprayPosition.E, "se": SprayIterator.SprayPosition.SE, "s": SprayIterator.SprayPosition.S, "sw": SprayIterator.SprayPosition.SW, "w": SprayIterator.SprayPosition.W, "nw": SprayIterator.SprayPosition.NW, "center": SprayIterator.SprayPosition.CENTER, }.get(self.config.spray_position, SprayIterator.SprayPosition.E) final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) spray_origin_map = { SprayIterator.SprayPosition.CENTER: (self.terminal.canvas.center), SprayIterator.SprayPosition.N: Coord(self.terminal.canvas.right // 2, self.terminal.canvas.top), SprayIterator.SprayPosition.NW: Coord(self.terminal.canvas.left, self.terminal.canvas.top), SprayIterator.SprayPosition.W: Coord(self.terminal.canvas.left, self.terminal.canvas.top // 2), SprayIterator.SprayPosition.SW: Coord(self.terminal.canvas.left, self.terminal.canvas.bottom), SprayIterator.SprayPosition.S: Coord(self.terminal.canvas.right // 2, self.terminal.canvas.bottom), SprayIterator.SprayPosition.SE: Coord(self.terminal.canvas.right - 1, self.terminal.canvas.bottom), SprayIterator.SprayPosition.E: Coord(self.terminal.canvas.right - 1, self.terminal.canvas.top // 2), SprayIterator.SprayPosition.NE: Coord(self.terminal.canvas.right - 1, self.terminal.canvas.top), } for character in self.terminal.get_characters(): character.motion.set_coordinate(spray_origin_map[self._spray_position]) input_coord_path = character.motion.new_path( speed=random.uniform(self.config.movement_speed_range[0], self.config.movement_speed_range[1]), ease=self.config.movement_easing, ) input_coord_path.new_waypoint(character.input_coord) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, input_coord_path, EventHandler.Action.SET_LAYER, 1, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_coord_path, EventHandler.Action.SET_LAYER, 0, ) droplet_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": for _ in range(7): droplet_scn.add_frame(character.input_symbol, 20, colors=self.character_final_color_map[character]) else: spray_gradient = Gradient( random.choice(final_gradient.spectrum), typing.cast("Color", self.character_final_color_map[character].fg_color), steps=7, ) droplet_scn.apply_gradient_to_symbols(character.input_symbol, 20, fg_gradient=spray_gradient) character.animation.activate_scene(droplet_scn) character.motion.activate_path(input_coord_path) self.pending_chars.append(character) random.shuffle(self.pending_chars) self._volume = max(int(len(self.pending_chars) * self.config.spray_volume), 1) def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_chars or self.active_characters: if self.pending_chars: for _ in range(random.randint(1, self._volume)): if self.pending_chars: next_character = self.pending_chars.pop() self.terminal.set_character_visibility(next_character, is_visible=True) self.active_characters.add(next_character) self.update() return self.frame raise StopIteration class Spray(BaseEffect[SprayConfig]): """Sprays the characters from a single point. Attributes: effect_config (SprayConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[SprayConfig]: return SprayConfig @property def _iterator_cls(self) -> type[SprayIterator]: return SprayIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_swarm.py000066400000000000000000000432111517776150200300250ustar00rootroot00000000000000"""Characters are grouped into swarms and move around the terminal before settling into position. Classes: Swarm: Characters are grouped into swarms and move around the terminal before settling into position. SwarmConfig: Configuration for the Swarm effect. SwarmIterator: Effect iterator for the Swarm effect. Does not normally need to be called directly. """ from __future__ import annotations import random import typing from dataclasses import dataclass from terminaltexteffects import ( Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, Scene, easing, geometry, ) 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "swarm", Swarm, SwarmConfig @dataclass class SwarmConfig(BaseConfig): """Configuration for the Swarm effect. Attributes: base_color (tuple[Color, ...]): Tuple of colors for the swarms. flash_color (Color): Color for the character flash. Characters flash when moving. swarm_size (float): Percent of total characters in each swarm. Valid values are 0 < n <= 1. swarm_coordination (float): Percent of characters in a swarm that move as a group. Valid values are 0 < n <= 1. swarm_area_count_range (tuple[int, int]): Range of the number of areas where characters will swarm. Valid values are n > 0. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="swarm", help="Characters are grouped into swarms and move around the terminal before settling into position.", description="swarm | Characters are grouped into swarms and move around the terminal before settling " "into position.", epilog=( "Example: terminaltexteffects swarm --base-color 31a0d4 --flash-color f2ea79 --final-gradient-stops " "31b900 f0ff65 --final-gradient-steps 12 --final-gradient-direction horizontal --swarm-size 0.1 " "--swarm-coordination 0.8 --swarm-area-count-range 2-4" ), ) base_color: tuple[Color, ...] = argutils.ArgSpec( name="--base-color", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#31a0d4"),), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the swarms", ) # pyright: ignore[reportAssignmentType] """tuple[Color, ...] : Tuple of colors for the swarms""" flash_color: Color = argutils.ArgSpec( name="--flash-color", type=argutils.ColorArg.type_parser, default=Color("#f2ea79"), metavar=argutils.ColorArg.METAVAR, help="Color for the character flash. Characters flash when moving.", ) # pyright: ignore[reportAssignmentType] """Color : Color for the character flash. Characters flash when moving.""" swarm_size: float = argutils.ArgSpec( name="--swarm-size", type=argutils.NonNegativeRatio.type_parser, metavar=argutils.NonNegativeRatio.METAVAR, default=0.1, help="Percent of total characters in each swarm.", ) # pyright: ignore[reportAssignmentType] "float : Percent of total characters in each swarm." swarm_coordination: float = argutils.ArgSpec( name="--swarm-coordination", type=argutils.NonNegativeRatio.type_parser, metavar=argutils.NonNegativeRatio.METAVAR, default=0.80, help="Percent of characters in a swarm that move as a group.", ) # pyright: ignore[reportAssignmentType] "float : Percent of characters in a swarm that move as a group." swarm_area_count_range: tuple[int, int] = argutils.ArgSpec( name="--swarm-area-count-range", type=argutils.PositiveIntRange.type_parser, metavar=argutils.PositiveIntRange.METAVAR, default=(2, 4), help="Range of the number of areas where characters will swarm.", ) # pyright: ignore[reportAssignmentType] "tuple[int, int] : Range of the number of areas where characters will swarm." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#31b900"), Color("#f0ff65")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.HORIZONTAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class SwarmIterator(BaseEffectIterator[SwarmConfig]): """Effect iterator for the Swarm effect.""" DYNAMIC_CLEAR_COLOR = Color("#ffffff") def __init__( self, effect: Swarm, ) -> None: """Initialize the Swarm effect iterator. Args: effect (Swarm): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.swarms: list[list[EffectCharacter]] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def make_swarms(self, swarm_size: int) -> None: """Create swarms of characters. Args: swarm_size (int): The size of each swarm. """ unswarmed_characters = self.terminal.get_characters( sort=argutils.CharacterSort.BOTTOM_TO_TOP_RIGHT_TO_LEFT, ) while unswarmed_characters: new_swarm: list[EffectCharacter] = [] for _ in range(swarm_size): if unswarmed_characters: new_swarm.append(unswarmed_characters.pop()) else: break self.swarms.append(new_swarm) final_swarm = self.swarms.pop() if len(final_swarm) < swarm_size // 2: self.swarms[-1].extend(final_swarm) else: self.swarms.append(final_swarm) def build(self) -> None: # noqa: PLR0915 """Build the initial state of the effect.""" swarm_size: int = max(round(len(self.terminal.get_characters()) * self.config.swarm_size), 1) self.make_swarms(swarm_size) final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) flash_list = [self.config.flash_color for _ in range(10)] for swarm in self.swarms: swarm_gradient = Gradient(random.choice(self.config.base_color), self.config.flash_color, steps=7) swarm_gradient_mirror = list(swarm_gradient) + flash_list + list(swarm_gradient)[::-1] swarm_area_coordinate_map: dict[Coord, list[Coord]] = {} swarm_spawn = self.terminal.canvas.random_coord(outside_scope=True) swarm_areas: list[Coord] = [] swarm_area_count = random.randint( self.config.swarm_area_count_range[0], self.config.swarm_area_count_range[1], ) # create areas where characters will swarm last_focus_coord = swarm_spawn radius = max(min(self.terminal.canvas.right, self.terminal.canvas.top) // 2, 1) while len(swarm_areas) < swarm_area_count: potential_focus_coords = geometry.find_coords_on_circle(last_focus_coord, radius) random.shuffle(potential_focus_coords) for coord in potential_focus_coords: if self.terminal.canvas.coord_is_in_canvas(coord): next_focus_coord = coord break else: next_focus_coord = self.terminal.canvas.random_coord() swarm_areas.append(next_focus_coord) swarm_area_coordinate_map[last_focus_coord] = geometry.find_coords_in_circle( last_focus_coord, max(min(self.terminal.canvas.right, self.terminal.canvas.top) // 6, 1) * 2, ) last_focus_coord = next_focus_coord # assign characters waypoints for swarm areas and inner waypoints within the swarm areas for character in swarm: character.motion.set_coordinate(swarm_spawn) flash_scn = character.animation.new_scene(sync=Scene.SyncMetric.DISTANCE) for step in swarm_gradient_mirror: flash_scn.add_frame(character.input_symbol, 1, colors=ColorPair(fg=step)) for swarm_area_count, swarm_area_coords in enumerate(swarm_area_coordinate_map.values()): swarm_area_name = f"{swarm_area_count}_swarm_area" origin_path = character.motion.new_path(path_id=swarm_area_name, speed=0.4, ease=easing.out_sine) origin_path.new_waypoint(random.choice(swarm_area_coords), waypoint_id=swarm_area_name) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, origin_path, EventHandler.Action.ACTIVATE_SCENE, flash_scn, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, origin_path, EventHandler.Action.SET_LAYER, 1, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, origin_path, EventHandler.Action.DEACTIVATE_SCENE, ) inner_paths = 0 total_inner_paths = 2 while inner_paths < total_inner_paths: next_coord = random.choice(swarm_area_coords) inner_paths += 1 inner_path = character.motion.new_path( path_id=str(len(character.motion.paths)), speed=0.18, ease=easing.in_out_sine, ) inner_path.new_waypoint(next_coord, waypoint_id=str(len(character.motion.paths))) # create landing waypoint and scene input_path = character.motion.new_path(speed=0.45, ease=easing.in_out_quad) input_path.new_waypoint(character.input_coord) input_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": if ( self.character_final_color_map[character].fg_color is None and self.character_final_color_map[character].bg_color is None ): clear_gradient = Gradient(self.config.flash_color, self.DYNAMIC_CLEAR_COLOR, steps=10) for step in clear_gradient: input_scn.add_frame(character.input_symbol, 3, colors=ColorPair(fg=step)) input_scn.add_frame(character.input_symbol, 3, colors=ColorPair()) else: input_scn.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=( Gradient( self.config.flash_color, typing.cast("Color", self.character_final_color_map[character].fg_color), steps=10, ) if self.character_final_color_map[character].fg_color else None ), bg_gradient=( Gradient( self.config.flash_color, typing.cast("Color", self.character_final_color_map[character].bg_color), steps=10, ) if self.character_final_color_map[character].bg_color else None ), ) else: for step in Gradient( self.config.flash_color, typing.cast("Color", self.character_final_color_map[character].fg_color), steps=10, ): input_scn.add_frame(character.input_symbol, 3, colors=ColorPair(fg=step)) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_path, EventHandler.Action.ACTIVATE_SCENE, input_scn, ) character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, input_path, EventHandler.Action.SET_LAYER, 0, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, input_path, EventHandler.Action.ACTIVATE_SCENE, flash_scn, ) character.motion.chain_paths(list(character.motion.paths.values())) self.call_next = True self.active_swarm_area = "0_swarm_area" def __next__(self) -> str: """Return the next frame in the animation.""" if self.swarms or self.active_characters: if self.swarms and self.call_next: self.call_next = False self.current_swarm = self.swarms.pop() self.active_swarm_area = "0_swarm_area" for character in self.current_swarm: character.motion.activate_path("0_swarm_area") self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) if len(self.active_characters) < len(self.current_swarm): # some of the characters have landed self.call_next = True if self.current_swarm: for character in self.current_swarm: if ( character.motion.active_path and character.motion.active_path.path_id != self.active_swarm_area and "swarm_area" in character.motion.active_path.path_id and int(character.motion.active_path.path_id[0]) > int(self.active_swarm_area[0]) ): self.active_swarm_area = character.motion.active_path.path_id for other in self.current_swarm: if other is not character and random.random() < self.config.swarm_coordination: other.motion.activate_path(other.motion.paths[self.active_swarm_area]) break self.update() return self.frame raise StopIteration class Swarm(BaseEffect[SwarmConfig]): """Characters are grouped into swarms and move around the terminal before settling into position. Attributes: effect_config (SwarmConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[SwarmConfig]: return SwarmConfig @property def _iterator_cls(self) -> type[SwarmIterator]: return SwarmIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_sweep.py000066400000000000000000000241501517776150200300200ustar00rootroot00000000000000"""Sweep across the canvas to reveal uncolored text, reverse sweep to color the text. Classes: Sweep: Sweep across the canvas to reveal uncolored text, reverse sweep to color the text. SweepConfig: Configuration for the Sweep effect. SweepIterator: Iterator for the Sweep effect. """ from __future__ import annotations import random from dataclasses import dataclass import terminaltexteffects as tte 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "sweep", Sweep, SweepConfig @dataclass class SweepConfig(BaseConfig): """Sweep effect configuration dataclass.""" parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="sweep", help="Sweep across the canvas to reveal uncolored text, reverse sweep to color the text.", description="sweep | Sweep across the canvas to reveal uncolored text, reverse sweep to color the text.", epilog=( f"{argutils.EASING_EPILOG}Example: terminaltexteffects sweep --sweep-symbols '█' '▓' '▒' '░' " "--first-sweep-direction " "column_right_to_left --second-sweep-direction column_left_to_right --final-gradient-stops 8A008A " "00D1FF ffffff --final-gradient-steps 8 --final-gradient-direction vertical" ), ) sweep_symbols: tuple[str, ...] = argutils.ArgSpec( name="--sweep-symbols", type=argutils.Symbol.type_parser, nargs="+", action=argutils.TupleAction, default=("█", "▓", "▒", "░"), metavar=argutils.Symbol.METAVAR, help="Space separated list of symbols to use for the sweep shimmer.", ) # pyright: ignore[reportAssignmentType] "tuple[str, ...] | str : Tuple of symbols to use for the sweep shimmer." first_sweep_direction: argutils.CharacterGroup = argutils.ArgSpec( name="--first-sweep-direction", default=argutils.CharacterGroup.COLUMN_RIGHT_TO_LEFT, type=argutils.CharacterGroupArg.type_parser, help="Direction of the first sweep, revealing uncolored characters.", ) # pyright: ignore[reportAssignmentType] "CharacterGroup : Direction of the first sweep, revealing uncolored characters." second_sweep_direction: argutils.CharacterGroup = argutils.ArgSpec( name="--second-sweep-direction", default=argutils.CharacterGroup.COLUMN_LEFT_TO_RIGHT, type=argutils.CharacterGroupArg.type_parser, help="Direction of the second sweep, coloring the characters.", ) # pyright: ignore[reportAssignmentType] "CharacterGroup : Direction of the second sweep, coloring the characters." final_gradient_stops: tuple[tte.Color, ...] = FinalGradientStopsArg( default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#ffffff")), help=( "Space separated, unquoted, list of colors for the character gradient (applied from bottom to top). " "If only one color is provided, the characters will be displayed in that color." ), ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...]: Space separated, unquoted, list of colors for the character gradient " "(applied from bottom to top). If only one color is provided, the characters will be displayed in that color." final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=8, ) # pyright: ignore[reportAssignmentType] "tuple[int, ...] | int: Space separated, unquoted, list of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." final_gradient_direction: tte.Gradient.Direction = FinalGradientDirectionArg( default=tte.Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class SweepIterator(BaseEffectIterator[SweepConfig]): """Iterator for the sweep effect.""" def __init__(self, effect: Sweep) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.character_final_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.dynamic_second_sweep_palette: list[tte.Color] = [] self.complete = False self.phase = "first sweep" self.easer: tte.easing.SequenceEaser self.build() def build(self) -> None: """Build the effect.""" final_fg_gradient = tte.Gradient( *self.config.final_gradient_stops, steps=self.config.final_gradient_steps, ) final_gradient_mapping = final_fg_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, ) shades_of_gray = [ tte.Color("#A0A0A0"), tte.Color("#808080"), tte.Color("#404040"), tte.Color("#202020"), tte.Color("#101010"), ] if self.terminal.config.existing_color_handling == "dynamic": for character in self.terminal.get_characters(): if character.animation.input_fg_color is not None: self.dynamic_second_sweep_palette.append(character.animation.input_fg_color) if character.animation.input_bg_color is not None: self.dynamic_second_sweep_palette.append(character.animation.input_bg_color) if not self.dynamic_second_sweep_palette: self.dynamic_second_sweep_palette = list(final_fg_gradient.spectrum) for character in self.terminal.get_characters(inner_fill_chars=True, outer_fill_chars=True): if not character.is_fill_character: if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = tte.ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = tte.ColorPair( fg=final_gradient_mapping[character.input_coord], ) initial_sweep_scn = character.animation.new_scene(scene_id="initial_sweep") for char in self.config.sweep_symbols: initial_sweep_scn.add_frame( char, 5, colors=tte.ColorPair(fg=random.choice(shades_of_gray)), ) initial_sweep_scn.add_frame(character.input_symbol, 1, colors=tte.ColorPair("#808080")) second_sweep_scn = character.animation.new_scene(scene_id="second_sweep") for char in self.config.sweep_symbols: second_sweep_scn.add_frame( char, 5, colors=tte.ColorPair( fg=( random.choice(self.dynamic_second_sweep_palette) if self.terminal.config.existing_color_handling == "dynamic" else random.choice(final_fg_gradient.spectrum) ), ), ) second_sweep_scn.add_frame( character.input_symbol, 1, colors=( self.character_final_color_map[character] if not character.is_fill_character else ( tte.ColorPair() if self.terminal.config.existing_color_handling == "dynamic" else tte.ColorPair(fg="000000") ) ), ) self.groups_first_sweep = self.terminal.get_characters_grouped( self.config.first_sweep_direction, inner_fill_chars=True, outer_fill_chars=True, ) self.easer = tte.easing.SequenceEaser( sequence=self.groups_first_sweep, easing_function=tte.easing.in_out_circ, ) self.groups_second_sweep = self.terminal.get_characters_grouped( self.config.second_sweep_direction, inner_fill_chars=True, outer_fill_chars=True, ) def __next__(self) -> str: """Return the next frame in the effect.""" while self.active_characters or not self.complete: self.easer.step() group: list[tte.EffectCharacter] for group in self.easer.added: for character in group: if self.phase == "first sweep": self.terminal.set_character_visibility(character, is_visible=True) character.animation.activate_scene( "initial_sweep" if self.phase == "first sweep" else "second_sweep", ) self.active_characters.update(group) if self.easer.is_complete() and self.phase == "first sweep": self.easer.sequence = self.groups_second_sweep self.easer.reset() self.phase = "second sweep" elif self.easer.is_complete() and self.phase == "second sweep": self.complete = True self.update() return self.frame raise StopIteration class Sweep(BaseEffect[SweepConfig]): """Sweep across the canvas to reveal uncolored text, reverse sweep to color the text.""" @property def _config_cls(self) -> type[SweepConfig]: return SweepConfig @property def _iterator_cls(self) -> type[SweepIterator]: return SweepIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_synthgrid.py000066400000000000000000000541531517776150200307160ustar00rootroot00000000000000"""Create a grid which fills with characters dissolving into the final text. Classes: SynthGrid: Create a grid which fills with characters dissolving into the final text. SynthGridConfig: Configuration for the SynthGrid effect. SynthGridIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, Terminal, geometry from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "synthgrid", SynthGrid, SynthGridConfig @dataclass class SynthGridConfig(BaseConfig): """Configuration for the SynthGrid effect. Attributes: grid_gradient_stops (tuple[Color, ...]): Tuple of colors for the grid gradient. grid_gradient_steps (tuple[int, ...] | int ): Int or Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. grid_gradient_direction (Gradient.Direction): Direction of the gradient for the grid color. text_gradient_stops (tuple[Color, ...]): Tuple of colors for the text gradient. text_gradient_steps (tuple[int, ...] | int ): Int or Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. text_gradient_direction (Gradient.Direction): Direction of the gradient for the text color. grid_row_symbol (str): Symbol to use for grid row lines. grid_column_symbol (str): Symbol to use for grid column lines. text_generation_symbols (tuple[str, ...] | str): Tuple of characters for the text generation animation. max_active_blocks (float): Maximum percentage of blocks to have active at any given time. For example, if set to 0.1, 10 percent of the blocks will be active at any given time. Valid values are 0 < n <= 1. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="synthgrid", help="Create a grid which fills with characters dissolving into the final text.", description="synthgrid | Create a grid which fills with characters dissolving into the final text.", epilog=( "Example: terminaltexteffects synthgrid --grid-gradient-stops CC00CC ffffff --grid-gradient-steps 12 " "--grid-gradient-direction diagonal --text-gradient-stops 8A008A 00D1FF ffffff --text-gradient-steps 12 " "--text-gradient-direction vertical --grid-row-symbol ─ --grid-column-symbol │ " "--text-generation-symbols ░ ▒ ▓ --max-active-blocks 0.1" ), ) grid_gradient_stops: tuple[Color, ...] = argutils.ArgSpec( name="--grid-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#CC00CC"), Color("#ffffff")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the grid gradient.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the grid gradient." grid_gradient_steps: tuple[int, ...] = argutils.ArgSpec( name="--grid-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", action=argutils.TupleAction, default=12, metavar=argutils.PositiveInt.METAVAR, help="Space separated, unquoted, list of the number of gradient steps to use. More steps will create a " "smoother and longer gradient animation.", ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) grid_gradient_direction: Gradient.Direction = argutils.ArgSpec( name="--grid-gradient-direction", type=argutils.GradientDirection.type_parser, default=Gradient.Direction.DIAGONAL, metavar=argutils.GradientDirection.METAVAR, help="Direction of the gradient for the grid color.", ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the gradient for the grid color." text_gradient_stops: tuple[Color, ...] = argutils.ArgSpec( name="--text-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the text gradient.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the text gradient." text_gradient_steps: tuple[int, ...] = argutils.ArgSpec( name="--text-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", action=argutils.TupleAction, default=12, metavar=argutils.PositiveInt.METAVAR, help="Space separated, unquoted, list of the number of gradient steps to use. More steps will create a " "smoother and longer gradient animation.", ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) text_gradient_direction: Gradient.Direction = argutils.ArgSpec( name="--text-gradient-direction", type=argutils.GradientDirection.type_parser, default=Gradient.Direction.VERTICAL, metavar=argutils.GradientDirection.METAVAR, help="Direction of the gradient for the text color.", ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the gradient for the text color." grid_row_symbol: str = argutils.ArgSpec( name="--grid-row-symbol", type=argutils.Symbol.type_parser, default="─", metavar=argutils.Symbol.METAVAR, help="Symbol to use for grid row lines.", ) # pyright: ignore[reportAssignmentType] "str : Symbol to use for grid row lines." grid_column_symbol: str = argutils.ArgSpec( name="--grid-column-symbol", type=argutils.Symbol.type_parser, default="│", metavar=argutils.Symbol.METAVAR, help="Symbol to use for grid column lines.", ) # pyright: ignore[reportAssignmentType] "str : Symbol to use for grid column lines." text_generation_symbols: tuple[str, ...] = argutils.ArgSpec( name="--text-generation-symbols", type=argutils.Symbol.type_parser, nargs="+", action=argutils.TupleAction, default=("░", "▒", "▓"), metavar=argutils.Symbol.METAVAR, help="Space separated, unquoted, list of characters for the text generation animation.", ) # pyright: ignore[reportAssignmentType] "tuple[str, ...] : Tuple of characters for the text generation animation." max_active_blocks: float = argutils.ArgSpec( name="--max-active-blocks", type=argutils.PositiveRatio.type_parser, default=0.1, metavar=argutils.PositiveRatio.METAVAR, help="Maximum percentage of blocks to have active at any given time. For example, if set to 0.1, 10 percent " "of the blocks will be active at any given time.", ) # pyright: ignore[reportAssignmentType] "float : Maximum percentage of blocks to have active at any given time." class GridLine: """A line in the grid.""" def __init__( self, terminal: Terminal, args: SynthGridConfig, origin: Coord, direction: str, grid_gradient_mapping: dict[geometry.Coord, Color], ) -> None: """Initialize the grid line. Args: terminal (Terminal): Terminal from the effect. args (SynthGridConfig): Configuration for the effect. origin (Coord): Origin coordinate. direction (str): Direction of the line. grid_gradient_mapping (dict[geometry.Coord, Color]): Mapping of coordinates to colors. """ self.terminal = terminal self.args = args self.origin = origin self.direction = direction if self.direction == "horizontal": self.grid_symbol = self.args.grid_row_symbol elif self.direction == "vertical": self.grid_symbol = self.args.grid_column_symbol self.characters: list[EffectCharacter] = [] if direction == "horizontal": for column_index in range(self.terminal.canvas.left, self.terminal.canvas.right + 1): effect_char = self.terminal.add_character(self.grid_symbol, Coord(0, 0)) grid_scn = effect_char.animation.new_scene() grid_scn.add_frame( self.grid_symbol, 1, colors=ColorPair(fg=grid_gradient_mapping[geometry.Coord(column_index, origin.row)]), ) effect_char.animation.activate_scene(grid_scn) effect_char.layer = 2 effect_char.motion.set_coordinate(Coord(column_index, origin.row)) self.characters.append(effect_char) elif direction == "vertical": for row_index in range(self.terminal.canvas.bottom, self.terminal.canvas.top): effect_char = self.terminal.add_character(self.grid_symbol, Coord(0, 0)) grid_scn = effect_char.animation.new_scene() grid_scn.add_frame( self.grid_symbol, 1, colors=ColorPair(fg=grid_gradient_mapping[geometry.Coord(origin.column, row_index)]), ) effect_char.animation.activate_scene(grid_scn) effect_char.layer = 2 effect_char.motion.set_coordinate(Coord(origin.column, row_index)) self.characters.append(effect_char) self.collapsed_characters = list(self.characters) self.extended_characters: list[EffectCharacter] = [] def extend(self) -> None: """Extend the line.""" count = 3 if self.direction == "horizontal" else 1 for _ in range(count): if self.collapsed_characters: next_char = self.collapsed_characters.pop(0) self.terminal.set_character_visibility(next_char, is_visible=True) self.extended_characters.append(next_char) def collapse(self) -> None: """Collapse the line.""" count = 3 if self.direction == "horizontal" else 1 if not self.collapsed_characters: self.extended_characters = self.extended_characters[::-1] for _ in range(count): if self.extended_characters: next_char = self.extended_characters.pop(0) self.terminal.set_character_visibility(next_char, is_visible=False) self.collapsed_characters.append(next_char) def is_extended(self) -> bool: """Check if the line is extended. Returns: bool: True if the line is extended, False otherwise. """ return not self.collapsed_characters def is_collapsed(self) -> bool: """Check if the line is collapsed. Returns: bool: True if the line is collapsed, False otherwise. """ return not self.extended_characters class SynthGridIterator(BaseEffectIterator[SynthGridConfig]): """Iterator for the SynthGrid effect.""" def __init__(self, effect: SynthGrid) -> None: """Initialize the effect iterator. Args: effect (SynthGrid): The effect to use for the iterator. """ super().__init__(effect) self.pending_groups: list[tuple[int, list[EffectCharacter]]] = [] self.grid_lines: list[GridLine] = [] self.group_tracker: dict[int, int] = {} self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def find_even_gap(self, dimension: int) -> int: """Find the closest even gap to 20% of the longest dimension. Args: dimension (int): The longest dimension. Returns: int: The gap that is closest to 20% of the dimension length. """ dimension = dimension - 2 if dimension <= 0: return 0 potential_gaps: list[int] = [i for i in range(dimension, 4, -1) if dimension % i <= 1] if not potential_gaps: return 4 return min(potential_gaps, key=lambda x: abs(x - dimension // 5)) def build(self) -> None: # noqa: PLR0915 """Build the initial state of the effect.""" grid_gradient = Gradient(*self.config.grid_gradient_stops, steps=self.config.grid_gradient_steps) grid_gradient_mapping = grid_gradient.build_coordinate_color_mapping( 1, self.terminal.canvas.top, 1, self.terminal.canvas.right, self.config.grid_gradient_direction, ) text_gradient = Gradient(*self.config.text_gradient_stops, steps=self.config.text_gradient_steps) text_gradient_mapping = text_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.text_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) elif character.input_symbol != " ": self.character_final_color_map[character] = ColorPair( fg=text_gradient_mapping[character.input_coord], ) else: self.character_final_color_map[character] = ColorPair() self.grid_lines.append( GridLine( self.terminal, self.config, Coord(self.terminal.canvas.left, self.terminal.canvas.bottom), "horizontal", grid_gradient_mapping, ), ) self.grid_lines.append( GridLine( self.terminal, self.config, Coord(self.terminal.canvas.left, self.terminal.canvas.top), "horizontal", grid_gradient_mapping, ), ) self.grid_lines.append( GridLine( self.terminal, self.config, Coord(self.terminal.canvas.left, self.terminal.canvas.bottom), "vertical", grid_gradient_mapping, ), ) self.grid_lines.append( GridLine( self.terminal, self.config, Coord(self.terminal.canvas.right, self.terminal.canvas.bottom), "vertical", grid_gradient_mapping, ), ) column_indexes: list[int] = [] row_indexes: list[int] = [] if self.terminal.canvas.top > 2 * self.terminal.canvas.right: row_gap = self.find_even_gap(self.terminal.canvas.top) + 1 column_gap = row_gap * 2 else: column_gap = self.find_even_gap(self.terminal.canvas.right) + 1 row_gap = column_gap // 2 for row_index in range(self.terminal.canvas.bottom + row_gap, self.terminal.canvas.top, max(row_gap, 1)): if self.terminal.canvas.top - row_index < 2: continue row_indexes.append(row_index) self.grid_lines.append( GridLine( self.terminal, self.config, Coord(self.terminal.canvas.left, row_index), "horizontal", grid_gradient_mapping, ), ) for column_index in range( self.terminal.canvas.left + column_gap, self.terminal.canvas.right, max(column_gap, 1), ): if self.terminal.canvas.right - column_index < 2: continue column_indexes.append(column_index) self.grid_lines.append( GridLine( self.terminal, self.config, Coord(column_index, self.terminal.canvas.bottom), "vertical", grid_gradient_mapping, ), ) row_indexes.append(self.terminal.canvas.top + 1) column_indexes.append(self.terminal.canvas.right + 1) prev_row_index = 1 for row_index in row_indexes: prev_column_index = 1 for column_index in column_indexes: coords_in_block: list[Coord] = [] if row_index == self.terminal.canvas.top: # make sure the top row is included row_index += 1 # noqa: PLW2901 for row in range(prev_row_index, row_index): for column in range(prev_column_index, column_index): coords_in_block.append(Coord(column, row)) # noqa: PERF401 characters_in_block: list[EffectCharacter] = [ self.terminal.character_by_input_coord[coord] for coord in coords_in_block if coord in self.terminal.character_by_input_coord ] if characters_in_block: self.pending_groups.append((len(self.pending_groups), characters_in_block)) prev_column_index = column_index prev_row_index = row_index for group_number, group in self.pending_groups: self.group_tracker[group_number] = 0 for character in group: dissolve_scn = character.animation.new_scene() for _ in range(random.randint(15, 30)): dissolve_scn.add_frame( random.choice(self.config.text_generation_symbols), 2, colors=ColorPair(fg=random.choice(text_gradient.spectrum)), ) dissolve_scn.add_frame( character.input_symbol, 1, colors=self.character_final_color_map.get(character, ColorPair()), ) character.animation.activate_scene(dissolve_scn) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, dissolve_scn, EventHandler.Action.CALLBACK, EventHandler.Callback(self.update_group_tracker, group_number), ) random.shuffle(self.pending_groups) self._phase = "grid_expand" self._total_group_count = len(self.pending_groups) if not self._total_group_count: for character in self.terminal.get_characters(): self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) self._active_groups: int = 0 def update_group_tracker(self, _: EffectCharacter, *args) -> None: # noqa: ANN002 """Update the group tracker.""" self.group_tracker[args[0]] -= 1 def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_groups or self.active_characters or self._phase != "complete": if self._phase == "grid_expand": if not all(grid_line.is_extended() for grid_line in self.grid_lines): for grid_line in self.grid_lines: if not grid_line.is_extended(): grid_line.extend() else: self._phase = "add_chars" elif self._phase == "add_chars": if ( self.pending_groups and self._active_groups < self._total_group_count * self.config.max_active_blocks ): group_number, next_group = self.pending_groups.pop(0) for char in next_group: self.terminal.set_character_visibility(char, is_visible=True) self.active_characters.add(char) self.group_tracker[group_number] += 1 if not self.pending_groups and not self.active_characters and not self._active_groups: self._phase = "collapse" elif self._phase == "collapse": if not all(grid_line.is_collapsed() for grid_line in self.grid_lines): for grid_line in self.grid_lines: if not grid_line.is_collapsed(): grid_line.collapse() else: self._phase = "complete" self.update() self._active_groups = 0 for active_count in self.group_tracker.values(): if active_count: self._active_groups += 1 return self.frame raise StopIteration class SynthGrid(BaseEffect[SynthGridConfig]): """Create a grid which fills with characters dissolving into the final text. Attributes: effect_config (SynthGridConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[SynthGridConfig]: return SynthGridConfig @property def _iterator_cls(self) -> type[SynthGridIterator]: return SynthGridIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_thunderstorm.py000066400000000000000000000774171517776150200314510ustar00rootroot00000000000000"""Create a Thunderstorm in the terminal. Classes: - Thunderstorm: Effect class for the Thunderstorm effect. - ThunderstormConfig: Configuration for the Thunderstorm effect. - ThunderstormIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import random import time import typing from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, FinalGradientStepsArg, FinalGradientStopsArg, ) from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils if typing.TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "thunderstorm", Thunderstorm, ThunderstormConfig @dataclass class ThunderstormConfig(BaseConfig): """Effect configuration dataclass.""" parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="thunderstorm", help="Create a thunderstorm in the terminal.", description="thunderstorm | Create a thunderstorm in the terminal.", epilog=( "terminaltexteffects thunderstorm --lightning-color 68A3E8 " "--glowing-text-color EF5411 --text-glow-time 6 " "--raindrop-symbols '\\\\' '.' ',' --spark-symbols '*' '.' \"'\" " "--spark-glow-color ff4d00 --spark-glow-time 18 " "--storm-time 12 --final-gradient-stops 8A008A 00D1FF ffffff " "--final-gradient-steps 12 --final-gradient-frames 3 --final-gradient-direction vertical" ), ) lightning_color: tte.Color = argutils.ArgSpec( name="--lightning-color", type=argutils.ColorArg.type_parser, default=tte.Color("#68A3E8"), metavar=argutils.ColorArg.METAVAR, help="Color for the lightning strike.", ) # pyright: ignore[reportAssignmentType] "Color: Color for the lightning strike." glowing_text_color: tte.Color = argutils.ArgSpec( name="--glowing-text-color", type=argutils.ColorArg.type_parser, default=tte.Color("#EF5411"), metavar=argutils.ColorArg.METAVAR, help="Color for the text when glowing after a lightning strike.", ) # pyright: ignore[reportAssignmentType] "Color: Color for the text when glowing after a lightning strike." text_glow_time: int = argutils.ArgSpec( name="--text-glow-time", type=argutils.PositiveInt.type_parser, default=6, metavar=argutils.PositiveInt.METAVAR, help="Duration, in number of frames, for the glowing/cooling animation for post-lightning text glow.", ) # pyright: ignore[reportAssignmentType] "int: Duration, in number of frames, for the glowing/cooling animation for post-lightning text glow." raindrop_symbols: tuple[str, ...] = argutils.ArgSpec( name="--raindrop-symbols", type=argutils.Symbol.type_parser, default=("\\", ".", ","), nargs="+", action=argutils.TupleAction, metavar=argutils.Symbol.METAVAR, help="Symbols to use for the raindrops.", ) # pyright: ignore[reportAssignmentType] "tuple[str, ...]: Symbols to use for the raindrops." spark_symbols: tuple[str, ...] = argutils.ArgSpec( name="--spark-symbols", type=argutils.Symbol.type_parser, default=("*", ".", "'"), nargs="+", action=argutils.TupleAction, metavar=argutils.Symbol.METAVAR, help="Symbols to use for the lightning impact sparks.", ) # pyright: ignore[reportAssignmentType] "tuple[str, ...]: Symbols to use for the lightning impact sparks." spark_glow_color: tte.Color = argutils.ArgSpec( name="--spark-glow-color", type=argutils.ColorArg.type_parser, default=tte.Color("#ff4d00"), metavar=argutils.ColorArg.METAVAR, help="Color for the spark glow after a lightning strike.", ) # pyright: ignore[reportAssignmentType] "Color: Color for the spark glow after a lightning strike." spark_glow_time: int = argutils.ArgSpec( name="--spark-glow-time", type=argutils.PositiveInt.type_parser, default=18, metavar=argutils.PositiveInt.METAVAR, help="Duration, in number of frames, for the cooling animation for post-lightning sparks.", ) # pyright: ignore[reportAssignmentType] "int: Duration, in number of frames, for the cooling animation for post-lightning sparks." storm_time: int = argutils.ArgSpec( name="--storm-time", type=argutils.PositiveInt.type_parser, default=12, metavar=argutils.PositiveInt.METAVAR, help="Duration, in seconds, the storm will occur.", ) # pyright: ignore[reportAssignmentType] "int: Duration, in seconds, the storm will occur." final_gradient_stops: tuple[tte.Color, ...] = FinalGradientStopsArg( default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...]: 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." ) final_gradient_steps: tuple[int, ...] = FinalGradientStepsArg( default=(12,), ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...]: Space separated, unquoted, list of the number of gradient steps to use. More " "steps will create a smoother and longer gradient animation." ) final_gradient_frames: int = FinalGradientFramesArg( default=3, ) # pyright: ignore[reportAssignmentType] "int: Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: tte.Gradient.Direction = FinalGradientDirectionArg( default=tte.Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class ThunderstormIterator(BaseEffectIterator[ThunderstormConfig]): """Effect iterator for the NamedEffect effect.""" DYNAMIC_NEUTRAL_GRAY = tte.Color("#808080") def __init__(self, effect: Thunderstorm) -> None: """Initialize the effect iterator. Args: effect (NamedEffect): The effect to iterate over. """ super().__init__(effect) self.character_final_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.character_visible_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.character_storm_color_map: dict[tte.EffectCharacter, tte.ColorPair] = {} self.delay = 0 self.strike_progression_delay = 0 self.rain_drops: list[tte.EffectCharacter] = [] self.pending_strike_chars: list[EffectCharacter] = [] self.available_strike_chars: list[EffectCharacter] = [] self.active_strike_chars: list[EffectCharacter] = [] self.pending_sparks: list[EffectCharacter] = [] self.available_sparks: list[EffectCharacter] = [] self.pending_glow_chars: list[EffectCharacter] = [] self.strike_in_progress: bool = False self.flashing: bool = False self.strike_branch_chance = 0.05 self.phase: str = "pre-storm" self.storm_start_time = time.monotonic() self.build() @staticmethod def _adjust_color_pair_brightness(colors: tte.ColorPair, brightness: float) -> tte.ColorPair: return tte.ColorPair( fg=( tte.Animation.adjust_color_brightness(colors.fg_color, brightness) if colors.fg_color is not None else None ), bg=( tte.Animation.adjust_color_brightness(colors.bg_color, brightness) if colors.bg_color is not None else None ), ) @staticmethod def _add_color_pair_gradient_frames( scene: tte.Scene, symbol: str, start_colors: tte.ColorPair, end_colors: tte.ColorPair, *, steps: int, duration: int, ) -> None: fg_steps = ( list( tte.Gradient( typing.cast("tte.Color", start_colors.fg_color), typing.cast("tte.Color", end_colors.fg_color), steps=steps, ), ) if start_colors.fg_color is not None and end_colors.fg_color is not None else [end_colors.fg_color if end_colors.fg_color is not None else start_colors.fg_color] * steps ) bg_steps = ( list( tte.Gradient( typing.cast("tte.Color", start_colors.bg_color), typing.cast("tte.Color", end_colors.bg_color), steps=steps, ), ) if start_colors.bg_color is not None and end_colors.bg_color is not None else [end_colors.bg_color if end_colors.bg_color is not None else start_colors.bg_color] * steps ) for index in range(steps): scene.add_frame( symbol=symbol, colors=tte.ColorPair( fg=fg_steps[index], bg=bg_steps[index], ), duration=duration, ) def hide_character(self, character: tte.EffectCharacter, *_: typing.Any) -> None: """Hide a helper character.""" self.terminal.set_character_visibility(character, is_visible=False) def return_spark_to_pool(self, character: tte.EffectCharacter, *_: typing.Any) -> None: """Return a spark character to the available pool.""" self.available_sparks.append(character) def return_strike_to_pool(self, character: tte.EffectCharacter, *_: typing.Any) -> None: """Return a strike character to the available pool.""" self.available_strike_chars.append(character) def return_raindrop_to_pool(self, character: tte.EffectCharacter, *_: typing.Any) -> None: """Return a raindrop character to the available pool.""" self.rain_drops.append(character) def build(self) -> None: """Build the effect.""" final_gradient = tte.Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) self.build_raindrop_characters() self.build_spark_characters() self.build_strike_characters() # setup scenes on text characters all_chars = self.terminal.get_characters() for text_char in all_chars: if self.terminal.config.existing_color_handling == "dynamic": visible_colors = tte.ColorPair( fg=( text_char.animation.input_fg_color if text_char.animation.input_fg_color is not None else self.DYNAMIC_NEUTRAL_GRAY ), bg=text_char.animation.input_bg_color, ) restore_colors = tte.ColorPair( fg=text_char.animation.input_fg_color, bg=text_char.animation.input_bg_color, ) else: visible_colors = tte.ColorPair( fg=final_gradient_mapping[text_char.input_coord], ) restore_colors = visible_colors storm_colors = self._adjust_color_pair_brightness(visible_colors, brightness=0.5) self.character_visible_color_map[text_char] = visible_colors self.character_storm_color_map[text_char] = storm_colors self.character_final_color_map[text_char] = restore_colors # post-strike glow and cool scene glow_scn = text_char.animation.new_scene(scene_id="glow") glow_fg_gradient = tte.Gradient( self.config.glowing_text_color, typing.cast("tte.Color", storm_colors.fg_color), steps=7, ) for color in glow_fg_gradient: glow_scn.add_frame( symbol=text_char.input_symbol, colors=tte.ColorPair(fg=color, bg=storm_colors.bg_color), duration=6, ) if self.terminal.config.existing_color_handling == "dynamic": glow_scn.add_frame(symbol=text_char.input_symbol, colors=storm_colors, duration=6) # fade before storm scene fade_scn = text_char.animation.new_scene(scene_id="fade") if self.terminal.config.existing_color_handling == "dynamic": self._add_color_pair_gradient_frames( fade_scn, text_char.input_symbol, visible_colors, storm_colors, steps=7, duration=12, ) fade_scn.add_frame(symbol=text_char.input_symbol, colors=storm_colors, duration=12) else: fade_gradient = tte.Gradient( typing.cast("tte.Color", visible_colors.fg_color), typing.cast("tte.Color", storm_colors.fg_color), steps=7, ) for color in fade_gradient: fade_scn.add_frame(symbol=text_char.input_symbol, colors=tte.ColorPair(fg=color), duration=12) unfade_scn = text_char.animation.new_scene(scene_id="unfade") if self.terminal.config.existing_color_handling == "dynamic": self._add_color_pair_gradient_frames( unfade_scn, text_char.input_symbol, storm_colors, visible_colors, steps=7, duration=12, ) unfade_scn.add_frame(symbol=text_char.input_symbol, colors=visible_colors, duration=12) if restore_colors != visible_colors: unfade_scn.add_frame(symbol=text_char.input_symbol, colors=restore_colors, duration=12) else: unfade_gradient = list( tte.Gradient( typing.cast("tte.Color", visible_colors.fg_color), typing.cast("tte.Color", storm_colors.fg_color), steps=7, ), )[::-1] for color in unfade_gradient: unfade_scn.add_frame(symbol=text_char.input_symbol, colors=tte.ColorPair(fg=color), duration=12) # lightning flash scene lightning_flash_color = tte.Animation.adjust_color_brightness( typing.cast("tte.Color", visible_colors.fg_color), brightness=1.7, ) strike_scn = text_char.animation.new_scene(scene_id="flash") flash_gradient = tte.Gradient( typing.cast("tte.Color", storm_colors.fg_color), lightning_flash_color, steps=7, loop=True, ) for color in flash_gradient: strike_scn.add_frame( symbol=text_char.input_symbol, colors=tte.ColorPair(fg=color, bg=storm_colors.bg_color), duration=6, ) self.terminal.set_character_visibility(text_char, is_visible=True) # setup a reference character callback to indicate when the pre-storm fade has completed def fade_complete(*_: typing.Any) -> None: self.phase = "storm" self.storm_start_time = time.monotonic() reference_char = all_chars[0] reference_char.event_handler.register_event( event=tte.Event.SCENE_COMPLETE, caller="fade", action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(fade_complete), ) def set_strike_in_progress_false(self, *_: typing.Any) -> None: """Reset the strike in progress flag.""" self.strike_in_progress = False def make_char_glow(self, strike_char: tte.EffectCharacter) -> None: """Activate the 'cool' scene on any text character behind a strike character. Args: strike_char (tte.EffectCharacter): Strike character. """ input_char = self.terminal.get_character_by_input_coord(strike_char.motion.current_coord) if input_char and input_char.is_visible: input_char.animation.activate_scene("glow") self.pending_glow_chars.append(input_char) def get_next_strike_char(self) -> tte.EffectCharacter: """Get the next available strike character. If no characters are available, new ones will be created. Returns: tte.EffectCharacter: The next available strike character. """ if not self.available_strike_chars: self.build_strike_characters(20) strike_char = self.available_strike_chars.pop() strike_char.animation.scenes.clear() strike_char.event_handler.registered_events.clear() return strike_char def get_next_spark_char(self) -> tte.EffectCharacter: """Get the next available spark character. If no characters are available, new ones will be created. Returns: tte.EffectCharacter: The next available spark character. """ if not self.available_sparks: self.build_spark_characters(20) spark_char = self.available_sparks.pop() spark_char.motion.paths.clear() spark_char.event_handler.registered_events.clear() return spark_char def setup_sparks_for_impact(self) -> None: """Configure sparks for the impact of a lightning strike.""" # setup sparks at lightning strike bottom impact last_strike_char = self.pending_strike_chars[-1] for _ in range(random.randint(6, 10)): spark_char = self.get_next_spark_char() spark_char.motion.set_coordinate(last_strike_char.motion.current_coord) spark_path = spark_char.motion.new_path( speed=random.uniform(0.1, 0.25), ease=tte.easing.out_quint, hold_time=30, ) spark_target = tte.Coord( column=last_strike_char.motion.current_coord.column + random.randint(4, 20) * random.choice((1, -1)), row=self.terminal.canvas.bottom, ) bezier_column = last_strike_char.motion.current_coord.column - ( (last_strike_char.motion.current_coord.column - spark_target.column) // 2 ) spark_path.new_waypoint( coord=spark_target, bezier_control=tte.Coord(column=bezier_column, row=random.randint(1, self.terminal.canvas.top)), ) spark_char.event_handler.register_event( event=tte.Event.PATH_COMPLETE, caller=spark_path, action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(self.hide_character), ) spark_char.event_handler.register_event( event=tte.Event.PATH_COMPLETE, caller=spark_path, action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(self.return_spark_to_pool), ) spark_char.animation.activate_scene("glow") spark_char.motion.activate_path(spark_path) self.pending_sparks.append(spark_char) def setup_lightning_strike(self, branch_neighbor: tte.EffectCharacter | None = None) -> None: """Build a lightning strike effect.""" if branch_neighbor is not None: column, row = branch_neighbor.motion.current_coord row = branch_neighbor.motion.current_coord.row else: column = random.randint(1, self.terminal.canvas.right) row = self.terminal.canvas.top while row >= self.terminal.canvas.bottom: if not self.available_strike_chars: self.build_strike_characters(20) if branch_neighbor is not None: if branch_neighbor.input_symbol == "/": column += 1 symbol = random.choice(("|", "\\")) elif branch_neighbor.input_symbol == "\\": column -= 1 symbol = random.choice(("|", "/")) else: delta = random.choice((-1, 1)) column += delta symbol = "\\" if delta == 1 else "/" else: symbol = random.choice(("\\", "/", "|")) strike_char = self.get_next_strike_char() strike_char.motion.set_coordinate(tte.Coord(column, row)) strike_char.animation.set_appearance(symbol=symbol, colors=tte.ColorPair(fg=self.config.lightning_color)) row -= 1 if symbol == "\\": column += 1 elif symbol == "/": column -= 1 self.pending_strike_chars.append(strike_char) if random.random() < self.strike_branch_chance and branch_neighbor is None: self.strike_branch_chance -= 0.01 self.setup_lightning_strike(branch_neighbor=strike_char) branch_neighbor = None self.strike_branch_chance = 0.05 self.setup_sparks_for_impact() def build_raindrop_characters(self, count: int = 50) -> None: """Build raindrop characters.""" for _ in range(count): spawn_column = random.randint(1 - self.terminal.canvas.top, self.terminal.canvas.right) rain_char = self.terminal.add_character( symbol=random.choice(self.config.raindrop_symbols), coord=tte.Coord(column=spawn_column - 1, row=self.terminal.canvas.top + 1), ) rain_char.layer = 1 rain_char.animation.set_appearance( symbol=rain_char.input_symbol, colors=tte.ColorPair(fg=tte.Color("#aaaaff")), ) fall_path = rain_char.motion.new_path(path_id="fall", speed=1) fall_path.new_waypoint( tte.Coord(column=spawn_column + self.terminal.canvas.top, row=self.terminal.canvas.bottom - 1), ) rain_char.motion.activate_path(fall_path) rain_char.event_handler.register_event( tte.Event.PATH_COMPLETE, fall_path, tte.Action.CALLBACK, rain_char.event_handler.Callback(self.return_raindrop_to_pool), ) self.terminal.set_character_visibility(rain_char, is_visible=True) self.rain_drops.append(rain_char) def build_spark_characters(self, count: int = 100) -> None: """Build spark characters for the lightning strike effect.""" spark_gradient = tte.Gradient( self.config.spark_glow_color, self.terminal.config.terminal_background_color, steps=7, ) for _ in range(count): spark = self.terminal.add_character( symbol=random.choice(self.config.spark_symbols), coord=tte.Coord(1, 1), ) spark.layer = 2 spark_scn = spark.animation.new_scene(scene_id="glow", ease=tte.easing.in_circ) for color in spark_gradient: spark_scn.add_frame( symbol=spark.input_symbol, colors=tte.ColorPair(fg=color), duration=self.config.spark_glow_time, ) self.available_sparks.append(spark) def build_strike_characters(self, count: int = 200) -> None: """Build strike characters for the lightning strike effect.""" for _ in range(count): strike_char = self.terminal.add_character( symbol="|", coord=tte.Coord(1, 1), ) self.available_strike_chars.append(strike_char) def lightning_strike(self) -> None: """Trigger a lightning strike effect.""" self.setup_lightning_strike() strike_base_color = self.config.lightning_color strike_flash_color = tte.Animation.adjust_color_brightness(strike_base_color, 1.7) strike_gradient = tte.Gradient( strike_base_color, strike_flash_color, steps=7, loop=True, ) fade_gradient = tte.Gradient(strike_base_color, self.terminal.config.terminal_background_color, steps=6) layer = 1 flash_ease = tte.easing.make_easing(0, 1.6, 1, random.uniform(-0.6, 0.4)) for strike_char in self.pending_strike_chars: flash_scn = strike_char.animation.new_scene(scene_id="flash", ease=flash_ease) for color in strike_gradient: flash_scn.add_frame( symbol=strike_char.animation.current_character_visual.symbol, colors=tte.ColorPair(fg=color), duration=6, ) fade_scn = strike_char.animation.new_scene(scene_id="fade") for color in fade_gradient: fade_scn.add_frame( symbol=strike_char.animation.current_character_visual.symbol, colors=tte.ColorPair(fg=color), duration=2, ) strike_char.layer = layer strike_char.event_handler.register_event( event=tte.Event.SCENE_COMPLETE, caller=flash_scn, action=tte.Action.ACTIVATE_SCENE, target=fade_scn, ) strike_char.event_handler.register_event( event=tte.Event.SCENE_COMPLETE, caller=fade_scn, action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(self.hide_character), ) strike_char.event_handler.register_event( event=tte.Event.SCENE_COMPLETE, caller=fade_scn, action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(self.make_char_glow), ) strike_char.event_handler.register_event( event=tte.Event.SCENE_COMPLETE, caller=fade_scn, action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(self.return_strike_to_pool), ) for text_char in self.terminal.get_characters(): flash_scene = text_char.animation.query_scene("flash") flash_scene.ease = flash_ease # pyright: ignore[reportOptionalMemberAccess] def step_lightning_strike(self) -> None: """Progress the lightning strike effect.""" if self.strike_progression_delay: self.strike_progression_delay -= 1 return if self.pending_strike_chars: for _ in range(random.randint(1, 3)): if not self.pending_strike_chars: break next_strike_char = self.pending_strike_chars.pop(0) self.active_strike_chars.append(next_strike_char) self.terminal.set_character_visibility(next_strike_char, is_visible=True) self.strike_progression_delay = 1 # if the last strike_char was activated, activate the sparks # and setup the post-fade callback to indicate the strike has # ended if not self.pending_strike_chars: while self.pending_sparks: spark = self.pending_sparks.pop() self.terminal.set_character_visibility(spark, is_visible=True) self.active_characters.add(spark) next_strike_char.event_handler.register_event( event=tte.Event.SCENE_COMPLETE, caller="fade", action=tte.Action.CALLBACK, target=tte.EventHandler.Callback(self.set_strike_in_progress_false), ) # activate the flash scene on all strike chars and text for strike_char in self.active_strike_chars: strike_char.animation.activate_scene("flash") self.active_characters.add(strike_char) self.active_strike_chars.clear() for text_char in self.terminal.get_characters(): text_char.animation.activate_scene("flash") self.active_characters.add(text_char) def rain(self) -> None: """Handle the rain effect.""" if self.rain_drops: if not self.delay: for _ in range(random.randint(1, 6)): if not self.rain_drops: self.build_raindrop_characters(20) drop = self.rain_drops.pop(random.randint(0, len(self.rain_drops) - 1)) drop.motion.set_coordinate(drop.input_coord) fall_path = drop.motion.query_path("fall") fall_path.speed = random.uniform(0.5, 1.5) drop.motion.activate_path(fall_path) self.active_characters.add(drop) self.delay = random.randint(1, 7) else: self.delay -= 1 def pre_storm_text_fade(self) -> None: """Activate the fade effect for all text characters before the storm.""" for char in self.terminal.get_characters(): char.animation.activate_scene("fade") self.active_characters.add(char) def post_storm_text_fade_in(self) -> None: """Active the fade in scene for all text characters after the storm clears.""" for char in self.terminal.get_characters(): char.animation.activate_scene("unfade") self.active_characters.add(char) def __next__(self) -> str: """Return the next frame of the effect.""" if self.active_characters or self.phase != "complete": if self.phase == "pre-storm": self.pre_storm_text_fade() self.phase = "waiting" elif self.phase == "storm": self.rain() if not self.strike_in_progress and random.random() < 0.008: self.strike_in_progress = True self.lightning_strike() if self.strike_in_progress: self.step_lightning_strike() for char in self.pending_glow_chars: self.active_characters.add(char) self.pending_glow_chars.clear() if (time.monotonic() - self.storm_start_time) >= self.config.storm_time and not self.strike_in_progress: self.post_storm_text_fade_in() self.phase = "complete" self.update() return self.frame raise StopIteration class Thunderstorm(BaseEffect[ThunderstormConfig]): """Create a thunderstorm in the terminal. Rain falls across the canvas. Lightning strikes illuminate the scene and cause sparks at the point of impact. Characters struck by lightning glow. """ @property def _config_cls(self) -> type[ThunderstormConfig]: return ThunderstormConfig @property def _iterator_cls(self) -> type[ThunderstormIterator]: return ThunderstormIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_unstable.py000066400000000000000000000423731517776150200305210ustar00rootroot00000000000000"""Spawns characters jumbled, explodes them to the edge of the canvas, then reassembles them. Classes: Unstable: Spawns characters jumbled, explodes them to the edge of the canvas, then reassembles them. UnstableConfig: Configuration for the Unstable effect. UnstableIterator: Effect iterator for the Unstable effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, Gradient, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "unstable", Unstable, UnstableConfig @dataclass class UnstableConfig(BaseConfig): """Configuration for the Unstable effect. Attributes: unstable_color (Color): Color transitioned to as the characters become unstable. explosion_ease (easing.EasingFunction): Easing function to use for character movement during the explosion. explosion_speed (float): Speed of characters during explosion. Valid values are n > 0. reassembly_ease (easing.EasingFunction): Easing function to use for character reassembly. reassembly_speed (float): Speed of characters during reassembly. Valid values are n > 0. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="unstable", help="Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them in the " "correct layout.", description="unstable | Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them " "in the correct layout.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects unstable --unstable-color ff9200 " "--explosion-ease OUT_EXPO --explosion-speed 1 --reassembly-ease OUT_EXPO --reassembly-speed 1 " "--final-gradient-stops 8A008A 00D1FF ffffff --final-gradient-steps 12 " "--final-gradient-direction vertical" ), ) unstable_color: Color = argutils.ArgSpec( name="--unstable-color", type=argutils.ColorArg.type_parser, default=Color("#ff9200"), metavar=argutils.ColorArg.METAVAR, help="Color transitioned to as the characters become unstable.", ) # pyright: ignore[reportAssignmentType] "Color : Color transitioned to as the characters become unstable." explosion_ease: easing.EasingFunction = argutils.ArgSpec( name="--explosion-ease", type=argutils.Ease.type_parser, default=easing.out_expo, help="Easing function to use for character movement during the explosion.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character movement during the explosion." explosion_speed: float = argutils.ArgSpec( name="--explosion-speed", type=argutils.PositiveFloat.type_parser, default=1, metavar=argutils.PositiveFloat.METAVAR, help="Speed of characters during explosion. ", ) # pyright: ignore[reportAssignmentType] "float : Speed of characters during explosion. " reassembly_ease: easing.EasingFunction = argutils.ArgSpec( name="--reassembly-ease", type=argutils.Ease.type_parser, default=easing.out_expo, help="Easing function to use for character reassembly.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for character reassembly." reassembly_speed: float = argutils.ArgSpec( name="--reassembly-speed", type=argutils.PositiveFloat.type_parser, default=1, metavar=argutils.PositiveFloat.METAVAR, help="Speed of characters during reassembly. ", ) # pyright: ignore[reportAssignmentType] "float : Speed of characters during reassembly." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class UnstableIterator(BaseEffectIterator[UnstableConfig]): """Effect iterator for the Unstable effect.""" DYNAMIC_NEUTRAL_GRAY = Color("#808080") def __init__(self, effect: Unstable) -> None: """Initialize the effect iterator.""" super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.jumbled_coords: dict[EffectCharacter, Coord] = {} self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.character_start_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: # noqa: PLR0915 """Build the initial effect state.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": start_fg_color = character.animation.input_fg_color or self.DYNAMIC_NEUTRAL_GRAY start_colors = ColorPair( fg=start_fg_color, bg=character.animation.input_bg_color, ) final_colors = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: start_colors = ColorPair(fg=final_gradient_mapping[character.input_coord]) final_colors = start_colors self.character_start_color_map[character] = start_colors self.character_final_color_map[character] = final_colors character_coords = [character.input_coord for character in self.terminal.get_characters()] for character in self.terminal.get_characters(): pos = random.randint(0, 3) if pos == 0: col = self.terminal.canvas.left row = self.terminal.canvas.random_row() elif pos == 1: col = self.terminal.canvas.right row = self.terminal.canvas.random_row() elif pos == 2: col = self.terminal.canvas.random_column() row = self.terminal.canvas.bottom else: col = self.terminal.canvas.random_column() row = self.terminal.canvas.top jumbled_coord = character_coords.pop(random.randint(0, len(character_coords) - 1)) self.jumbled_coords[character] = jumbled_coord character.motion.set_coordinate(jumbled_coord) explosion_path = character.motion.new_path( path_id="explosion", speed=self.config.explosion_speed, ease=self.config.explosion_ease, ) explosion_path.new_waypoint(Coord(col, row)) reassembly_path = character.motion.new_path( path_id="reassembly", speed=self.config.reassembly_speed, ease=self.config.reassembly_ease, ) reassembly_path.new_waypoint(character.input_coord) rumble_scn = character.animation.new_scene(scene_id="rumble") if self.terminal.config.existing_color_handling == "dynamic": start_fg_color = self.character_start_color_map[character].fg_color or self.DYNAMIC_NEUTRAL_GRAY start_bg_color = self.character_start_color_map[character].bg_color rumble_scn.apply_gradient_to_symbols( character.input_symbol, 10, fg_gradient=Gradient( start_fg_color, self.config.unstable_color, steps=12, ), bg_gradient=( Gradient( start_bg_color, self.config.unstable_color, steps=12, ) if start_bg_color is not None else None ), ) else: final_fg_color = self.character_final_color_map[character].fg_color or self.DYNAMIC_NEUTRAL_GRAY unstable_gradient = Gradient( final_fg_color, self.config.unstable_color, steps=12, ) rumble_scn.apply_gradient_to_symbols(character.input_symbol, 10, fg_gradient=unstable_gradient) final_scn = character.animation.new_scene(scene_id="final") if self.terminal.config.existing_color_handling == "dynamic": final_fg_color = self.character_final_color_map[character].fg_color final_bg_color = self.character_final_color_map[character].bg_color if ( final_fg_color is None and final_bg_color is None ): final_scn.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=Gradient( self.config.unstable_color, self.DYNAMIC_NEUTRAL_GRAY, steps=12, ), ) final_scn.add_frame(character.input_symbol, 3, colors=ColorPair()) else: final_scn.apply_gradient_to_symbols( character.input_symbol, 3, fg_gradient=( Gradient( self.config.unstable_color, final_fg_color, steps=12, ) if final_fg_color is not None else None ), bg_gradient=( Gradient( self.config.unstable_color, final_bg_color, steps=12, ) if final_bg_color is not None else None ), ) if final_fg_color is None: final_scn.add_frame( character.input_symbol, 3, colors=ColorPair(bg=final_bg_color), ) else: final_fg_color = self.character_final_color_map[character].fg_color or self.DYNAMIC_NEUTRAL_GRAY final_color = Gradient( self.config.unstable_color, final_fg_color, steps=12, ) final_scn.apply_gradient_to_symbols(character.input_symbol, 3, fg_gradient=final_color) character.animation.activate_scene(rumble_scn) if self.terminal.config.existing_color_handling == "dynamic": character.animation.set_appearance(character.input_symbol, self.character_start_color_map[character]) self.terminal.set_character_visibility(character, is_visible=True) self._explosion_hold_time = 30 self.phase = "rumble" self._max_rumble_steps = 150 self._current_rumble_steps = 0 self._rumble_mod_delay = 18 def __next__(self) -> str: """Return the next from in the effect.""" next_frame = None if self.phase == "rumble": if self._current_rumble_steps < self._max_rumble_steps: if self._current_rumble_steps > 30 and self._current_rumble_steps % self._rumble_mod_delay == 0: row_offset = random.choice([-1, 0, 1]) column_offset = random.choice([-1, 0, 1]) for character in self.terminal.get_characters(): character.motion.set_coordinate( Coord( character.motion.current_coord.column + column_offset, character.motion.current_coord.row + row_offset, ), ) character.animation.step_animation() next_frame = self.frame for character in self.terminal.get_characters(): character.motion.set_coordinate(self.jumbled_coords[character]) self._rumble_mod_delay -= 1 self._rumble_mod_delay = max(self._rumble_mod_delay, 1) else: for character in self.terminal.get_characters(): character.animation.step_animation() next_frame = self.frame self._current_rumble_steps += 1 else: self.phase = "explosion" for character in self.terminal.get_characters(): character.motion.activate_path(character.motion.query_path("explosion")) self.active_characters = set(self.terminal.get_characters()) if self.phase == "explosion": if self.active_characters: for character in self.active_characters: character.tick() self.active_characters = { character for character in self.active_characters if character.motion.current_coord != character.motion.query_path("explosion").waypoints[0].coord } next_frame = self.frame elif self._explosion_hold_time: for character in self.active_characters: character.tick() self._explosion_hold_time -= 1 next_frame = self.frame else: self.phase = "reassembly" for character in self.terminal.get_characters(): character.animation.activate_scene(character.animation.query_scene("final")) self.active_characters.add(character) character.motion.activate_path(character.motion.query_path("reassembly")) if self.phase == "reassembly" and self.active_characters: for character in self.active_characters: character.tick() self.active_characters = { character for character in self.active_characters if character.motion.current_coord != character.motion.query_path("reassembly").waypoints[0].coord or not character.animation.active_scene_is_complete() } next_frame = self.frame if next_frame is not None: return next_frame raise StopIteration class Unstable(BaseEffect[UnstableConfig]): """Spawns characters jumbled, explodes them to the edge of the canvas, then reassembles them. Attributes: effect_config (UnstableConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[UnstableConfig]: return UnstableConfig @property def _iterator_cls(self) -> type[UnstableIterator]: return UnstableIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_vhstape.py000066400000000000000000000632561517776150200303610ustar00rootroot00000000000000"""Lines of characters glitch left and right and lose detail like an old VHS tape. Classes: VHSTape: Lines of characters glitch left and right and lose detail like an old VHS tape. VHSTapeConfig: Configuration for the VHSTape effect. VHSTapeIterator: Effect iterator for the VHSTape effect. Does not normally need to be called directly. """ from __future__ import annotations import random from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, Scene 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "vhstape", VHSTape, VHSTapeConfig @dataclass class VHSTapeConfig(BaseConfig): """Configuration for the VHSTape effect. Attributes: glitch_line_colors (tuple[Color, ...]): Tuple of colors for the characters when a single line is glitching. Colors are applied in order as an animation. glitch_wave_colors (tuple[Color, ...]): Tuple of colors for the characters in lines that are part of the glitch wave. Colors are applied in order as an animation. noise_colors (tuple[Color, ...]): Tuple of colors for the characters during the noise phase. glitch_line_chance (float): Chance that a line will glitch on any given frame. noise_chance (float): Chance that all characters will experience noise on any given frame. Valid values are 0 <= n <= 1. total_glitch_time (int): Total time, in frames, that the glitching phase will last. Valid values are n > 0. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="vhstape", help="Lines of characters glitch left and right and lose detail like an old VHS tape.", description="vhstape | Lines of characters glitch left and right and lose detail like an old VHS tape.", epilog=( "Example: terminaltexteffects vhstape --final-gradient-stops ab48ff e7b2b2 fffebd " "--final-gradient-steps 12 --final-gradient-direction vertical --glitch-line-colors " "ffffff ff0000 00ff00 0000ff ffffff --glitch-wave-colors ffffff ff0000 00ff00 0000ff ffffff " "--noise-colors 1e1e1f 3c3b3d 6d6c70 a2a1a6 cbc9cf ffffff --glitch-line-chance 0.05 " "--noise-chance 0.004 --total-glitch-time 600" ), ) glitch_line_colors: tuple[Color, ...] = argutils.ArgSpec( name="--glitch-line-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#ffffff"), Color("#ff0000"), Color("#00ff00"), Color("#0000ff"), Color("#ffffff")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the characters when a single line is glitching. Colors " "are applied in order as an animation.", ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the characters when a single line is glitching. Colors are " "applied in order as an animation." ) glitch_wave_colors: tuple[Color, ...] = argutils.ArgSpec( name="--glitch-wave-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#ffffff"), Color("#ff0000"), Color("#00ff00"), Color("#0000ff"), Color("#ffffff")), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the characters in lines that are part of the glitch wave. " "Colors are applied in order as an animation.", ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the characters in lines that are part of the glitch wave. Colors " "are applied in order as an animation." ) noise_colors: tuple[Color, ...] = argutils.ArgSpec( name="--noise-colors", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=( Color("#1e1e1f"), Color("#3c3b3d"), Color("#6d6c70"), Color("#a2a1a6"), Color("#cbc9cf"), Color("#ffffff"), ), metavar=argutils.ColorArg.METAVAR, help="Space separated, unquoted, list of colors for the characters during the noise phase.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the characters during the noise phase." glitch_line_chance: float = argutils.ArgSpec( name="--glitch-line-chance", type=argutils.NonNegativeRatio.type_parser, default=0.05, metavar=argutils.NonNegativeRatio.METAVAR, help="Chance that a line will glitch on any given frame.", ) # pyright: ignore[reportAssignmentType] "float : Chance that a line will glitch on any given frame." noise_chance: float = argutils.ArgSpec( name="--noise-chance", type=argutils.NonNegativeRatio.type_parser, default=0.004, metavar=argutils.NonNegativeRatio.METAVAR, help="Chance that all characters will experience noise on any given frame.", ) # pyright: ignore[reportAssignmentType] "float : Chance that all characters will experience noise on any given frame." total_glitch_time: int = argutils.ArgSpec( name="--total-glitch-time", type=argutils.PositiveInt.type_parser, default=600, metavar=argutils.PositiveInt.METAVAR, help="Total time, frames, that the glitching phase will last.", ) # pyright: ignore[reportAssignmentType] "int : Total time, frames, that the glitching phase will last." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#ab48ff"), Color("#e7b2b2"), Color("#fffebd")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class VHSTapeIterator(BaseEffectIterator[VHSTapeConfig]): """Effect iterator for the VHSTape effect.""" DYNAMIC_NEUTRAL_GRAY = Color("#808080") class Line: """Line of characters for the VHSTape effect.""" def __init__( self, characters: list[EffectCharacter], args: VHSTapeConfig, character_stable_color_map: dict[EffectCharacter, ColorPair], character_final_color_map: dict[EffectCharacter, ColorPair], ) -> None: """Initialize the line of characters. Args: characters (list[EffectCharacter]): The characters in the line. args (VHSTapeConfig): Configuration for the effect. character_stable_color_map (dict[EffectCharacter, ColorPair]): Mapping of characters to their stable colors during the effect. character_final_color_map (dict[EffectCharacter, ColorPair]): Mapping of characters to their resolved final colors. """ self.characters = characters self.args = args self.character_stable_color_map = character_stable_color_map self.character_final_color_map = character_final_color_map self.build_line_effects() def build_line_effects(self) -> None: """Build the effects for the line of characters.""" glitch_line_colors = self.args.glitch_line_colors snow_chars = ["#", "*", ".", ":"] noise_colors = self.args.noise_colors offset = random.randint(4, 25) direction = random.choice((-1, 1)) hold_time = random.randint(1, 50) for character in self.characters: # make glitch and restore waypoints glitch_path = character.motion.new_path(path_id="glitch", speed=2, hold_time=hold_time) glitch_path.new_waypoint( Coord(character.input_coord.column + (offset * direction), character.input_coord.row), waypoint_id="glitch", ) restore_path = character.motion.new_path(path_id="restore", speed=2) restore_path.new_waypoint(character.input_coord, waypoint_id="restore") # make glitch wave waypoints glitch_wave_mid_path = character.motion.new_path(path_id="glitch_wave_mid", speed=2) glitch_wave_mid_path.new_waypoint( Coord(character.input_coord.column + 8, character.input_coord.row), waypoint_id="glitch_wave_mid", ) glitch_wave_end_path = character.motion.new_path(path_id="glitch_wave_end", speed=2) glitch_wave_end_path.new_waypoint( Coord(character.input_coord.column + 14, character.input_coord.row), waypoint_id="glitch_wave_end", ) # make glitch scenes base_scn = character.animation.new_scene(scene_id="base") base_scn.add_frame( character.input_symbol, duration=1, colors=self.character_stable_color_map[character], ) glitch_scn_forward = character.animation.new_scene( scene_id="rgb_glitch_fwd", sync=Scene.SyncMetric.STEP, ) for color in glitch_line_colors: glitch_scn_forward.add_frame(character.input_symbol, duration=1, colors=ColorPair(fg=color)) glitch_scn_backward = character.animation.new_scene( scene_id="rgb_glitch_bwd", sync=Scene.SyncMetric.STEP, ) for color in glitch_line_colors[::-1]: glitch_scn_backward.add_frame(character.input_symbol, duration=1, colors=ColorPair(fg=color)) snow_scn = character.animation.new_scene(scene_id="snow") for _ in range(25): snow_scn.add_frame( random.choice(snow_chars), duration=2, colors=ColorPair(fg=random.choice(noise_colors)), ) snow_scn.add_frame( character.input_symbol, duration=1, colors=self.character_stable_color_map[character], ) final_snow_scn = character.animation.new_scene(scene_id="final_snow") final_redraw_scn = character.animation.new_scene(scene_id="final_redraw") final_redraw_scn.add_frame("█", duration=6, colors=ColorPair("#ffffff")) final_redraw_scn.add_frame( character.input_symbol, duration=1, colors=self.character_final_color_map[character], ) for _ in range(30): final_snow_scn.add_frame( random.choice(snow_chars), duration=2, colors=ColorPair(fg=random.choice(noise_colors)), ) # register events character.event_handler.register_event( EventHandler.Event.PATH_COMPLETE, glitch_path, EventHandler.Action.ACTIVATE_PATH, restore_path, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, glitch_path, EventHandler.Action.ACTIVATE_SCENE, glitch_scn_forward, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, restore_path, EventHandler.Action.ACTIVATE_SCENE, glitch_scn_backward, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, glitch_wave_mid_path, EventHandler.Action.ACTIVATE_SCENE, glitch_scn_forward, ) character.event_handler.register_event( EventHandler.Event.PATH_ACTIVATED, glitch_wave_end_path, EventHandler.Action.ACTIVATE_SCENE, glitch_scn_forward, ) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, glitch_scn_backward, EventHandler.Action.ACTIVATE_SCENE, base_scn, ) def snow(self) -> None: """Activate the snow animation for the line.""" for character in self.characters: character.animation.activate_scene(character.animation.query_scene("snow")) def set_hold_time(self, hold_time: int) -> None: """Set the hold time for the glitch and restore paths.""" for character in self.characters: character.motion.paths["glitch"].hold_time = hold_time def glitch(self, *, final: bool = False) -> None: """Activate the glitch animation for the line. Args: final (bool, optional): If final, set hold times to 0. Defaults to False. """ for character in self.characters: glitch_path = character.motion.query_path("glitch") restore_path = character.motion.query_path("restore") if final: glitch_path.hold_time = 0 restore_path.hold_time = 0 glitch_path.speed = 40 / random.randint(20, 40) restore_path.speed = 40 / random.randint(20, 40) character.motion.activate_path(glitch_path) def restore(self) -> None: """Activate the restore animation for the line.""" for character in self.characters: restore_path = character.motion.query_path("restore") restore_path.speed = 40 / random.randint(20, 40) character.motion.activate_path(restore_path) def activate_path(self, path_id: str) -> None: """Activate the specified path for the line. Args: path_id (str): The ID of the path to activate. """ for character in self.characters: character.motion.activate_path(character.motion.query_path(path_id)) def line_movement_complete(self) -> bool: """Check if the movement of the line is complete. Returns: bool: True if the movement of the line is complete, False otherwise. """ return all(character.motion.movement_is_complete() for character in self.characters) def __init__(self, effect: VHSTape) -> None: """Initialize the effect iterator. Args: effect (VHSTape): The effect to use for the iterator. """ super().__init__(effect) self.pending_chars: list[EffectCharacter] = [] self.lines: dict[int, VHSTapeIterator.Line] = {} self.active_glitch_wave_top: int | None = None self.active_glitch_wave_lines: list[VHSTapeIterator.Line] = [] self.active_glitch_lines: list[VHSTapeIterator.Line] = [] self.character_stable_color_map: dict[EffectCharacter, ColorPair] = {} self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the initial state of the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": input_fg = character.animation.input_fg_color input_bg = character.animation.input_bg_color self.character_stable_color_map[character] = ColorPair( fg=input_fg or self.DYNAMIC_NEUTRAL_GRAY, bg=input_bg, ) self.character_final_color_map[character] = ColorPair( fg=input_fg, bg=input_bg, ) else: gradient_color = final_gradient_mapping[character.input_coord] stable_colors = ColorPair(fg=gradient_color) self.character_stable_color_map[character] = stable_colors self.character_final_color_map[character] = stable_colors for row_index, characters in enumerate( self.terminal.get_characters_grouped(grouping=argutils.CharacterGroup.ROW_BOTTOM_TO_TOP), ): self.lines[row_index] = VHSTapeIterator.Line( characters, self.config, self.character_stable_color_map, self.character_final_color_map, ) for character in self.terminal.get_characters(): self.terminal.set_character_visibility(character, is_visible=True) character.animation.activate_scene(character.animation.query_scene("base")) self._glitching_steps_elapsed = 0 self._phase = "glitching" self._to_redraw = list(self.lines.values()) self._redrawing = False def glitch_wave(self) -> None: """Move the glitch wave.""" if not self.active_glitch_wave_top: if self.terminal.canvas.text_height >= 3: # choose a wave top index in the top half of the canvas or at least 3 rows up self.active_glitch_wave_top = self.terminal.canvas.text_bottom + random.randint( max((3, round(self.terminal.canvas.text_height * 0.5))), self.terminal.canvas.text_height, ) else: # not enough room for a wave return # if all lines have completed movement, proceed to move/restore wave if all(line.line_movement_complete() for line in self.active_glitch_wave_lines): if self.active_glitch_wave_lines: # only move 30% of the time wave_top_delta = (1 if random.random() < 0.3 else -1) if random.random() < 0.3 else 0 self.active_glitch_wave_top += wave_top_delta # clamp wave top to canvas self.active_glitch_wave_top = max(2, min(self.active_glitch_wave_top, self.terminal.canvas.text_top)) # get the lines for the wave new_wave_lines: list[VHSTapeIterator.Line] = [] for line_index in range(self.active_glitch_wave_top - 2, self.active_glitch_wave_top + 1): adjusted_line_index = line_index - (self.terminal.canvas.text_bottom - 1) if adjusted_line_index in self.lines: new_wave_lines.append(self.lines[adjusted_line_index]) # restore any lines that are no longer part of the wave for line in self.active_glitch_wave_lines: if line not in new_wave_lines: line.restore() self.active_characters = self.active_characters.union(line.characters) self.active_glitch_wave_lines = new_wave_lines if self.active_glitch_wave_top < self.terminal.canvas.text_bottom + 2: # wave at bottom, restore lines for line in self.active_glitch_wave_lines: line.restore() self.active_characters = self.active_characters.union(line.characters) self.active_glitch_wave_top = None self.active_glitch_wave_lines = [] else: for line, path_id in zip( self.active_glitch_wave_lines, ("glitch_wave_mid", "glitch_wave_end", "glitch_wave_mid"), ): line.activate_path(path_id) self.active_characters = self.active_characters.union(line.characters) def __next__(self) -> str: """Return the next frame in the animation.""" if self._phase != "complete" or self.active_characters: if self._phase == "glitching": # Check if all active glitch wave lines have completed their movement, if so move the wave if not self.active_glitch_wave_lines or all( line.line_movement_complete() for line in self.active_glitch_wave_lines ): self.glitch_wave() # Remove completed glitch lines from active glitch lines self.active_glitch_lines = [ line for line in self.active_glitch_lines if not line.line_movement_complete() ] # Randomly add new glitch lines if random.random() < self.config.glitch_line_chance and len(self.active_glitch_lines) < 3: glitch_line: VHSTapeIterator.Line = random.choice(list(self.lines.values())) if glitch_line not in self.active_glitch_wave_lines and glitch_line not in self.active_glitch_lines: glitch_line.set_hold_time(random.randint(20, 75)) self.active_glitch_lines.append(glitch_line) glitch_line.glitch() self.active_characters = self.active_characters.union(glitch_line.characters) # Randomly add noise to all lines if random.random() < self.config.noise_chance: for line in self.lines.values(): line.snow() if line not in self.active_glitch_wave_lines and line not in self.active_glitch_lines: self.active_characters = self.active_characters.union(line.characters) self._glitching_steps_elapsed += 1 # Check if glitching time has reached the total glitch time if self._glitching_steps_elapsed >= self.config.total_glitch_time: # Restore glitch wave lines for line in self.active_glitch_wave_lines: line.restore() # Restore glitch lines for line in self.active_glitch_lines: line.restore() self._phase = "noise" elif self._phase == "noise": # Activate final snow animation for all characters if not self.active_characters: for character in self.terminal.get_characters(): character.animation.activate_scene(character.animation.query_scene("final_snow")) self.active_characters.add(character) self._phase = "redraw" elif self._phase == "redraw": # Redraw lines one by one if self._redrawing or not self.active_characters: self._redrawing = True if self._to_redraw: next_line = self._to_redraw.pop() for character in next_line.characters: character.animation.activate_scene(character.animation.query_scene("final_redraw")) self.active_characters.add(character) else: self._phase = "complete" self.update() return self.frame raise StopIteration class VHSTape(BaseEffect[VHSTapeConfig]): """Lines of characters glitch left and right and lose detail like an old VHS tape. Attributes: effect_config (VHSTapeConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[VHSTapeConfig]: return VHSTapeConfig @property def _iterator_cls(self) -> type[VHSTapeIterator]: return VHSTapeIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_waves.py000066400000000000000000000345121517776150200300250ustar00rootroot00000000000000"""Waves travel across the terminal leaving behind the characters. Classes: Waves: Creates waves that travel across the terminal, leaving behind the characters. WavesConfig: Configuration for the Waves effect. WavesIterator: Iterates over the effect. Does not normally need to be called directly. """ from __future__ import annotations import typing from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, EffectCharacter, EventHandler, Gradient, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "waves", Waves, WavesConfig @dataclass class WavesConfig(BaseConfig): """Configuration for the Waves effect. Attributes: wave_symbols (tuple[str, ...] | str): Symbols to use for the wave animation. Multi-character strings will be used in sequence to create an animation. wave_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. wave_gradient_steps (tuple[int, ...]): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. wave_count (int): Number of waves to generate. Valid values are n > 0. wave_length (int): The number of frames for each step of the wave. Higher wave-lengths will create a slower wave. Valid values are n > 0. wave_direction (typing.Literal['column_left_to_right','column_right_to_left','row_top_to_bottom','row_bottom_to_top','center_to_outside','outside_to_center']): Direction of the wave. wave_easing (easing.EasingFunction): Easing function to use for wave travel. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ # noqa: E501 parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="waves", help="Waves travel across the terminal leaving behind the characters.", description="waves | Waves travel across the terminal leaving behind the characters.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects waves --wave-symbols ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ " "▇ ▆ ▅ ▄ ▃ ▂ ▁ --wave-gradient-stops f0ff65 ffb102 31a0d4 ffb102 f0ff65 --wave-gradient-steps 6 " "--wave-count 7 --wave-length 2 --wave-direction column_left_to_right --wave-easing IN_OUT_SINE " "--final-gradient-stops ffb102 31a0d4 f0ff65 --final-gradient-steps 12 " "--final-gradient-direction diagonal" ), ) wave_symbols: tuple[str, ...] = argutils.ArgSpec( name="--wave-symbols", type=argutils.Symbol.type_parser, default=("▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"), nargs="+", action=argutils.TupleAction, metavar=argutils.Symbol.METAVAR, help="Symbols to use for the wave animation. Multi-character strings will be used in sequence to create an " "animation.", ) # pyright: ignore[reportAssignmentType] ( "tuple[str, ...] : Symbols to use for the wave animation. Multi-character strings will be used in sequence to " "create an animation." ) wave_gradient_stops: tuple[Color, ...] = argutils.ArgSpec( name="--wave-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(Color("#f0ff65"), Color("#ffb102"), Color("#31a0d4"), Color("#ffb102"), Color("#f0ff65")), metavar=argutils.ColorArg.METAVAR, help="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.", ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) wave_gradient_steps: tuple[int, ...] = argutils.ArgSpec( name="--wave-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", action=argutils.TupleAction, default=(6,), metavar=argutils.PositiveInt.METAVAR, help="Space separated, unquoted, list of the number of gradient steps to use. More steps will create a " "smoother and longer gradient animation.", ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] : Tuple of the number of gradient steps to use. More steps will create a smoother and " "longer gradient animation." ) wave_count: int = argutils.ArgSpec( name="--wave-count", type=argutils.PositiveInt.type_parser, default=7, help="Number of waves to generate. n > 0.", ) # pyright: ignore[reportAssignmentType] "int : Number of waves to generate. n > 0." wave_length: int = argutils.ArgSpec( name="--wave-length", type=argutils.PositiveInt.type_parser, default=2, metavar=argutils.PositiveInt.METAVAR, help="The number of frames for each step of the wave. Higher wave-lengths will create a slower wave.", ) # pyright: ignore[reportAssignmentType] "int : The number of frames for each step of the wave. Higher wave-lengths will create a slower wave." wave_direction: typing.Literal[ "column_left_to_right", "column_right_to_left", "row_top_to_bottom", "row_bottom_to_top", "center_to_outside", "outside_to_center", ] = argutils.ArgSpec( name="--wave-direction", default="column_left_to_right", help="Direction of the wave.", choices=[ "column_left_to_right", "column_right_to_left", "row_top_to_bottom", "row_bottom_to_top", "center_to_outside", "outside_to_center", ], ) # pyright: ignore[reportAssignmentType] "typing.Literal['column_left_to_right','column_right_to_left','row_top_to_bottom','row_bottom_to_top','center_to_outside','outside_to_center']" wave_easing: easing.EasingFunction = argutils.ArgSpec( name="--wave-easing", type=argutils.Ease.type_parser, default=easing.in_out_sine, help="Easing function to use for wave travel.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for wave travel." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#ffb102"), Color("#31a0d4"), Color("#f0ff65")), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...] : Tuple of colors for the final color gradient. If only one color is provided, the " "characters will be displayed in that color." ) final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps " "will create a smoother and longer gradient animation." ) final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.DIAGONAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class WavesIterator(BaseEffectIterator[WavesConfig]): """Iterator for the Waves effect.""" def __init__(self, effect: Waves) -> None: """Initialize the iterator with the provided effect. Args: effect (Waves): The effect to iterate over. """ super().__init__(effect) self.pending_columns: list[list[EffectCharacter]] = [] self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.build() def build(self) -> None: """Build the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) wave_gradient = Gradient(*self.config.wave_gradient_stops, steps=self.config.wave_gradient_steps) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) wave_scn = character.animation.new_scene() wave_scn.ease = self.config.wave_easing for _ in range(self.config.wave_count): wave_scn.apply_gradient_to_symbols( self.config.wave_symbols, duration=self.config.wave_length, fg_gradient=wave_gradient, ) final_scn = character.animation.new_scene() if self.terminal.config.existing_color_handling == "dynamic": final_colors = self.character_final_color_map[character] if final_colors.fg_color is None and final_colors.bg_color is None: final_scn.add_frame(character.input_symbol, 10, colors=ColorPair()) else: final_fg_color = final_colors.fg_color final_bg_color = final_colors.bg_color final_scn.apply_gradient_to_symbols( character.input_symbol, duration=10, fg_gradient=( Gradient( wave_gradient.spectrum[-1], final_fg_color, steps=self.config.final_gradient_steps, ) if final_fg_color is not None else None ), bg_gradient=( Gradient( wave_gradient.spectrum[-1], final_bg_color, steps=self.config.final_gradient_steps, ) if final_bg_color is not None else None ), ) if final_fg_color is None: final_scn.add_frame(character.input_symbol, 10, colors=ColorPair(bg=final_bg_color)) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None for step in Gradient( wave_gradient.spectrum[-1], final_fg_color, steps=self.config.final_gradient_steps, ): final_scn.add_frame(character.input_symbol, 10, colors=ColorPair(fg=step)) character.event_handler.register_event( EventHandler.Event.SCENE_COMPLETE, wave_scn, EventHandler.Action.ACTIVATE_SCENE, final_scn, ) character.animation.activate_scene(wave_scn) if self.terminal.config.existing_color_handling == "dynamic": character.animation.set_appearance(character.input_symbol, self.character_final_color_map[character]) grouping_map = { "column_left_to_right": argutils.CharacterGroup.COLUMN_LEFT_TO_RIGHT, "column_right_to_left": argutils.CharacterGroup.COLUMN_RIGHT_TO_LEFT, "row_top_to_bottom": argutils.CharacterGroup.ROW_TOP_TO_BOTTOM, "row_bottom_to_top": argutils.CharacterGroup.ROW_BOTTOM_TO_TOP, "center_to_outside": argutils.CharacterGroup.CENTER_TO_OUTSIDE, "outside_to_center": argutils.CharacterGroup.OUTSIDE_TO_CENTER, } for column in self.terminal.get_characters_grouped(grouping=grouping_map[self.config.wave_direction]): self.pending_columns.append(column) def __next__(self) -> str: """Return the next frame in the animation.""" if self.pending_columns or self.active_characters: if self.pending_columns: next_column = self.pending_columns.pop(0) for character in next_column: self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) self.update() return self.frame raise StopIteration class Waves(BaseEffect[WavesConfig]): """Creates waves that travel across the terminal, leaving behind the characters. Attributes: effect_config (ExpandConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[WavesConfig]: return WavesConfig @property def _iterator_cls(self) -> type[WavesIterator]: return WavesIterator terminaltexteffects-release-0.15.0/terminaltexteffects/effects/effect_wipe.py000066400000000000000000000221671517776150200276470ustar00rootroot00000000000000"""Performs a wipe across the terminal to reveal characters. Classes: Wipe: Performs a wipe across the terminal to reveal characters. WipeConfig: Configuration for the Wipe effect. WipeIterator: Effect iterator for the Wipe effect. Does not normally need to be called directly. """ from __future__ import annotations from dataclasses import dataclass from terminaltexteffects import Color, ColorPair, EffectCharacter, Gradient, easing from terminaltexteffects.engine.base_config import ( BaseConfig, FinalGradientDirectionArg, FinalGradientFramesArg, 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]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "wipe", Wipe, WipeConfig @dataclass class WipeConfig(BaseConfig): """Configuration for the Wipe effect. Attributes: wipe_direction (CharacterGroup): Direction the text will wipe. wipe_delay (int): Number of frames to wait before adding the next character group. Increase, to slow down the effect. Valid values are n >= 0. final_gradient_stops (tuple[Color, ...]): Tuple of colors for the wipe gradient. final_gradient_steps (tuple[int, ...] | int): Tuple of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. Valid values are n > 0. final_gradient_frames (int): Number of frames to display each gradient step. Increase to slow down the gradient animation. final_gradient_direction (Gradient.Direction): Direction of the final gradient. """ parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="wipe", help="Wipes the text across the terminal to reveal characters.", description="wipe | Wipes the text across the terminal to reveal characters.", epilog=( f"{argutils.EASING_EPILOG} Example: terminaltexteffects wipe --wipe-direction " "diagonal_top_left_to_bottom_right --wipe-delay 0 --wipe-ease IN_OUT_CIRC " "--final-gradient-stops 833ab4 fd1d1d fcb045 --final-gradient-steps 12 " "--final-gradient-frames 3 --final-gradient-direction vertical" ), ) wipe_direction: argutils.CharacterGroup = argutils.ArgSpec( name="--wipe-direction", default=argutils.CharacterGroup.DIAGONAL_TOP_LEFT_TO_BOTTOM_RIGHT, type=argutils.CharacterGroupArg.type_parser, help="Direction the text will wipe.", ) # pyright: ignore[reportAssignmentType] "CharacterGroup : Direction the text will wipe." wipe_delay: int = argutils.ArgSpec( name="--wipe-delay", type=argutils.NonNegativeInt.type_parser, default=0, metavar=argutils.NonNegativeInt.METAVAR, help="Number of frames to wait before adding the next character group. Increase, to slow down the effect.", ) # pyright: ignore[reportAssignmentType] "int : Number of frames to wait before adding the next character group. Increase, to slow down the effect." wipe_ease: easing.EasingFunction = argutils.ArgSpec( name="--wipe-ease", type=argutils.Ease.type_parser, default=easing.in_out_circ, help="Easing function to use for the wipe effect.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction : Easing function to use for the wipe effect." final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( default=(Color("#833ab4"), Color("#fd1d1d"), Color("#fcb045")), help="Space separated, unquoted, list of colors for the wipe gradient.", ) # pyright: ignore[reportAssignmentType] "tuple[Color, ...] : Tuple of colors for the wipe gradient." final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( default=12, ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int : Int or Tuple of ints for the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ) final_gradient_frames: int = FinalGradientFramesArg( default=3, ) # pyright: ignore[reportAssignmentType] "int : Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( default=Gradient.Direction.VERTICAL, ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." class WipeIterator(BaseEffectIterator[WipeConfig]): """Effect iterator for the Wipe effect.""" def __init__(self, effect: Wipe) -> None: """Initialize the effect iterator. Args: effect (Wipe): The effect to use for the iterator. """ super().__init__(effect) self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} self.easer = easing.SequenceEaser( self.terminal.get_characters_grouped(self.config.wipe_direction), easing_function=self.config.wipe_ease, ) self._wipe_delay = self.config.wipe_delay self.build() def build(self) -> None: """Build the effect.""" final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): if self.terminal.config.existing_color_handling == "dynamic": self.character_final_color_map[character] = ColorPair( fg=character.animation.input_fg_color, bg=character.animation.input_bg_color, ) else: self.character_final_color_map[character] = ColorPair( fg=final_gradient_mapping[character.input_coord], ) wipe_scn = character.animation.new_scene(scene_id="wipe") if self.terminal.config.existing_color_handling == "dynamic": final_colors = self.character_final_color_map[character] frame_count = ( sum(self.config.final_gradient_steps) + 1 if isinstance(self.config.final_gradient_steps, tuple) else self.config.final_gradient_steps + 1 ) for _ in range(frame_count): wipe_scn.add_frame( character.input_symbol, self.config.final_gradient_frames, colors=final_colors, ) else: final_fg_color = self.character_final_color_map[character].fg_color assert final_fg_color is not None wipe_gradient = Gradient( final_gradient.spectrum[0], final_fg_color, steps=self.config.final_gradient_steps, ) wipe_scn.apply_gradient_to_symbols( character.input_symbol, self.config.final_gradient_frames, fg_gradient=wipe_gradient, ) def __next__(self) -> str: """Return the next frame in the animation.""" if self.active_characters or not self.easer.is_complete(): if self._wipe_delay == 0: self.easer.step() for group in self.easer.added: for character in group: character.animation.activate_scene("wipe") self.terminal.set_character_visibility(character, is_visible=True) self.active_characters.add(character) for group in self.easer.removed: for character in group: character.animation.deactivate_scene() character.animation.query_scene("wipe").reset_scene() self.terminal.set_character_visibility(character, is_visible=False) self._wipe_delay = self.config.wipe_delay else: self._wipe_delay -= 1 self.update() return self.frame raise StopIteration class Wipe(BaseEffect[WipeConfig]): """Performs a wipe across the terminal to reveal characters. Attributes: effect_config (WipeConfig): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property def _config_cls(self) -> type[WipeConfig]: return WipeConfig @property def _iterator_cls(self) -> type[WipeIterator]: return WipeIterator terminaltexteffects-release-0.15.0/terminaltexteffects/engine/000077500000000000000000000000001517776150200246335ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/engine/__init__.py000066400000000000000000000000511517776150200267400ustar00rootroot00000000000000"""TerminalTextEffects engine module.""" terminaltexteffects-release-0.15.0/terminaltexteffects/engine/animation.py000066400000000000000000001162051517776150200271710ustar00rootroot00000000000000"""Classes for handling animations in terminal text effects. Classes: CharacterVisual: A class for storing symbol, color, and terminal graphical modes for the character. Frame: A class representing a frame in an animation. Scene: A class representing a sequence of frames that can be played in an animation. Animation: A class for handling animations for an EffectCharacter. """ from __future__ import annotations import typing from dataclasses import dataclass from enum import Enum, auto from terminaltexteffects.utils import ansitools, colorterm, easing, graphics, hexterm from terminaltexteffects.utils.exceptions import ( ActivateEmptySceneError, AnimationSceneError, FrameDurationError, SceneNotFoundError, ) if typing.TYPE_CHECKING: from terminaltexteffects.engine import base_character # pragma: no cover @dataclass class CharacterVisual: """A class for storing symbol, color, and terminal graphical modes for the character. Args: symbol (str): The unformatted symbol. bold (bool): Bold mode. dim (bool): Dim mode. italic (bool): Italic mode. underline (bool): Underline mode. blink (bool): Blink mode. reverse (bool): Reverse mode. hidden (bool): Hidden mode. strike (bool): Strike mode. colors (graphics.ColorPair | None): The symbol's colors. _fg_color_code (str | int | None): The symbol's foreground color code. _bg_color_code (str | int | None): The symbol's background color code. Attributes: formatted_symbol (str): The current symbol with all ANSI sequences applied. Methods: format_symbol: Formats the symbol for printing by applying ANSI sequences for supported active modes and color. """ symbol: str bold: bool = False dim: bool = False italic: bool = False underline: bool = False blink: bool = False reverse: bool = False hidden: bool = False strike: bool = False colors: graphics.ColorPair | None = None # the Color object provided during initialization # the _*_color_code attributes are used to store the actual 8-bit int or 24-bit hex str after applying terminal # config args these are used by colorterm to produce the ansi sequences _fg_color_code: str | int | None = None _bg_color_code: str | int | None = None def __post_init__(self) -> None: """Create the formatted symbol by applying ANSI sequences for any active modes and color.""" self.formatted_symbol = self.format_symbol() def format_symbol(self) -> str: """Format the symbol for printing by applying ANSI sequences for supported active modes and color. The `dim` attribute is stored on the visual but is not currently emitted as an ANSI sequence. """ formatting_string = "" if self.bold: formatting_string += ansitools.apply_bold() # Future: review the dim ANSI sequence and decide whether CharacterVisual should emit it. if self.italic: formatting_string += ansitools.apply_italic() if self.underline: formatting_string += ansitools.apply_underline() if self.blink: formatting_string += ansitools.apply_blink() if self.reverse: formatting_string += ansitools.apply_reverse() if self.hidden: formatting_string += ansitools.apply_hidden() if self.strike: formatting_string += ansitools.apply_strikethrough() if self._fg_color_code is not None: formatting_string += colorterm.fg(self._fg_color_code) if self._bg_color_code is not None: formatting_string += colorterm.bg(self._bg_color_code) return f"{formatting_string}{self.symbol}{ansitools.reset_all() if formatting_string else ''}" @dataclass class Frame: """A Frame is a CharacterVisual with a duration. Args: character_visual (CharacterVisual): a CharacterVisual object duration (int): the number of ticks to display the Frame Attributes: character_visual (CharacterVisual): the CharacterVisual object for the Frame duration (int): the number of ticks to display the Frame ticks_elapsed (int): the number of ticks that have elapsed displaying this frame """ character_visual: CharacterVisual duration: int def __post_init__(self) -> None: """Initialize the ticks_elapsed attribute to 0.""" self.ticks_elapsed = 0 class Scene: """A Scene is a collection of Frames that can be played in sequence. Scenes can be looped and synced to movement. Methods: add_frame: Adds a Frame to the Scene. activate: Activates the Scene. get_next_visual: Gets the next CharacterVisual in the Scene. apply_gradient_to_symbols: Applies a gradient effect to a sequence of symbols. reset_scene: Resets the Scene. Attributes: scene_id (str): the ID of the Scene is_looping (bool): Whether the Scene should loop sync (Scene.SyncMetric | None): The type of sync to use for the Scene ease (easing.EasingFunction | None): The easing function to use for the Scene no_color (bool): Whether to ignore colors use_xterm_colors (bool): Whether to convert all colors to XTerm-256 colors frames (list[Frame]): The list of Frames in the Scene played_frames (list[Frame]): The list of Frames that have been played frame_index_map (dict[int, Frame]): A mapping of frame index to Frame easing_total_steps (int): The total number of steps in the easing function easing_current_step (int): The current step in the easing function preexisting_colors (graphics.ColorPair | None): The preexisting colors parsed from the input. preexisting_bold (bool): Whether parsed input bold styling should override frame bold styling. """ xterm_color_map: typing.ClassVar[dict[str, int]] = {} class SyncMetric(Enum): """Enum for specifying how a Scene synchronizes to motion progress. Attributes: DISTANCE (int): Sync scene progress to the active path's traveled distance. STEP (int): Sync scene progress to the active path's current step count. """ DISTANCE = auto() STEP = auto() def __init__( self, scene_id: str, *, is_looping: bool = False, sync: SyncMetric | None = None, ease: easing.EasingFunction | None = None, no_color: bool = False, use_xterm_colors: bool = False, ) -> None: """Initialize a Scene. Args: scene_id (str): the ID of the Scene is_looping (bool, optional): Whether the Scene should loop. Defaults to False. sync (Scene.SyncMetric | None, optional): The type of sync to use for the Scene. Defaults to None. ease (easing.EasingFunction | None, optional): The easing function to use for the Scene. Defaults to None. no_color (bool, optional): Whether to colors should be ignored. Defaults to False. use_xterm_colors (bool, optional): Whether to convert all colors to XTerm-256 colors. Defaults to False. """ self.scene_id = scene_id self.is_looping = is_looping self.sync: Scene.SyncMetric | None = sync self.ease: easing.EasingFunction | None = ease self.no_color = no_color self.use_xterm_colors = use_xterm_colors self.frames: list[Frame] = [] self.played_frames: list[Frame] = [] self.frame_index_map: dict[int, Frame] = {} self.easing_total_steps: int = 0 self.easing_current_step: int = 0 self.preexisting_colors: graphics.ColorPair | None = None self.preexisting_bold: bool = False def _get_color_code(self, color: graphics.Color | None) -> str | int | None: """Get the color code for the given color. RGB colors are converted to XTerm-256 colors if use_xterm_colors is True. If no_color is True, returns None. Otherwise, returns the RGB color. Args: color (graphics.Color | None): the color to get the code for Returns: str | int | None: the color code """ if color: if self.no_color: return None if self.use_xterm_colors: if color.xterm_color is not None: return color.xterm_color if color.rgb_color in self.xterm_color_map: return self.xterm_color_map[color.rgb_color] xterm_color = hexterm.hex_to_xterm(color.rgb_color) self.xterm_color_map[color.rgb_color] = xterm_color return xterm_color return color.rgb_color return None def add_frame( self, symbol: str, duration: int, *, colors: graphics.ColorPair | None = None, bold: bool = False, dim: bool = False, italic: bool = False, underline: bool = False, blink: bool = False, reverse: bool = False, hidden: bool = False, strike: bool = False, ) -> None: """Add a Frame to the Scene with the given symbol, duration, color, and graphical modes. If `preexisting_colors` is set on the Scene, those colors override the `colors` argument for every frame added to the Scene. Args: symbol (str): the symbol to show duration (int): the number of frames to use the Frame colors (graphics.ColorPair | None, optional): the colors to use. Defaults to None. bold (bool, optional): bold mode. Defaults to False. dim (bool, optional): dim mode. Defaults to False. italic (bool, optional): italic mode. Defaults to False. underline (bool, optional): underline mode. Defaults to False. blink (bool, optional): blink mode. Defaults to False. reverse (bool, optional): reverse mode. Defaults to False. hidden (bool, optional): hidden mode. Defaults to False. strike (bool, optional): strike mode. Defaults to False. Raises: FrameDurationError: if the frame duration is less than 1 """ # override fg and bg colors if they are set in the Scene due to existing color handling = always if self.preexisting_colors: colors = self.preexisting_colors if self.preexisting_bold: bold = True # get the color code for the fg and bg colors if colors: char_vis_fg_color = self._get_color_code(colors.fg_color) char_vis_bg_color = self._get_color_code(colors.bg_color) else: char_vis_fg_color = None char_vis_bg_color = None if duration < 1: raise FrameDurationError(duration) char_vis = CharacterVisual( symbol, bold=bold, dim=dim, italic=italic, underline=underline, blink=blink, reverse=reverse, hidden=hidden, strike=strike, colors=colors, _fg_color_code=char_vis_fg_color, _bg_color_code=char_vis_bg_color, ) frame = Frame(char_vis, duration) self.frames.append(frame) for _ in range(frame.duration): self.frame_index_map[self.easing_total_steps] = frame self.easing_total_steps += 1 def activate(self) -> CharacterVisual: """Activate the Scene by returning the first frame's `CharacterVisual`. Called by the `Animation` object when the Scene is activated. Raises: ActivateEmptySceneError: if the Scene has no frames Returns: CharacterVisual: the first frame's visual. """ if self.frames: return self.frames[0].character_visual raise ActivateEmptySceneError(self) def get_next_visual(self) -> CharacterVisual: """Get the next CharacterVisual in the Scene. Retrieve the current frame from `frames`, then increment the frame's `ticks_elapsed`. If `ticks_elapsed` reaches the frame duration, reset that frame's `ticks_elapsed` to `0` and move it from `frames` to `played_frames`. If the Scene is looping and all frames have been played, restore `frames` from `played_frames` so the next loop begins from the start of each frame again. Return the current frame's `CharacterVisual`. Returns: CharacterVisual: The visual of the current frame in the Scene. """ current_frame = self.frames[0] next_visual = current_frame.character_visual current_frame.ticks_elapsed += 1 if current_frame.ticks_elapsed == current_frame.duration: current_frame.ticks_elapsed = 0 self.played_frames.append(self.frames.pop(0)) if self.is_looping and not self.frames: self.frames.extend(self.played_frames) self.played_frames.clear() return next_visual def apply_gradient_to_symbols( self, symbols: typing.Sequence[str], duration: int, *, fg_gradient: graphics.Gradient | None = None, bg_gradient: graphics.Gradient | None = None, ) -> None: """Apply a gradient effect to a sequence of symbols and add each symbol as a frame to the Scene. Args: symbols (Sequence[str]): The sequence of symbols to apply the gradient to. duration (int): The duration to show each frame. fg_gradient (graphics.Gradient | None): The foreground gradient to apply. Defaults to None. bg_gradient (graphics.Gradient | None): The background gradient to apply. Defaults to None. Returns: None Raises: AnimationSceneError: if gradients are invalid or symbols are invalid """ T = typing.TypeVar("T") R = typing.TypeVar("R") def cyclic_distribution( larger_seq: typing.Sequence[T], smaller_seq: typing.Sequence[R], ) -> typing.Generator[tuple[T, R], None, None]: """Distributes the elements of a smaller sequence cyclically across a larger sequence with overflow. Example: cyclic_distribution([1, 2, 3, 4, 5], [a, b]) -> [(1, a), (2, a), (3, a), (4, b), (5, b)] Args: larger_seq (typing.Sequence[T]): the larger sequence smaller_seq (typing.Sequence[R]): the smaller sequence Yields: typing.Generator[tuple[T, R], None, None]: a generator yielding tuples of elements from the larger and smaller sequences """ repeat_factor = len(larger_seq) // len(smaller_seq) overflow_count = len(larger_seq) % len(smaller_seq) overflow_used = False smaller_index = 0 current_repeat_factor = 0 for larger_seq_element in larger_seq: if current_repeat_factor >= repeat_factor: if overflow_count: if overflow_used: smaller_index += 1 current_repeat_factor = 0 overflow_used = False else: overflow_used = True overflow_count -= 1 else: smaller_index += 1 current_repeat_factor = 0 current_repeat_factor += 1 yield larger_seq_element, smaller_seq[smaller_index] if fg_gradient is None and bg_gradient is None: message = "Foreground and background gradient are None. At least one gradient must be provided." raise AnimationSceneError( message, ) if not ((fg_gradient and fg_gradient.spectrum) or (bg_gradient and bg_gradient.spectrum)): message = ( "Foreground and background gradient are empty. At least one gradient must have at least one color." ) raise AnimationSceneError(message) for symbol in symbols: 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] = [] if fg_gradient and fg_gradient.spectrum and bg_gradient and bg_gradient.spectrum: if len(fg_gradient.spectrum) >= len(bg_gradient.spectrum): color_pairs = [ graphics.ColorPair(fg=fg_color, bg=bg_color) for fg_color, bg_color in cyclic_distribution(fg_gradient.spectrum, bg_gradient.spectrum) ] else: color_pairs = [ graphics.ColorPair(fg=fg_color, bg=bg_color) for bg_color, fg_color in cyclic_distribution(bg_gradient.spectrum, fg_gradient.spectrum) ] elif fg_gradient and fg_gradient.spectrum: color_pairs = [graphics.ColorPair(fg=color, bg=None) for color in fg_gradient.spectrum] elif bg_gradient and bg_gradient.spectrum: color_pairs = [graphics.ColorPair(fg=None, bg=color) for color in bg_gradient.spectrum] if len(symbols) >= len(color_pairs): for symbol, colors in cyclic_distribution(symbols, color_pairs): self.add_frame(symbol, duration, colors=colors) else: for colors, symbol in cyclic_distribution(color_pairs, symbols): self.add_frame(symbol, duration, colors=colors) def reset_scene(self) -> None: """Reset the Scene to its initial playback state. All remaining frames are moved back into the full frame sequence, each frame's `ticks_elapsed` is reset to `0`, `played_frames` is cleared, and `easing_current_step` is reset to `0`. """ for sequence in self.frames: sequence.ticks_elapsed = 0 self.played_frames.append(sequence) self.frames.clear() self.frames.extend(self.played_frames) self.played_frames.clear() self.easing_current_step = 0 def __eq__(self, other: object) -> bool: """Check if two Scene objects are equal based on their scene_id.""" if not isinstance(other, Scene): return NotImplemented return self.scene_id == other.scene_id def __hash__(self) -> int: """Return the hash value of the Scene based on its scene_id.""" return hash(self.scene_id) class Animation: """Animation handler for an EffectCharacter. It contains a scene_name -> Scene mapping and the active Scene. Calls to step_animation() progress the Scene and apply the next visual to the character. Attributes: scenes (dict[str, Scene]): a mapping of scene IDs to Scene objects character (base_character.EffectCharacter): the EffectCharacter object to animate active_scene (Scene | None): the active Scene use_xterm_colors (bool): whether to convert all colors to XTerm-256 colors no_color (bool): whether to ignore colors existing_color_handling (str): how to handle color ANSI sequences from the input data input_fg_color (graphics.Color | None): the input foreground Color input_bg_color (graphics.Color | None): the input background Color input_bold (bool): whether the input character was parsed with active bold SGR styling xterm_color_map (dict[str, int]): a mapping of RGB color codes to XTerm-256 color codes active_scene_current_step (int): Reserved for scene-step tracking; currently reset on activation but otherwise unused. current_character_visual (CharacterVisual): the current visual of the character Methods: new_scene: Creates a new Scene and adds it to the Animation. query_scene: Returns a Scene from the Animation. active_scene_is_complete: Returns whether the active scene is complete. set_appearance: Applies a symbol and color to the character. adjust_color_brightness: Adjusts the brightness of a given color. _ease_animation: Returns the percentage of total distance that should be moved based on the easing function. step_animation: Apply the next symbol in the scene to the character. activate_scene: Activates a Scene. """ def __init__(self, character: base_character.EffectCharacter) -> None: """Initialize the Animation object. Args: character (base_character.EffectCharacter): the EffectCharacter object to animate """ self.scenes: dict[str, Scene] = {} self.character = character self.active_scene: Scene | None = None self.use_xterm_colors: bool = False self.no_color: bool = False self.existing_color_handling: typing.Literal["always", "dynamic", "ignore"] = "ignore" self.input_fg_color: graphics.Color | None = None self.input_bg_color: graphics.Color | None = None self.input_bold: bool = False self.xterm_color_map: dict[str, int] = {} # Future: review whether `active_scene_current_step` should be removed or implemented for real scene tracking. self.active_scene_current_step: int = 0 self.current_character_visual: CharacterVisual = CharacterVisual(character.input_symbol) def _get_color_code(self, color: graphics.Color | None) -> str | int | None: """Get the color code for the given color. RGB colors are converted to XTerm-256 colors if use_xterm_colors is True. If no_color is True, returns None. Otherwise, returns the RGB color. Args: color (graphics.Color | None): the color to get the code for Returns: str | int | None: the color code """ if color: if self.no_color: return None if self.use_xterm_colors: if color.xterm_color is not None: return color.xterm_color if color.rgb_color in self.xterm_color_map: return self.xterm_color_map[color.rgb_color] xterm_color = hexterm.hex_to_xterm(color.rgb_color) self.xterm_color_map[color.rgb_color] = xterm_color return xterm_color return color.rgb_color return None def new_scene( self, *, is_looping: bool = False, sync: Scene.SyncMetric | None = None, ease: easing.EasingFunction | None = None, scene_id: str = "", ) -> 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. Args: scene_id (str): Name for the scene. Used to query for the scene. is_looping (bool): Whether the scene should loop. sync (Scene.SyncMetric | None): The type of sync to use for the scene. ease (easing.EasingFunction | None): The easing function to use for the scene. Returns: Scene: The new Scene. """ if not scene_id: found_unique = False current_id = len(self.scenes) while not found_unique: scene_id = f"{current_id}" if scene_id not in self.scenes: 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. 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 else: preexisting_colors = None preexisting_bold = False new_scene = Scene( scene_id=scene_id, is_looping=is_looping, sync=sync, ease=ease, no_color=self.no_color, use_xterm_colors=self.use_xterm_colors, ) new_scene.preexisting_colors = preexisting_colors new_scene.preexisting_bold = preexisting_bold self.scenes[scene_id] = new_scene return new_scene @typing.overload def query_scene(self, scene_id: str) -> Scene: ... @typing.overload def query_scene(self, scene_id: str, not_found_action: typing.Literal["raise"]) -> Scene: ... @typing.overload def query_scene(self, scene_id: str, not_found_action: None) -> Scene | None: ... def query_scene(self, scene_id: str, not_found_action: typing.Literal["raise"] | None = "raise") -> Scene | None: """Return the Scene with the given scene_id, or None if no Scene with the given ID exists. Args: scene_id (str): The ID of the Scene. not_found_action (Literal["raise"] | None, optional): Action to take if a Scene with the given `scene_id` is not found. If "raise", a SceneNotFoundError will be raised. If `None`, method will return `None`. Returns: Scene | None: The Scene with the given `scene_id`, or None. Raises: SceneNotFoundError: If `not_found_action` is "raise" and a Scene with the given `scene_id` is not found. """ found_scene = self.scenes.get(scene_id, None) if not_found_action and found_scene is None: raise SceneNotFoundError(scene_id) return found_scene def active_scene_is_complete(self) -> bool: """Return whether the active scene should be treated as complete. A scene is treated as complete when there is no active scene, when the active scene has no remaining frames to play, or when the active scene is looping. Returns: bool: True if the active scene is complete, False otherwise. """ return bool(not self.active_scene or not self.active_scene.frames or self.active_scene.is_looping) def set_appearance(self, symbol: str | None = None, colors: graphics.ColorPair | None = None) -> None: """Update the current character visual with the symbol and colors provided. If no symbol is provided, the character's input symbol is used. If `existing_color_handling` is `"always"`, any provided foreground or background colors are overridden by the character's input colors where available. If the character has an active scene, any appearance set with this method will be overwritten when the scene is stepped to the next frame. Args: symbol (str | None): The symbol to apply. colors (graphics.ColorPair | None): The colors to apply. """ if symbol is None: symbol = self.character.input_symbol if colors is None: colors = graphics.ColorPair(fg=None, bg=None) # In always mode, input-derived characters use exactly the parsed input fg/bg pair, # even when one or both channels are absent. bold = False if self.existing_color_handling == "always" and self.character.uses_input_preexisting_colors: colors = graphics.ColorPair(fg=self.input_fg_color, bg=self.input_bg_color) bold = self.input_bold char_vis_fg_color: str | int | None = self._get_color_code(colors.fg_color) char_vis_bg_color: str | int | None = self._get_color_code(colors.bg_color) self.current_character_visual = CharacterVisual( symbol, bold=bold, colors=colors, _fg_color_code=char_vis_fg_color, _bg_color_code=char_vis_bg_color, ) @staticmethod def adjust_color_brightness(color: graphics.Color, brightness: float) -> graphics.Color: """Adjust the brightness of a given color. Args: color (Color): The color code to adjust. brightness (float): The brightness adjustment factor. Returns: Color: The adjusted color code. """ def hue_to_rgb(lightness_scaled: float, color_intensity: float, hue_value: float) -> float: """Convert a hue value to an RGB value component. This function is a helper function used in the conversion from HSL (Hue, Saturation, Lightness) color space to RGB (Red, Green, Blue) color space. It takes in three parameters: lightness_scaled, color_intensity, and hue_value. These parameters are derived from the HSL color space and are used to calculate the corresponding RGB value. Args: lightness_scaled (float): The lightness value from the HSL color space, scaled and shifted to be used in the RGB conversion. color_intensity (float): The intensity of the color, used to adjust the RGB values. hue_value (float): The hue value from the HSL color space, used to calculate the RGB values. Returns: float: The calculated RGB component. """ if hue_value < 0: hue_value += 1 if hue_value > 1: hue_value -= 1 if hue_value < 1 / 6: return lightness_scaled + (color_intensity - lightness_scaled) * 6 * hue_value if hue_value < 1 / 2: return color_intensity if hue_value < 2 / 3: return lightness_scaled + (color_intensity - lightness_scaled) * (2 / 3 - hue_value) * 6 return lightness_scaled normalized_red = int(color.rgb_color[0:2], 16) / 255 normalized_green = int(color.rgb_color[2:4], 16) / 255 normalized_blue = int(color.rgb_color[4:6], 16) / 255 # Convert RGB to HSL max_val = max(normalized_red, normalized_green, normalized_blue) min_val = min(normalized_red, normalized_green, normalized_blue) lightness = (max_val + min_val) / 2 if max_val == min_val: hue_value = saturation = 0.0 # achromatic else: diff = max_val - min_val lightness_threshold = 0.5 saturation = ( diff / (2 - max_val - min_val) if lightness > lightness_threshold else diff / (max_val + min_val) ) if max_val == normalized_red: hue_value = (normalized_green - normalized_blue) / diff + ( 6 if normalized_green < normalized_blue else 0 ) elif max_val == normalized_green: hue_value = (normalized_blue - normalized_red) / diff + 2 else: hue_value = (normalized_red - normalized_green) / diff + 4 hue_value /= 6 # Adjust lightness lightness = max(min(lightness * brightness, 1), 0) # Convert back to RGB if saturation == 0: red = green = blue = lightness # achromatic else: color_intensity = ( lightness * (1 + saturation) if lightness < lightness_threshold # type: ignore[unbound] else lightness + saturation - lightness * saturation ) lightness_scaled = 2 * lightness - color_intensity red = hue_to_rgb(lightness_scaled, color_intensity, hue_value + 1 / 3) green = hue_to_rgb(lightness_scaled, color_intensity, hue_value) blue = hue_to_rgb(lightness_scaled, color_intensity, hue_value - 1 / 3) # Convert to hex adjusted_color = f"{int(red * 255):02x}{int(green * 255):02x}{int(blue * 255):02x}" return graphics.Color(adjusted_color) def _ease_animation(self, easing_func: easing.EasingFunction) -> float: """Return the percentage of total distance that should be moved based on the easing function. Args: easing_func (easing.EasingFunction): The easing function to use. Returns: float: The percentage of total distance to move. """ if self.active_scene is None: return 0 elapsed_step_ratio = self.active_scene.easing_current_step / self.active_scene.easing_total_steps return easing_func(elapsed_step_ratio) def step_animation(self) -> None: """Progress the Scene and apply the next visual to the character. Behavior: * 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. * When a non-looping scene completes, it is reset, deactivated, and a `SCENE_COMPLETE` event is triggered. """ if self.active_scene and self.active_scene.frames: # if the active scene is synced to movement, calculate the sequence index based on the # current waypoint progress if self.active_scene.sync: if self.character.motion.active_path: if self.active_scene.sync == Scene.SyncMetric.STEP: sequence_index = round( (len(self.active_scene.frames) - 1) * ( max(self.character.motion.active_path.current_step, 1) / max(self.character.motion.active_path.max_steps, 1) ), ) elif self.active_scene.sync == Scene.SyncMetric.DISTANCE: sequence_index = round( (len(self.active_scene.frames) - 1) * ( max( max(self.character.motion.active_path.total_distance, 1) - max( self.character.motion.active_path.total_distance - self.character.motion.active_path.last_distance_reached, 1, ), 1, ) / max(self.character.motion.active_path.total_distance, 1) ), ) try: self.current_character_visual = self.active_scene.frames[sequence_index].character_visual # type: ignore[unbound] except IndexError: self.current_character_visual = self.active_scene.frames[-1].character_visual # when the active waypoint has been deactivated, use the final symbol in the scene and finish the scene else: self.current_character_visual = self.active_scene.frames[-1].character_visual self.active_scene.played_frames.extend(self.active_scene.frames) self.active_scene.frames.clear() elif self.active_scene and self.active_scene.ease: easing_factor = self._ease_animation(self.active_scene.ease) frame_index = round(easing_factor * max(self.active_scene.easing_total_steps - 1, 0)) frame_index = max(min(frame_index, self.active_scene.easing_total_steps - 1), 0) frame = self.active_scene.frame_index_map[frame_index] self.current_character_visual = frame.character_visual self.active_scene.easing_current_step += 1 if self.active_scene.easing_current_step == self.active_scene.easing_total_steps: if self.active_scene.is_looping: self.active_scene.easing_current_step = 0 else: self.active_scene.played_frames.extend(self.active_scene.frames) self.active_scene.frames.clear() else: self.current_character_visual = self.active_scene.get_next_visual() if self.active_scene_is_complete(): completed_scene = self.active_scene if not self.active_scene.is_looping: self.active_scene.reset_scene() self.active_scene = None self.character.event_handler._handle_event( self.character.event_handler.Event.SCENE_COMPLETE, completed_scene, ) def activate_scene(self, scene: Scene | str) -> None: """Set the active scene and updates the current character visual. If `scene` is a string, a scene query will be performed for a scene with a `scene_id` matching the provided string. This method does not reset scene playback state before activation. To restart a scene from its initial frame sequence, reset the scene before activating it. A SCENE_ACTIVATED event is triggered. Args: scene (Scene | str): Scene instance of Scene ID for the scene that should be activated. Raises: SceneNotFoundError: Raised if the scene_id provided does not correspond to a known scene. """ if isinstance(scene, str): found_scene = self.query_scene(scene) if found_scene is None: raise SceneNotFoundError(scene) else: found_scene = scene self.active_scene = found_scene 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) @typing.overload def deactivate_scene(self) -> None: ... @typing.overload def deactivate_scene(self, scene: Scene) -> None: ... @typing.overload def deactivate_scene(self, scene: str) -> None: ... def deactivate_scene(self, scene: Scene | str | None = None) -> None: """Deactivate a scene if it is currently active. If `scene` is omitted, the current active scene is deactivated if one exists. If `scene` is a string, it is resolved as a scene ID before deactivation. Args: scene (Scene | str | None): Scene to deactivate, its ID, or None to deactivate the current active scene. Raises: SceneNotFoundError: If `scene` is a string and no scene with that ID exists. """ if scene is None: self.active_scene = None return if isinstance(scene, str): found_scene = self.query_scene(scene) if found_scene is None: raise SceneNotFoundError(scene) else: found_scene = scene if self.active_scene and self.active_scene is found_scene: self.active_scene = None terminaltexteffects-release-0.15.0/terminaltexteffects/engine/base_character.py000066400000000000000000000550341517776150200301420ustar00rootroot00000000000000"""Classes used to manage the state of a single character from the input data. Classes: EffectCharacter: A class representing a single character from the input data. EventHandler: A class used to register and handle events related to a character. """ from __future__ import annotations import typing from dataclasses import dataclass from enum import Enum, auto from terminaltexteffects.engine import animation, motion from terminaltexteffects.utils.exceptions import ( DuplicateEventRegistrationError, EventRegistrationCallerError, EventRegistrationTargetError, PathNotFoundError, SceneNotFoundError, ) from terminaltexteffects.utils.geometry import Coord class EventHandler: """Register and handle events related to a character. Events related to character state changes (e.g. scene complete) can be registered with the EventHandler. When an event is triggered, the EventHandler will take the specified action (e.g. activate a Path). The EventHandler is used by the EffectCharacter class to handle events related to the character. Attributes: character (EffectCharacter): The character whose events are handled by this EventHandler. registered_events: Registered event/action mappings keyed by the triggering event and caller object. Note: SEGMENT_ENTERED/EXITED events will trigger the first time the character enters or exits a segment. If looping, each loop will trigger the event, but not backwards motion as is possible with the bounce easing functions. """ def __init__(self, character: EffectCharacter) -> None: """Initialize the instance with the EffectCharacter object. Args: character (EffectCharacter): The character for which the EventHandler is handling events. """ self.character = character self.registered_events: dict[ tuple[EventHandler.Event, animation.Scene | motion.Waypoint | motion.Path], list[ tuple[ EventHandler.Action, animation.Scene | motion.Waypoint | motion.Path | int | Coord | EventHandler.Callback | str | None, ] ], ] = {} class Event(Enum): """An Event that can be registered with the EventHandler. Register Events with the EventHandler using the register_event method of the EventHandler class. Attributes: SEGMENT_ENTERED (Event): A path segment has been entered. SEGMENT_EXITED (Event): A path segment has been exited. PATH_ACTIVATED (Event): A path has been activated. PATH_COMPLETE (Event): A path has been completed. PATH_HOLDING (Event): A path has entered the holding state. SCENE_ACTIVATED (Event): An animation scene has been activated. SCENE_COMPLETE (Event): An animation scene has completed. """ SEGMENT_ENTERED = auto() SEGMENT_EXITED = auto() PATH_ACTIVATED = auto() PATH_COMPLETE = auto() PATH_HOLDING = auto() SCENE_ACTIVATED = auto() SCENE_COMPLETE = auto() class Action(Enum): """Actions that can be taken when an event is triggered. An Action is taken when an Event is triggered. Register Actions with the EventHandler using the register_event method of the EventHandler class. Attributes: ACTIVATE_PATH (Action): Activates a path. The action target is the path itself or its ID. ACTIVATE_SCENE (Action): Activates an animation scene. The action target is the scene itself or its ID. DEACTIVATE_PATH (Action): Deactivates a path. The action target is the path itself, its ID, or None to deactivate the currently active path. DEACTIVATE_SCENE (Action): Deactivates an animation scene. The action target is the scene itself, its ID, or None to deactivate the currently active scene. RESET_APPEARANCE (Action): Resets the appearance of the character to the input symbol and color. SET_LAYER (Action): Sets the layer of the character. The action target is the layer number. SET_COORDINATE (Action): Sets the coordinate of the character. The action target is the coordinate. CALLBACK (Action): Calls a callback function. The action target is an EventHandler.Callback object. """ ACTIVATE_PATH = auto() ACTIVATE_SCENE = auto() DEACTIVATE_PATH = auto() DEACTIVATE_SCENE = auto() RESET_APPEARANCE = auto() SET_LAYER = auto() SET_COORDINATE = auto() CALLBACK = auto() @dataclass(init=False) class Callback: """A callback action target that can be taken when an event is triggered. Register callback actions with the EventHandler using the register_event method of the EventHandler class. The callback function will be called with the character and any additional arguments when the event is triggered. The character will be the first argument passed to the callback function followed by any additional arguments in the order they were passed to the Callback object. Example: Create a callback to set the character's visibility to False. The following code would be used within an effect where 'self' is the EffectIterator instance: cb = EventHandler.Callback(lambda c: self.terminal.set_character_visibility(c, is_visible=False)) """ callback: typing.Callable args: tuple[typing.Any, ...] def __init__(self, callback: typing.Callable, *args: typing.Any) -> None: """Initialize the instance with the callback function and arguments. Args: callback (typing.Callable): The callback function to call. args (tuple[typing.Any,...]): A tuple of arguments to pass to the callback function. The first argument will be the character, followed by any additional arguments. """ self.callback = callback self.args = args @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.ACTIVATE_SCENE], target: animation.Scene | str, ) -> None: ... @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.DEACTIVATE_SCENE], target: animation.Scene | str | None = ..., ) -> None: ... @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.ACTIVATE_PATH], target: motion.Path | str, ) -> None: ... @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.DEACTIVATE_PATH], target: motion.Path | str | None = ..., ) -> None: ... @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.SET_COORDINATE], target: Coord, ) -> None: ... @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.SET_LAYER], target: int, ) -> None: ... @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.CALLBACK], target: Callback, ) -> None: ... @typing.overload def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: typing.Literal[Action.RESET_APPEARANCE], target: None = None, ) -> None: ... def register_event( self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path | str, action: Action, target: animation.Scene | motion.Path | int | Coord | Callback | str | None = None, ) -> None: """Register an event to be handled by the EventHandler. Note: Action.RESET_APPEARANCE does not accept a target. For `PATH_ACTIVATED`, `PATH_COMPLETE`, `SCENE_ACTIVATED`, and `SCENE_COMPLETE`, `caller` may be the triggering object itself or its registered ID string. `PATH_HOLDING` and segment events require their corresponding object instances. For `ACTIVATE_PATH`, `DEACTIVATE_PATH`, `ACTIVATE_SCENE`, and `DEACTIVATE_SCENE`, string targets are resolved from registered path or scene IDs. `DEACTIVATE_PATH`, `DEACTIVATE_SCENE`, and `RESET_APPEARANCE` may use `None` where supported by the action. Args: event (Event): The event to register. caller (animation.Scene | motion.Waypoint | motion.Path | str): The object that triggers the event. action (Action): The action to take when the event is triggered. target (animation.Scene | motion.Path | int | Coord | Callback | str | None): The target of the action. Raises: EventRegistrationCallerError: If the caller object is not the required type for the specified event. EventRegistrationTargetError: If the target is not the correct type for the action or if ``Action.RESET_APPEARANCE`` is provided with a target. DuplicateEventRegistrationError: If the exact same event-caller-action-target combination has already been registered. Example: Register an event to activate a scene when a Path is complete: `event_handler.register_event(EventHandler.Event.PATH_COMPLETE, some_path, EventHandler.Action.ACTIVATE_SCENE, some_scene)` """ event_caller_map = { EventHandler.Event.SEGMENT_ENTERED: motion.Waypoint, EventHandler.Event.SEGMENT_EXITED: motion.Waypoint, EventHandler.Event.PATH_ACTIVATED: motion.Path, EventHandler.Event.PATH_COMPLETE: motion.Path, EventHandler.Event.PATH_HOLDING: motion.Path, EventHandler.Event.SCENE_ACTIVATED: animation.Scene, EventHandler.Event.SCENE_COMPLETE: animation.Scene, } action_target_map = { EventHandler.Action.ACTIVATE_PATH: motion.Path, EventHandler.Action.ACTIVATE_SCENE: animation.Scene, EventHandler.Action.DEACTIVATE_PATH: motion.Path, EventHandler.Action.DEACTIVATE_SCENE: animation.Scene, EventHandler.Action.RESET_APPEARANCE: type(None), EventHandler.Action.SET_LAYER: int, EventHandler.Action.SET_COORDINATE: Coord, EventHandler.Action.CALLBACK: EventHandler.Callback, } # find caller Path when provided path_id if event in (EventHandler.Event.PATH_ACTIVATED, EventHandler.Event.PATH_COMPLETE) and isinstance(caller, str): if (path_query_result := self.character.motion.query_path(caller)) is None: raise PathNotFoundError(path_id=caller) caller = path_query_result # find caller Scene when provided scene_id elif event in (EventHandler.Event.SCENE_ACTIVATED, EventHandler.Event.SCENE_COMPLETE) and isinstance( caller, str, ): if (scene_query_result := self.character.animation.query_scene(caller)) is None: raise SceneNotFoundError(scene_id=caller) caller = scene_query_result if event_caller_map[event] != caller.__class__: raise EventRegistrationCallerError(event, caller, event_caller_map[event]) # find target Path when provided path_id if action in (EventHandler.Action.ACTIVATE_PATH, EventHandler.Action.DEACTIVATE_PATH) and isinstance( target, str, ): if (path_query_result := self.character.motion.query_path(target)) is None: raise PathNotFoundError(path_id=target) target = path_query_result # find target Scene when provided scene_id elif action in (EventHandler.Action.ACTIVATE_SCENE, EventHandler.Action.DEACTIVATE_SCENE) and isinstance( target, str, ): if (scene_query_result := self.character.animation.query_scene(target)) is None: raise SceneNotFoundError(scene_id=target) target = scene_query_result elif action in (EventHandler.Action.DEACTIVATE_PATH, EventHandler.Action.DEACTIVATE_SCENE) and target is None: pass elif (action is EventHandler.Action.RESET_APPEARANCE and target is not None) or ( action_target_map[action] != target.__class__ ): raise EventRegistrationTargetError(action, target, action_target_map[action]) assert isinstance(caller, (motion.Path, animation.Scene, motion.Waypoint)) new_event = (event, caller) new_action = (action, target) if new_event not in self.registered_events: self.registered_events[new_event] = [] # Check for duplicate event-action-target combination if new_action in self.registered_events[new_event]: raise DuplicateEventRegistrationError(event, caller, action, target) self.registered_events[new_event].append(new_action) def _handle_event(self, event: Event, caller: animation.Scene | motion.Waypoint | motion.Path) -> None: """Handle a registered event by executing all associated actions. This method processes an event triggered by a caller object (Scene, Waypoint, or Path) and executes all actions that were registered for this specific event-caller combination. If no actions are registered for the given event and caller, the method returns without doing anything. The method supports the following action types: - ACTIVATE_PATH: Activates a motion path for the character - ACTIVATE_SCENE: Activates an animation scene for the character - DEACTIVATE_PATH: Deactivates a specific motion path or, when the target is `None`, the currently active path - DEACTIVATE_SCENE: Deactivates a specific animation scene or, when the target is `None`, the currently active scene - RESET_APPEARANCE: Resets the character's appearance to its input symbol and, where applicable, its configured input colors - SET_LAYER: Sets the character's rendering layer - SET_COORDINATE: Sets the character's current coordinate - CALLBACK: Executes a custom callback function with the character and additional arguments Args: event (Event): The event to handle. Must be one of the Event enum values. caller (animation.Scene | motion.Waypoint | motion.Path): The object that triggered the event. The caller type must match the expected type for the given event (e.g., PATH_COMPLETE events must be triggered by motion.Path objects). Note: This method is typically called internally by the animation and motion systems when specific state changes occur. Events and their associated actions are registered using the register_event method. Example: When `Event.PATH_COMPLETE` is triggered for a registered path caller, all actions registered for that event/caller pair are executed in order. """ action_map = { EventHandler.Action.ACTIVATE_PATH: self.character.motion.activate_path, EventHandler.Action.ACTIVATE_SCENE: self.character.animation.activate_scene, EventHandler.Action.DEACTIVATE_PATH: self.character.motion.deactivate_path, EventHandler.Action.DEACTIVATE_SCENE: self.character.animation.deactivate_scene, EventHandler.Action.RESET_APPEARANCE: lambda _: self.character.animation.set_appearance( self.character.input_symbol, ), EventHandler.Action.SET_LAYER: lambda layer: setattr(self.character, "layer", layer), EventHandler.Action.SET_COORDINATE: lambda coord: setattr(self.character.motion, "current_coord", coord), EventHandler.Action.CALLBACK: lambda callback: callback.callback(self.character, *callback.args), } if (event, caller) not in self.registered_events: return for event_action in self.registered_events[(event, caller)]: action, target = event_action action_map[action](target) # type: ignore[operator] class EffectCharacter: """A class representing a single character from the input data. EffectCharacters are created and managed by the Terminal and are used to apply animations and effects to individual characters. Additional non-input characters can be created with `Terminal.add_character()`. Methods: tick: Progress the character's animation and motion by one step. Attributes: character_id (int): The unique ID of the character, generated by the Terminal. input_symbol (str): The symbol for the character in the input data. input_coord (Coord): The coordinate of the character in the input data. is_visible (bool): Whether the character is currently visible and should be printed to the terminal. animation (animation.Animation): The animation object that controls the character's appearance. motion (motion.Motion): The motion object that controls the character's movement. event_handler (EventHandler): The event handler object that handles events related to the character. layer (int): The layer of the character. The layer determines the order in which characters are printed. is_fill_character (bool): Whether the character is a fill character. Fill characters are used to fill the empty cells of the Canvas. uses_input_preexisting_colors (bool): Whether engine-level `existing_color_handling="always"` should treat this character as input-derived and therefore replace effect-owned colors with the parsed input fg/bg pair, even when that pair is empty. links (set[EffectCharacter]): Linked neighboring characters used by spanning-tree algorithms. neighbors (dict[str, EffectCharacter | None]): Adjacent characters keyed by direction (`"north"`, `"east"`, `"south"`, `"west"`). """ def __init__(self, character_id: int, symbol: str, input_column: int, input_row: int) -> None: """Initialize the character instance with the character ID, symbol, and input coordinates. Args: character_id (int): The unique ID of the character, generated by the Terminal. symbol (str): The symbol for the character in the input data. input_column (int): The column of the character in the input data. input_row (int): The row of the character in the input data. """ self._character_id: int = character_id self._input_symbol: str = symbol self._input_coord: Coord = Coord(input_column, input_row) self._input_ansi_sequences: dict[str, str | None] = {"fg_color": None, "bg_color": None} self._is_visible: bool = False self.animation: animation.Animation = animation.Animation(self) self.motion: motion.Motion = motion.Motion(self) self.event_handler: EventHandler = EventHandler(self) self.layer: int = 0 self.is_fill_character = False self.uses_input_preexisting_colors = False self.links: set[EffectCharacter] = set() self.neighbors: dict[str, EffectCharacter | None] = {} @property def input_symbol(self) -> str: """The symbol for the character in the input data.""" return self._input_symbol @property def input_coord(self) -> Coord: """The coordinate of the character in the input data.""" return self._input_coord @property def is_visible(self) -> bool: """Whether the character is currently visible and should be printed to the terminal.""" return self._is_visible @property def character_id(self) -> int: """The unique ID of the character, generated by the Terminal.""" return self._character_id @property def is_active(self) -> bool: """Returns whether the character is currently active. A character is active when its animation has an incomplete active scene or its motion has an incomplete active path. Returns: bool: True if the character is active, False if not. """ return bool(not self.animation.active_scene_is_complete() or not self.motion.movement_is_complete()) def tick(self) -> None: """Progress the character by one tick. Motion is advanced first, then animation is stepped so animation logic can react to the character's updated motion state for the same tick. """ self.motion.move() self.animation.step_animation() def _link(self, char: EffectCharacter, *, bidirectional: bool = True) -> None: """Link this character with another character. Used for spanning-tree algorithms. When `bidirectional` is True, the reverse link is added to `char` as well. Links are stored in a set, so repeated links are idempotent. Args: char (EffectCharacter): Character being linked to this character. bidirectional (bool, optional): Apply the link on both characters. Defaults to True. """ if bidirectional: char._link(self, bidirectional=False) self.links.add(char) def __hash__(self) -> int: """Return the hash value of the character.""" return hash(self.character_id) def __eq__(self, other: object) -> bool: """Check if two EffectCharacter instances are equal based on their character_id.""" if not isinstance(other, EffectCharacter): return NotImplemented return self.character_id == other.character_id def __repr__(self) -> str: """Return a string representation of the EffectCharacter instance.""" return ( f"EffectCharacter(character_id={self.character_id}, symbol='{self.input_symbol}', " f"input_column={self.input_coord.column}, input_row={self.input_coord.row})" ) terminaltexteffects-release-0.15.0/terminaltexteffects/engine/base_config.py000066400000000000000000000145171517776150200274540ustar00rootroot00000000000000"""Base effect configuration classes. `BaseConfig` works with `argutils.ArgSpec` field defaults to define effect configuration options and populate an `argparse.ArgumentParser`. `BaseConfig._build_config` constructs config instances either from a parsed `argparse.Namespace` or from the default values stored on each field's `ArgSpec`. """ from __future__ import annotations import argparse import typing from dataclasses import dataclass, fields from terminaltexteffects.utils import argutils from terminaltexteffects.utils.graphics import Color, Gradient @dataclass(frozen=True) class FinalGradientDirectionArg(argutils.ArgSpec): """Argument specification for selecting the final text gradient direction.""" name: str = "--final-gradient-direction" type: typing.Callable[[str], Gradient.Direction] = argutils.GradientDirection.type_parser default: Gradient.Direction = Gradient.Direction.VERTICAL metavar: str = argutils.GradientDirection.METAVAR help: str = "Direction of the final gradient across the text." @dataclass(frozen=True) class FinalGradientStopsArg(argutils.ArgSpec): """Argument specification for selecting the final text gradient stops.""" name: str = "--final-gradient-stops" type: typing.Callable[[str], Color] = argutils.ColorArg.type_parser nargs: str = "+" action: type[argutils.TupleAction] = argutils.TupleAction default: tuple[Color, ...] = (Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")) metavar: str = argutils.ColorArg.METAVAR help: str = ( "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." ) @dataclass(frozen=True) class FinalGradientStepsArg(argutils.ArgSpec): """Argument specification for selecting the final text gradient steps.""" name: str = "--final-gradient-steps" type: typing.Callable[[str], int] = argutils.PositiveInt.type_parser nargs: str = "+" action: type[argutils.TupleAction] = argutils.TupleAction default: tuple[int, ...] | int = 12 metavar: str = argutils.PositiveInt.METAVAR help: str = ( "Space separated, unquoted, list of the number of gradient steps to use. " "More steps will create a smoother and longer gradient animation." ) @dataclass(frozen=True) class FinalGradientFramesArg(argutils.ArgSpec): """Argument specification for selecting the final text gradient frames.""" name: str = "--final-gradient-frames" type: typing.Callable[[str], int] = argutils.PositiveInt.type_parser default: int = 5 metavar: str = argutils.PositiveInt.METAVAR help: str = "Number of frames to display each gradient step. Increase to slow down the gradient animation." @dataclass class BaseConfig: """Base configuration class for effects. This class serves as a base for all effect configurations, providing a common interface for argument parser population and configuration building. Effect config classes are created via `_build_config`, which reads values from a parsed `argparse.Namespace` and falls back to `argutils.ArgSpec` defaults for fields defined with `ArgSpec` instances. Any config class intended to be used to populate a subparser must define a `parser_spec` attribute with type `argutils.ParserSpec`. """ @classmethod def _populate_parser(cls, parser: argparse.ArgumentParser | argparse._SubParsersAction) -> None: """Populate the argument parser with the config class's argument specs. If `parser` is a subparser collection, `cls.parser_spec` is used to create the subparser before adding field argument specs. Config classes used in that mode must define `parser_spec`. Raises: AttributeError: If `parser` is a subparser collection and `cls.parser_spec` is not defined. """ if isinstance(parser, argparse._SubParsersAction): parser = parser.add_parser(**vars(cls.parser_spec)) # pyright: ignore[reportAttributeAccessIssue] parser.formatter_class = argutils.CustomFormatter # pyright: ignore[reportAttributeAccessIssue] assert isinstance(parser, argparse.ArgumentParser) for field in fields(cls): if field.name == "parser_spec": continue spec = field.default assert isinstance(spec, argutils.ArgSpec) add_args_sig = {k: v for k, v in vars(spec).items() if v is not argutils._MISSING} parser.add_argument(add_args_sig.pop("name"), **add_args_sig) @classmethod def _build_config(cls: type[CONFIG], parsed_args: argparse.Namespace | None = None) -> CONFIG: """Build a config instance from parsed arguments or `ArgSpec` defaults. When `parsed_args` is provided, matching namespace attributes are used first and missing fields fall back to each field's `ArgSpec.default` when available. When `parsed_args` is `None`, the config is built entirely from `ArgSpec.default` values. Args: parsed_args (argparse.Namespace | None): Parsed CLI arguments, or None to use `ArgSpec` defaults only. Raises: AttributeError: If a required config field is missing from `parsed_args` and does not define an `ArgSpec` default. Returns: CONFIG: A populated config instance. """ if parsed_args is not None: config_args: dict[str, typing.Any] = {} for field in fields(cls): if field.name == "parser_spec": continue if hasattr(parsed_args, field.name): config_args[field.name] = getattr(parsed_args, field.name) elif isinstance(field.default, argutils.ArgSpec): config_args[field.name] = field.default.default else: msg = f"Missing required config field '{field.name}' for {cls.__name__} in parsed arguments." raise AttributeError(msg) return cls(**config_args) return cls( **{ field.name: field.default.default for field in fields(cls) if isinstance(field.default, argutils.ArgSpec) }, ) CONFIG = typing.TypeVar("CONFIG", bound=BaseConfig) terminaltexteffects-release-0.15.0/terminaltexteffects/engine/base_effect.py000066400000000000000000000155131517776150200274400ustar00rootroot00000000000000"""Base classes for all effects. Base classes from which all effects should inherit. These classes define the basic structure for an effect and establish the effect iterator interface as well as the effect configuration and terminal configuration. Classes: BaseEffectIterator(Generic[T]): An abstract base class that defines the basic structure for an iterator that applies a certain effect to the input data. Provides initialization for the effect configuration and terminal as well as the `__iter__` method. BaseEffect(Generic[T]): An abstract base class that defines the basic structure for an effect. Provides the `__iter__` method and a context manager for terminal output. """ from __future__ import annotations from abc import ABC, abstractmethod from contextlib import contextmanager from copy import deepcopy from typing import TYPE_CHECKING, Generic, TypeVar from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.engine.terminal import Terminal, TerminalConfig if TYPE_CHECKING: from collections.abc import Generator from terminaltexteffects.engine.base_character import EffectCharacter T = TypeVar("T", bound=BaseConfig) class BaseEffectIterator(ABC, Generic[T]): """Base iterator class for all effects. Args: effect (BaseEffect): Effect to apply to the input data. Attributes: config (T): Configuration for the effect. terminal (Terminal): Terminal to use for output. active_characters (set[EffectCharacter]): Set of active characters in the effect. preexisting_colors_present (bool): Whether any terminal input characters were initialized with parsed foreground or background input colors. Properties: frame (str): Current frame of the effect. Methods: update: Run the tick method for all active characters and remove inactive characters from the active list. __iter__: Return the iterator object. __next__: Return the next frame of the effect. """ def __init__(self, effect: BaseEffect) -> None: """Initialize the iterator with the Effect. Args: effect (BaseEffect): Effect to apply to the input data. """ self.config: T = deepcopy(effect.effect_config) self.terminal = Terminal(effect.input_data, deepcopy(effect.terminal_config)) self.active_characters: set[EffectCharacter] = set() self.preexisting_colors_present: bool = any( any((character.animation.input_fg_color, character.animation.input_bg_color)) for character in self.terminal.get_characters() ) @property def frame(self) -> str: """Return the current formatted frame from the terminal. If the configured terminal frame rate is greater than `0`, enforce the frame rate before reading the formatted output string. This property does not advance effect state on its own. Returns: str: Current frame of the effect. """ if self.terminal._frame_rate: self.terminal.enforce_framerate() return self.terminal.get_formatted_output_string() def update(self) -> None: """Run one tick for each active character and prune inactive characters. Each character in `active_characters` is ticked once. After all ticks complete, characters whose `is_active` flag is false are removed from the set. """ for character in self.active_characters: character.tick() self.active_characters -= {character for character in self.active_characters if not character.is_active} def __iter__(self) -> BaseEffectIterator: """Return this iterator instance. Returns: BaseEffectIterator: This iterator. """ return self @abstractmethod def __next__(self) -> str: """Return the next frame of the effect. Perform any necessary updates to the effect to progress the effect logic and return the next frame. Raises: NotImplementedError: This method must be implemented by the subclass. Returns: str: Next frame of the effect. """ class BaseEffect(ABC, Generic[T]): """Base iterable class for all effects. Base class for all effects. Provides the `__iter__` method and a context manager for terminal output. Attributes: input_data (str): Text to which the effect will be applied. effect_config (T): Configuration for the effect. terminal_config (TerminalConfig): Configuration for the terminal. """ @property @abstractmethod def _config_cls(self) -> type[T]: """Effect configuration class as a subclass of ArgsDataClass.""" @property @abstractmethod def _iterator_cls(self) -> type[BaseEffectIterator]: """Effect iterator class as a subclass of BaseEffectIterator.""" def __init__( self, input_data: str, effect_config: T | None = None, terminal_config: TerminalConfig | None = None, ) -> None: """Initialize the effect with the input data. Args: input_data (str): Text to which the effect will be applied. effect_config (BaseConfig | None, optional): Effect configuration. If not provided, a new configuration will be built with default values. Defaults to None. terminal_config (TerminalConfig | None, optional): Terminal configuration. If not provided, a new configuration will be built with default values. Defaults to None. """ self.input_data = input_data self.effect_config: T = effect_config or self._config_cls._build_config() self.terminal_config: TerminalConfig = terminal_config or TerminalConfig._build_config() def __iter__(self) -> BaseEffectIterator: """Create and return a new iterator for the effect. Returns: BaseEffectIterator: A new iterator instance for this effect. """ return self._iterator_cls(self) @contextmanager def terminal_output(self, end_symbol: str = "\n") -> Generator[Terminal, None, None]: """Context manager for terminal output. Prepares the terminal for output and restores it after. Args: end_symbol (str, optional): Symbol to print after the effect has completed. Defaults to newline. Yields: Terminal: Terminal object for handling output. Raises: Exception: Any exception that occurs within the context manager is re-raised after the terminal state is restored. """ terminal = Terminal(self.input_data, self.terminal_config) try: terminal.prep_canvas() yield terminal finally: terminal.restore_cursor(end_symbol) terminaltexteffects-release-0.15.0/terminaltexteffects/engine/motion.py000066400000000000000000000612061517776150200265170ustar00rootroot00000000000000"""Classes and methods for managing and manipulating character motion. Classes: Waypoint: A Waypoint comprises an identifier, a coordinate, and, optionally, bezier control point(s). Segment: A segment of a path consisting of two waypoints and the distance between them. Path: Represents a path consisting of multiple waypoints for motion. Motion: Motion class for managing the movement of a character. """ from __future__ import annotations import typing from dataclasses import dataclass from terminaltexteffects.utils import easing, geometry from terminaltexteffects.utils.exceptions import ( ActivateEmptyPathError, DuplicatePathIDError, DuplicateWaypointIDError, PathInvalidSpeedError, PathNotFoundError, WaypointNotFoundError, ) from terminaltexteffects.utils.geometry import Coord if typing.TYPE_CHECKING: from terminaltexteffects.engine import base_character # pragma: no cover @dataclass(frozen=True) class Waypoint: """A Waypoint comprises an identifier, a coordinate, and, optionally, bezier control point(s). Attributes: waypoint_id (str): Unique identifier for the waypoint. coord (Coord): Coordinate of the waypoint. bezier_control (tuple[Coord, ...] | None): Optional bezier control point(s). Defaults to None. """ waypoint_id: str coord: Coord bezier_control: tuple[Coord, ...] | None = None @dataclass class Segment: """A segment of a path consisting of two waypoints and the distance between them. Segments are created by the Path class. The start waypoint is the end waypoint of the previous segment or the origin waypoint. Attributes: start (Waypoint): start waypoint end (Waypoint): end waypoint distance (float): distance between the start and end waypoints """ start: Waypoint end: Waypoint distance: float def __post_init__(self) -> None: """Initialize additional attributes for the Segment class.""" self.enter_event_triggered: bool = False self.exit_event_triggered: bool = False def __eq__(self, other: object) -> bool: """Check if two Segment objects are equal. Segments are equal if their start and end waypoints are equal. """ if not isinstance(other, Segment): return NotImplemented return self.start == other.start and self.end == other.end def __hash__(self) -> int: """Return the hash value of the Segment. Hash is calculated using a tuple of the start and end waypoints. """ return hash((self.start, self.end)) @dataclass class Path: """Represents a path consisting of multiple waypoints for motion. Attributes: path_id (str): The unique identifier for the path. speed (float): Path speed; must be greater than 0. ease (easing.EasingFunction | None): Easing function applied across path traversal. layer (int | None): Character layer to apply when the path is activated. If None, the layer is unchanged. hold_time (int): Number of frames to remain at the end of the path before completion. loop (bool): Whether the path should restart after completion. segments (list[Segment]): Ordered segments traversed by the path. waypoints (list[Waypoint]): Ordered waypoints in the path. waypoint_lookup (dict[str, Waypoint]): Waypoints indexed by `waypoint_id`. total_distance (float): Total travel distance across all segments, including the origin segment when active. current_step (int): Current traversal step count. max_steps (int): Total number of traversal steps, derived from `total_distance / speed`. hold_time_remaining (int): Remaining hold frames once the path reaches its end. last_distance_reached (float): Most recent eased or linear distance traveled along the active path. origin_segment (Segment | None): Temporary segment from the current coordinate to the first waypoint, set on activation. Methods: new_waypoint: Creates a new Waypoint and appends adds it to the Path. query_waypoint: Returns the waypoint with the given waypoint_id. step: Progresses to the next step along the path and returns the coordinate at that step. """ path_id: str speed: float = 1.0 ease: easing.EasingFunction | None = None layer: int | None = None hold_time: int = 0 loop: bool = False def __post_init__(self) -> None: """Initialize the Path object and calculates the total distance and maximum steps.""" self.segments: list[Segment] = [] self.waypoints: list[Waypoint] = [] self.waypoint_lookup: dict[str, Waypoint] = {} self.total_distance: float = 0 self.current_step: int = 0 self.max_steps: int = 0 self.hold_time_remaining = self.hold_time self.last_distance_reached: float = 0 # used for animation syncing to distance self.origin_segment: Segment | None = None if self.speed <= 0: raise PathInvalidSpeedError(self.speed) def new_waypoint( self, coord: Coord, *, bezier_control: tuple[Coord, ...] | Coord | None = None, waypoint_id: str = "", ) -> Waypoint: """Create a new Waypoint and appends adds it to the Path. Args: waypoint_id (str): Unique identifier for the waypoint. Used to query for the waypoint. coord (Coord): coordinate bezier_control (tuple[Coord, ...] | Coord | None): Optional bezier control point(s) for the segment ending at this waypoint. A single Coord is normalized to a one-item tuple. Defaults to None. Returns: Waypoint: The new waypoint. """ if not waypoint_id: found_unique = False current_id = len(self.waypoints) while not found_unique: waypoint_id = f"{current_id}" if waypoint_id not in self.waypoint_lookup: found_unique = True else: current_id += 1 if waypoint_id in self.waypoint_lookup: raise DuplicateWaypointIDError(waypoint_id) bezier_control_tuple: tuple[Coord, ...] | None if bezier_control and isinstance(bezier_control, Coord): bezier_control_tuple = (bezier_control,) elif bezier_control and isinstance(bezier_control, tuple): bezier_control_tuple = bezier_control else: bezier_control_tuple = None new_waypoint = Waypoint(waypoint_id, coord, bezier_control=bezier_control_tuple) self._add_waypoint_to_path(new_waypoint) return new_waypoint def _add_waypoint_to_path(self, waypoint: Waypoint) -> None: """Add a waypoint to the path and update the total distance and maximum steps. Args: waypoint (Waypoint): waypoint to add """ self.waypoint_lookup[waypoint.waypoint_id] = waypoint self.waypoints.append(waypoint) if len(self.waypoints) < 2: return if waypoint.bezier_control: distance_from_previous = geometry.find_length_of_bezier_curve( self.waypoints[-2].coord, waypoint.bezier_control, waypoint.coord, ) else: distance_from_previous = geometry.find_length_of_line( self.waypoints[-2].coord, waypoint.coord, double_row_diff=True, ) self.total_distance += distance_from_previous self.segments.append(Segment(self.waypoints[-2], waypoint, distance_from_previous)) self.max_steps = round(self.total_distance / self.speed) def query_waypoint(self, waypoint_id: str) -> Waypoint: """Return the waypoint with the given waypoint_id. Args: waypoint_id (str): waypoint_id Returns: Waypoint: The waypoint with the given waypoint_id. """ waypoint = self.waypoint_lookup.get(waypoint_id, None) if not waypoint: raise WaypointNotFoundError(waypoint_id) return waypoint def step(self, event_handler: base_character.EventHandler) -> Coord: """Progresses to the next step along the path and returns the coordinate at that step. This method is called by the Motion.move() method. It calculates the next coordinate based on the current step, total distance, bezier control points, and the easing function if provided. It also handles the triggering of segment enter and exit events. Args: event_handler (base_character.EventHandler): The EventHandler for the character. Returns: Coord: The next coordinate on the path. """ if not self.max_steps or self.current_step >= self.max_steps or not self.total_distance: # if the path has zero distance or there are no more steps, return the final waypoint coordinate return self.segments[-1].end.coord self.current_step += 1 if self.ease: distance_factor = self.ease(self.current_step / self.max_steps) else: distance_factor = self.current_step / self.max_steps distance_to_travel = distance_factor * self.total_distance self.last_distance_reached = distance_to_travel for segment in self.segments: if distance_to_travel <= segment.distance: active_segment = segment if not segment.enter_event_triggered: segment.enter_event_triggered = True event_handler._handle_event(event_handler.Event.SEGMENT_ENTERED, segment.end) break distance_to_travel -= segment.distance if not segment.exit_event_triggered: segment.exit_event_triggered = True event_handler._handle_event(event_handler.Event.SEGMENT_EXITED, segment.end) # if the distance_to_travel is further than the last waypoint, # preserve the distance from the start of the final segment else: active_segment = self.segments[-1] distance_to_travel += active_segment.distance if active_segment.distance == 0: segment_distance_to_travel_factor = 0.0 elif self.ease: segment_distance_to_travel_factor = distance_to_travel / active_segment.distance else: segment_distance_to_travel_factor = min((distance_to_travel / active_segment.distance, 1)) if active_segment.end.bezier_control: next_coord = geometry.find_coord_on_bezier_curve( active_segment.start.coord, active_segment.end.bezier_control, active_segment.end.coord, segment_distance_to_travel_factor, ) else: next_coord = geometry.find_coord_on_line( active_segment.start.coord, active_segment.end.coord, segment_distance_to_travel_factor, ) return next_coord def __eq__(self, other: object) -> bool: """Check if two Path objects are equal. Args: other (object): object to compare Returns: bool: True if the two Path objects are equal, False otherwise. """ if not isinstance(other, Path): return NotImplemented return self.path_id == other.path_id def __hash__(self) -> int: """Return the hash value of the Path. Hash is calculated using the path_id. """ return hash(self.path_id) class Motion: """Motion class for managing the movement of a character. Attributes: paths (dict[str, Path]): dictionary of paths character (base_character.EffectCharacter): The EffectCharacter to move. current_coord (Coord): current coordinate previous_coord (Coord): previous coordinate active_path (Path | None): active path Methods: set_coordinate: Sets the current coordinate to the given coordinate. new_path: Creates a new Path and adds it to the Motion.paths dictionary with the path_id as key. query_path: Returns the path with the given path_id. movement_is_complete: Returns whether the character has an active path. chain_paths: Creates a chain of paths by registering activation events for each path such that paths[n] activates paths[n+1] when reached. If loop is True, paths[-1] activates paths[0] when reached. activate_path: Activates the first waypoint in the path. deactivate_path: Unsets the current path if the current path is path. move: Moves the character one step closer to the target position based on an easing function if present, otherwise linearly. """ def __init__(self, character: base_character.EffectCharacter) -> None: """Initialize the Motion object with the given EffectCharacter. Args: character (base_character.EffectCharacter): The EffectCharacter to move. """ self.paths: dict[str, Path] = {} self.character = character self.current_coord: Coord = Coord(character.input_coord.column, character.input_coord.row) self.previous_coord: Coord = Coord(-1, -1) self.active_path: Path | None = None def set_coordinate(self, coord: Coord) -> None: """Set the current coordinate to the given coordinate. Args: coord (Coord): coordinate """ self.current_coord = coord def new_path( self, *, speed: float = 1, ease: easing.EasingFunction | None = None, layer: int | None = None, hold_time: int = 0, loop: bool = False, path_id: str = "", ) -> Path: """Create a new Path and add it to the Motion.paths dictionary with the path_id as key. Args: speed (float, optional): speed > 0. Defaults to 1. ease (easing.EasingFunction | None, optional): easing function for character movement. Defaults to None. layer (int | None, optional): layer to move the character to, if None, layer is unchanged. Defaults to None. hold_time (int, optional): number of frames to hold the character at the end of the path. Defaults to 0. loop (bool, optional): Whether the path should loop back to the beginning. Default is False. path_id (str, optional): Unique identifier for the path. Used to query for the path. Defaults to "". Raises: DuplicatePathIDError: If a path with the provided id already exists. PathInvalidSpeedError: If `speed` is less than or equal to 0. Returns: Path: The new path. """ if not path_id: found_unique = False current_id = len(self.paths) while not found_unique: path_id = f"{current_id}" if path_id not in self.paths: found_unique = True else: current_id += 1 if path_id in self.paths: raise DuplicatePathIDError(path_id) new_path = Path(path_id, speed, ease, layer, hold_time, loop) self.paths[path_id] = new_path return new_path @typing.overload def query_path(self, path_id: str) -> Path: ... @typing.overload def query_path(self, path_id: str, not_found_action: typing.Literal["raise"]) -> Path: ... @typing.overload def query_path(self, path_id: str, not_found_action: None) -> Path | None: ... def query_path(self, path_id: str, not_found_action: typing.Literal["raise"] | None = "raise") -> Path | None: """Return the path with the given path_id, or None if no path with the given ID exists. Args: path_id (str): path_id not_found_action (Literal["raise"] | None, optional): Action to take if a path with the given path_id is not found. If "raise", a PathNotFoundError will be raised. If `None`, method will return `None`. Returns: Path | None: The path with the given path_id, or None. Raises: PathNotFoundError: If `not_found_action` is "raise" and a Path with the given `path_id` is not found. """ found_path = self.paths.get(path_id, None) if not_found_action and found_path is None: raise PathNotFoundError(path_id) return found_path def movement_is_complete(self) -> bool: """Return whether the character has an active path. Returns: bool: True if the character has no active path, False otherwise. """ return self.active_path is None def chain_paths(self, paths: list[Path], *, loop: bool = False) -> None: """Create a chain of paths. Paths are chained by registering activation events for each path such that paths[n] activates paths[n+1] when reached. If loop is True, paths[-1] activates paths[0] when reached. Args: paths (list[Path]): list of paths to chain loop (bool, optional): Whether the chain should loop. Defaults to False. """ if len(paths) < 2: return for i, path in enumerate(paths): if i == 0: continue self.character.event_handler.register_event( self.character.event_handler.Event.PATH_COMPLETE, paths[i - 1], self.character.event_handler.Action.ACTIVATE_PATH, path, ) if loop: self.character.event_handler.register_event( self.character.event_handler.Event.PATH_COMPLETE, paths[-1], self.character.event_handler.Action.ACTIVATE_PATH, paths[0], ) def activate_path(self, path: Path | str) -> None: """Activates the first waypoint in the given path and updates the path's properties accordingly. If the provided `path` arg is not a `Path` object, it must be a `path_id` string. This method sets the active path to the given path and mutates the Path to reflect the character's current starting position. It calculates the distance to the first waypoint and updates the total distance of the path. If the path has an origin segment, it removes it from the segments list and subtracts its distance from the total distance. Then, it creates a new origin segment from the current coordinate to the first waypoint and inserts it at the beginning of the segments list. The method also resets the current step, hold time remaining, and max steps of the path based on the total distance and speed. It ensures that the enter and exit events for each segment are not triggered. If the path has a layer, it sets the character's layer to it. Finally, it triggers the PATH_ACTIVATED event for the character. Args: path (Path | str): The path to activate. Must be the Path itself, or the Path's ID. """ if isinstance(path, str): found_path = self.query_path(path) if found_path is None: raise PathNotFoundError(path) else: found_path = path if not found_path.waypoints: raise ActivateEmptyPathError(found_path.path_id) self.active_path = found_path first_waypoint = self.active_path.waypoints[0] if first_waypoint.bezier_control: distance_to_first_waypoint = geometry.find_length_of_bezier_curve( self.current_coord, first_waypoint.bezier_control, first_waypoint.coord, ) else: distance_to_first_waypoint = geometry.find_length_of_line( self.current_coord, first_waypoint.coord, double_row_diff=True, ) self.active_path.total_distance += distance_to_first_waypoint if self.active_path.origin_segment: self.active_path.segments.pop(0) self.active_path.total_distance -= self.active_path.origin_segment.distance self.active_path.origin_segment = Segment( Waypoint("origin", self.current_coord), first_waypoint, distance_to_first_waypoint, ) self.active_path.segments.insert(0, self.active_path.origin_segment) self.active_path.current_step = 0 self.active_path.hold_time_remaining = self.active_path.hold_time self.active_path.max_steps = round(self.active_path.total_distance / self.active_path.speed) for segment in self.active_path.segments: segment.enter_event_triggered = False segment.exit_event_triggered = False if self.active_path.layer is not None: self.character.layer = self.active_path.layer self.character.event_handler._handle_event(self.character.event_handler.Event.PATH_ACTIVATED, self.active_path) @typing.overload def deactivate_path(self) -> None: ... @typing.overload def deactivate_path(self, path: Path) -> None: ... @typing.overload def deactivate_path(self, path: str) -> None: ... def deactivate_path(self, path: Path | str | None = None) -> None: """Deactivate a path if it is currently active. If `path` is omitted, the current active path is deactivated if one exists. If `path` is a string, it is resolved as a path ID before deactivation. Args: path (Path | str | None): Path to deactivate, its ID, or None to deactivate the current active path. Raises: PathNotFoundError: If `path` is a string and no path with that ID exists. """ if path is None: self.active_path = None return if isinstance(path, str): found_path = self.query_path(path) if found_path is None: raise PathNotFoundError(path) else: found_path = path if self.active_path and self.active_path is found_path: self.active_path = None def move(self) -> None: """Move the character along the active path. The character's current coordinate is updated to the next step in the active path. If the active path is completed, an event is triggered based on whether the path is set to loop or not. Looping paths are reactivated from the current coordinate when they contain more than one segment. Otherwise, the path is deactivated and a PATH_COMPLETE event is triggered. If the path has a hold time, the character will pause at the end of the path for the specified duration. During this hold time, a PATH_HOLDING event is triggered on the first frame, and the hold time is decremented on each subsequent frame until it reaches zero. If there is no active path or if the active path has no segments, the character does not move. The character's previous coordinate is preserved before moving to allow for clearing the location in the terminal. """ # preserve previous coordinate to allow for clearing the location in the terminal self.previous_coord = Coord(self.current_coord.column, self.current_coord.row) if not self.active_path or not self.active_path.segments: return self.current_coord = self.active_path.step(self.character.event_handler) if self.active_path.current_step == self.active_path.max_steps: if self.active_path.hold_time and self.active_path.hold_time_remaining == self.active_path.hold_time: self.character.event_handler._handle_event( self.character.event_handler.Event.PATH_HOLDING, self.active_path, ) self.active_path.hold_time_remaining -= 1 return if self.active_path.hold_time_remaining: self.active_path.hold_time_remaining -= 1 return if self.active_path.loop and len(self.active_path.segments) > 1: looping_path = self.active_path self.deactivate_path(self.active_path) self.activate_path(looping_path) else: self.completed_path = self.active_path self.deactivate_path(self.active_path) self.character.event_handler._handle_event( self.character.event_handler.Event.PATH_COMPLETE, self.completed_path, ) terminaltexteffects-release-0.15.0/terminaltexteffects/engine/terminal.py000066400000000000000000002055321517776150200270270ustar00rootroot00000000000000"""A module for managing the terminal state and output. Classes: TerminalConfig: Configuration for the terminal. Canvas: Represents the canvas in the terminal. Canvas bounds are derived from the input text dimensions, terminal dimensions, and relevant TerminalConfig options. Terminal: A class for managing the terminal state and output. """ from __future__ import annotations import random import re import shutil import sys import time import typing from dataclasses import dataclass from typing import Literal from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.utils import ansitools, argutils from terminaltexteffects.utils.argutils import CharacterGroup, CharacterSort, ColorSort from terminaltexteffects.utils.exceptions import ( InvalidCharacterGroupError, InvalidCharacterSortError, InvalidColorSortError, UnsupportedAnsiSequenceError, ) from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.graphics import Color @dataclass class TerminalConfig(BaseConfig): """Configuration for the terminal. Attributes: tab_width (int): Number of spaces to use for a tab character. xterm_colors (bool): Convert any colors specified in RGB hex to the closest XTerm-256 color. no_color (bool): Disable all colors in the effect. terminal_background_color (Color): Background color of the terminal used by effects that depend on it. existing_color_handling (Literal['always','dynamic','ignore']): Specify handling of existing ANSI SGR color sequences in the input data. Supported input colors include 3-bit, 4-bit, 8-bit, and 24-bit foreground/background sequences. 'always' will always use the input colors, ignoring any effect specific colors. 'dynamic' will leave it to the effect implementation to apply input colors. 'ignore' will ignore the colors in the input data. Default is 'ignore'. wrap_text (bool): Wrap text wider than the canvas width. frame_rate (int): Target frame rate for the animation in frames per second. Set to 0 to disable frame rate limiting. canvas_width (int): Canvas width, set to an integer > 0 to use a specific dimension, if set to 0 the canvas width is detected automatically based on the terminal device, if set to -1 the canvas width is based on the input data width. canvas_height (int): Canvas height, set to an integer > 0 to use a specific dimension, if set to 0 the canvas height is detected automatically based on the terminal device, if set to -1 the canvas height is based on the input data height. anchor_canvas (Literal['sw','s','se','e','ne','n','nw','w','c']): Anchor point for the Canvas. The Canvas will be anchored in the terminal to the location corresponding to the cardinal/diagonal direction. Defaults to 'sw'. anchor_text (Literal['n','ne','e','se','s','sw','w','nw','c']): Anchor point for the text within the Canvas. Input text will be anchored in the Canvas to the location corresponding to the cardinal/diagonal direction. Defaults to 'sw'. ignore_terminal_dimensions (bool): Ignore the terminal dimensions and utilize the full Canvas beyond the extents of the terminal. Useful for sending frames to another output handler. reuse_canvas (bool): Do not create new rows at the start of the effect. The cursor will be restored to the position of the previous canvas. no_eol (bool): Suppress the trailing newline emitted when an effect animation completes. no_restore_cursor (bool): Do not restore cursor visibility when an effect animation completes. """ tab_width: int = argutils.ArgSpec( name="--tab-width", type=argutils.PositiveInt.type_parser, metavar=argutils.PositiveInt.METAVAR, default=4, help="Number of spaces to use for a tab character.", ) # pyright: ignore[reportAssignmentType] "int : Number of spaces to use for a tab character." xterm_colors: bool = argutils.ArgSpec( name="--xterm-colors", default=False, action="store_true", help="Convert any colors specified in 24-bit RGB hex to the closest 8-bit XTerm-256 color.", ) # pyright: ignore[reportAssignmentType] "bool : Convert any colors specified in 24-bit RGB hex to the closest 8-bit XTerm-256 color." no_color: bool = argutils.ArgSpec( name="--no-color", default=False, action="store_true", help="Disable all colors in the effect.", ) # pyright: ignore[reportAssignmentType] "bool : Disable all colors in the effect." terminal_background_color: Color = argutils.ArgSpec( name="--terminal-background-color", type=argutils.ColorArg.type_parser, default=Color("#000000"), metavar=argutils.ColorArg.METAVAR, help=( "The background color of your terminal. " "Used to determine the appropriate color for fade-in/out within effects." ), ) # type: ignore[assignment] "Color: User-defined background color of the terminal." existing_color_handling: Literal["always", "dynamic", "ignore"] = argutils.ArgSpec( name="--existing-color-handling", default="ignore", choices=["always", "dynamic", "ignore"], help=( "Specify handling of existing ANSI SGR color sequences in the input data. Supported input colors include " "3-bit, 4-bit, 8-bit, and 24-bit foreground/background sequences. 'always' will always use the input " "colors, ignoring any effect specific colors. 'dynamic' will leave it to the effect implementation to " "apply input colors. 'ignore' will ignore the colors in the input data. Default is 'ignore'." ), ) # pyright: ignore[reportAssignmentType] ( "Literal['always','dynamic','ignore'] : Specify handling of existing ANSI SGR color sequences in the input " "data. Supported input colors include 3-bit, 4-bit, 8-bit, and 24-bit foreground/background sequences. " "'always' will always use the input colors, ignoring any effect specific colors. 'dynamic' will leave it to " "the effect implementation to apply input colors. 'ignore' will ignore the colors in the input data. " "Default is 'ignore'." ) wrap_text: bool = argutils.ArgSpec( name="--wrap-text", default=False, action="store_true", help="Wrap text wider than the canvas width.", ) # pyright: ignore[reportAssignmentType] "bool : Wrap text wider than the canvas width." frame_rate: int = argutils.ArgSpec( name="--frame-rate", type=argutils.NonNegativeInt.type_parser, default=60, help=( "Target frame rate for the animation in frames per second. Set to 0 to disable frame rate limiting. " "Defaults to 60." ), ) # type: ignore[assignment] "int : Target frame rate for the animation in frames per second. Set to 0 to disable frame rate limiting." canvas_width: int = argutils.ArgSpec( name="--canvas-width", metavar=argutils.CanvasDimension.METAVAR, type=argutils.CanvasDimension.type_parser, default=-1, help=( "Canvas width, set to an integer > 0 to use a specific dimension, use 0 to match the terminal width, " "or use -1 to match the input text width. Defaults to -1." ), ) # pyright: ignore[reportAssignmentType] ( "int : Canvas width, set to an integer > 0 to use a specific dimension, if set to 0 the canvas width is " "detected automatically based on the terminal device, if set to -1 the canvas width is based on " "the input data width. Defaults to -1." ) canvas_height: int = argutils.ArgSpec( name="--canvas-height", metavar=argutils.CanvasDimension.METAVAR, type=argutils.CanvasDimension.type_parser, default=-1, help=( "Canvas height, set to an integer > 0 to use a specific dimension, use 0 to match the terminal " "height, or use -1 to match the input text height. Defaults to -1." ), ) # pyright: ignore[reportAssignmentType] ( "int : Canvas height, set to an integer > 0 to use a specific dimension, if set to 0 the canvas height " "is detected automatically based on the terminal device, if set to -1 the canvas height is " "based on the input data height. Defaults to -1." ) anchor_canvas: Literal["sw", "s", "se", "e", "ne", "n", "nw", "w", "c"] = argutils.ArgSpec( name="--anchor-canvas", choices=["sw", "s", "se", "e", "ne", "n", "nw", "w", "c"], default="sw", help=( "Anchor point for the canvas. The canvas will be anchored in the terminal to the location " "corresponding to the cardinal/diagonal direction. Defaults to 'sw'." ), ) # pyright: ignore[reportAssignmentType] ( "Literal['sw','s','se','e','ne','n','nw','w','c'] : Anchor point for the canvas. The canvas will be " "anchored in the terminal to the location corresponding to the cardinal/diagonal direction. Defaults to 'sw'." ) anchor_text: Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "c"] = argutils.ArgSpec( name="--anchor-text", choices=["n", "ne", "e", "se", "s", "sw", "w", "nw", "c"], default="sw", help=( "Anchor point for the text within the Canvas. Input text will be anchored in the Canvas to " "the location corresponding to the cardinal/diagonal direction. Defaults to 'sw'." ), ) # pyright: ignore[reportAssignmentType] ( "Literal['n','ne','e','se','s','sw','w','nw','c'] : Anchor point for the text within the Canvas. " "Input text will be anchored in the Canvas to the location corresponding to the cardinal/diagonal direction. " "Defaults to 'sw'." ) ignore_terminal_dimensions: bool = argutils.ArgSpec( name="--ignore-terminal-dimensions", default=False, action="store_true", help=( "Ignore the terminal dimensions and utilize the full Canvas beyond the extents of the terminal. " "Useful for sending frames to another output handler." ), ) # pyright: ignore[reportAssignmentType] ( "bool : Ignore the terminal dimensions and utilize the full Canvas beyond the extents of the terminal. " "Useful for sending frames to another output handler." ) reuse_canvas: bool = argutils.ArgSpec( name="--reuse-canvas", default=False, action="store_true", help=( "Do not create new rows at the start of the effect. The cursor will be moved up the number of rows " "present in the input text in an attempt to re-use the canvas. This option works best when used in " "a shell script. If used interactively with prompts between runs, the result is unpredictable." ), ) # pyright: ignore[reportAssignmentType] ( "bool: Do not create new rows at the start of the effect. The cursor will be moved up the number of rows " "present in the input text in an attempt to re-use the canvas. This option works best when used in " "a shell script. If used interactively with prompts between runs, the result is unpredictable." ) no_eol: bool = argutils.ArgSpec( name="--no-eol", default=False, action="store_true", help=("Suppress the trailing newline emitted when an effect animation completes."), ) # pyright: ignore[reportAssignmentType] ("bool : Suppress the trailing newline emitted when an effect animation completes. ") no_restore_cursor: bool = argutils.ArgSpec( name="--no-restore-cursor", default=False, action="store_true", help=("Do not restore cursor visibility after the effect."), ) # pyright: ignore[reportAssignmentType] ("bool : Do not restore cursor visibility after the effect.") @dataclass class Canvas: """Represent the canvas in the terminal. The canvas bounds are derived from the input text dimensions, terminal dimensions, and `TerminalConfig` options such as explicit canvas sizing, text wrapping, and ignoring terminal dimensions. This class provides methods for working with the canvas, such as checking if a coordinate is within the canvas, getting random coordinates within the canvas, and getting a random coordinate outside the canvas. This class also provides attributes for the dimensions of the canvas, the extents of the text within the canvas, and the center of the canvas. Args: top (int): top row of the canvas right (int): right column of the canvas bottom (int): bottom row of the canvas. Defaults to 1. left (int): left column of the canvas. Defaults to 1. Attributes: top (int): top row of the canvas right (int): right column of the canvas bottom (int): bottom row of the canvas left (int): left column of the canvas center_row (int): row of the center of the canvas center_column (int): column of the center of the canvas center (Coord): coordinate of the center of the canvas width (int): width of the canvas height (int): height of the canvas text_left (int): left column of the text within the canvas text_right (int): right column of the text within the canvas text_top (int): top row of the text within the canvas text_bottom (int): bottom row of the text within the canvas text_width (int): width of the text within the canvas text_height (int): height of the text within the canvas text_center_row (int): row of the center of the text within the canvas text_center_column (int): column of the center of the text within the canvas text_center (Coord): coordinate of the center of the text within the canvas Methods: coord_is_in_canvas: Checks whether a coordinate is within the canvas. coord_is_in_text: Checks whether a coordinate is within the text boundary of the canvas. random_column: Get a random column position within the canvas. random_row: Get a random row position within the canvas. random_coord: Get a random coordinate within or outside the canvas. """ top: int """int: top row of the canvas""" right: int """int: right column of the canvas""" bottom: int = 1 """int: bottom row of the canvas""" left: int = 1 """int: left column of the canvas""" def __post_init__(self) -> None: """Initialize derived canvas geometry and default text-boundary values.""" self.center_row = max(self.top // 2, self.bottom) """int: row of the center of the canvas""" if self.top % 2 and self.top > 1: self.center_row += 1 self.center_column = max(self.right // 2, self.left) """int: column of the center of the canvas""" if self.right % 2 and self.right > 1: self.center_column += 1 self.center = Coord(self.center_column, self.center_row) """Coord: coordinate of the center of the canvas""" self.width = self.right """int: width of the canvas""" self.height = self.top """int: height of the canvas""" self.text_left = 0 """int: left column of the text within the canvas""" self.text_right = 0 """int: right column of the text within the canvas""" self.text_top = 0 """int: top row of the text within the canvas""" self.text_bottom = 0 """int: bottom row of the text within the canvas""" self.text_width = 0 """int: width of the text within the canvas""" self.text_height = 0 """int: height of the text within the canvas""" self.text_center_row = 0 """int: row of the center of the text within the canvas""" self.text_center_column = 0 """int: column of the center of the text within the canvas""" self.text_center = Coord(self.text_center_column, self.text_center_row) """Coord: coordinate of the center of the text within the canvas""" def _anchor_text( self, characters: list[EffectCharacter], anchor: Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "c"], ) -> 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. Args: characters (list[EffectCharacter]): Non-empty list of 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. Returns: list[EffectCharacter]: List of characters anchored within the canvas. Only returns characters with coordinates within the canvas after anchoring. """ # 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]) column_delta = row_delta = 0 if input_width != self.width: if anchor in ("s", "n", "c"): column_delta = self.center_column - (input_width // 2) elif anchor in ("se", "e", "ne"): column_delta = self.right - input_width elif anchor in ("sw", "w", "nw"): column_delta = self.left - 1 if input_height != self.height: if anchor in ("w", "e", "c"): row_delta = self.center_row - (input_height // 2) elif anchor in ("nw", "n", "ne"): row_delta = self.top - input_height elif anchor in ("sw", "s", "se"): row_delta = self.bottom - 1 for character in characters: current_coord = character.input_coord anchored_coord = Coord( current_coord.column + column_delta, current_coord.row + row_delta, ) character._input_coord = anchored_coord character.motion.set_coordinate(anchored_coord) characters = [character for character in characters if self.coord_is_in_canvas(character.input_coord)] # get text dimensions, centers, and extents self.text_left = min([character.input_coord.column for character in characters]) self.text_right = max([character.input_coord.column for character in characters]) self.text_top = max([character.input_coord.row for character in characters]) self.text_bottom = min([character.input_coord.row for character in characters]) self.text_width = max(self.text_right - self.text_left + 1, 1) self.text_height = max(self.text_top - self.text_bottom + 1, 1) self.text_center_row = self.text_bottom + ((self.text_top - self.text_bottom) // 2) self.text_center_column = self.text_left + ((self.text_right - self.text_left) // 2) self.text_center = Coord(self.text_center_column, self.text_center_row) return characters def coord_is_in_canvas(self, coord: Coord) -> bool: """Check whether a coordinate is within the canvas. Args: coord (Coord): coordinate to check Returns: bool: whether the coordinate is within the canvas """ return self.left <= coord.column <= self.right and self.bottom <= coord.row <= self.top def coord_is_in_text(self, coord: Coord) -> bool: """Check whether a coordinate is within the text boundary. Args: coord (Coord): coordinate to check Returns: bool: whether the coordinate is within the text boundary """ return self.text_left <= coord.column <= self.text_right and self.text_bottom <= coord.row <= self.text_top def random_column(self, *, within_text_boundary: bool = False) -> int: """Get a random column position within the canvas. Args: within_text_boundary (bool, optional): If True, the column will be limited to the text boundary. Otherwise, it can be anywhere within the canvas. Defaults to False. Returns: int: a random column position within the canvas """ if within_text_boundary: return random.randint(self.text_left, self.text_right) return random.randint(self.left, self.right) def random_row(self, *, within_text_boundary: bool = False) -> int: """Get a random row position within the canvas. Args: within_text_boundary (bool, optional): If True, the row will be limited to the text boundary. Otherwise, it can be anywhere within the canvas. Defaults to False. Returns: int: a random row position within the canvas """ if within_text_boundary: return random.randint(self.text_bottom, self.text_top) return random.randint(self.bottom, self.top) def random_coord( self, *, outside_scope: bool = False, within_text_boundary: bool = False, ) -> Coord: """Get a random coordinate. The coordinate is within the canvas unless `outside_scope` is True. When `outside_scope` is True, the returned coordinate is positioned exactly one cell beyond one of the four canvas edges. `outside_scope` takes precedence over `within_text_boundary`; the two options are functionally mutually exclusive. Args: outside_scope (bool, optional): whether the coordinate should fall outside the canvas. Defaults to False. within_text_boundary (bool, optional): If True, the coordinate will be limited to the text boundary. Otherwise, it can be anywhere within the canvas. Defaults to False. Returns: Coord: A random coordinate. The coordinate is within the canvas unless `outside_scope` is `True`. """ if outside_scope is True: random_coord_above = Coord(self.random_column(), self.top + 1) random_coord_below = Coord(self.random_column(), self.bottom - 1) random_coord_left = Coord(self.left - 1, self.random_row()) random_coord_right = Coord(self.right + 1, self.random_row()) return random.choice( [random_coord_above, random_coord_below, random_coord_left, random_coord_right], ) return Coord( self.random_column(within_text_boundary=within_text_boundary), self.random_row(within_text_boundary=within_text_boundary), ) class Terminal: """A class for managing the terminal state and output. The terminal tracks input characters, fill characters, added characters, and the currently visible rendered state for the active canvas. Attributes: config (TerminalConfig): Configuration for the terminal. canvas (Canvas): The canvas in the terminal. character_by_input_coord (dict[Coord, EffectCharacter]): Mapping of input and fill characters keyed by canvas coordinates. Characters created with `add_character()` are tracked separately. terminal_state (list[str]): Internal row-by-row representation of the currently visible terminal output. visible_top (int): Top visible row within the terminal after canvas anchoring is applied. visible_bottom (int): Bottom visible row within the terminal after canvas anchoring is applied. visible_right (int): Rightmost visible column within the terminal after canvas anchoring is applied. visible_left (int): Leftmost visible column within the terminal after canvas anchoring is applied. Methods: get_piped_input: Gets the piped input from stdin. prep_canvas: Prepares the terminal for the effect by adding empty lines and hiding the cursor. restore_cursor: Restores the cursor visibility. get_characters: Get a list of all EffectCharacters in the terminal with an optional sort. get_characters_grouped: Get a list of all EffectCharacters grouped by the specified CharacterGroup grouping. get_character_by_input_coord: Get an EffectCharacter by its input coordinates. set_character_visibility: Set the visibility of a character. get_formatted_output_string: Get the formatted output string based on the current terminal state. print: Prints the current terminal state to stdout while preserving the cursor position. """ ansi_sequence_color_map: typing.ClassVar[dict[str, Color]] = {} ansi_escape_sequence_pattern: typing.ClassVar[re.Pattern[str]] = re.compile( r"(?:\x1b\][^\x07]*(?:\x07|\x1b\\))|(?:\x1b\[[0-?]*[ -/]*[@-~])|(?:\x1b.)", ) csi_sequence_pattern: typing.ClassVar[re.Pattern[str]] = re.compile(r"\x1b\[([0-?]*)([ -/]*)([@-~])") def __init__(self, input_data: str, config: TerminalConfig | None = None) -> None: """Initialize the Terminal. Args: input_data (str): The input data to be displayed in the terminal. config (TerminalConfig, optional): Configuration for the terminal. Defaults to None. """ if config is None: self.config = TerminalConfig._build_config() else: self.config = config if not input_data: input_data = "No Input." self._next_character_id = 0 self._input_colors_frequency: dict[Color, int] = {} self._preprocessed_character_lines = self._preprocess_input_data(input_data) self._terminal_width, self._terminal_height = self._get_terminal_dimensions() self.canvas = Canvas(*self._get_canvas_dimensions()) if not self.config.ignore_terminal_dimensions: self.canvas_column_offset, self.canvas_row_offset = self._calc_canvas_offsets() else: self.canvas_column_offset = self.canvas_row_offset = 0 self._terminal_width = self.canvas.right self._terminal_height = self.canvas.top # the visible_* attributes are used to determine which characters are visible on the terminal self.visible_top = min(self.canvas.top + self.canvas_row_offset, self._terminal_height) self.visible_bottom = max(self.canvas.bottom + self.canvas_row_offset, 1) self.visible_right = min( self.canvas.right + self.canvas_column_offset, self._terminal_width, ) self.visible_left = max(self.canvas.left + self.canvas_column_offset, 1) self._input_characters = [ character for character in self._setup_input_characters() if character.input_coord.row <= self.canvas.top and character.input_coord.column <= self.canvas.right ] self._added_characters: list[EffectCharacter] = [] self.character_by_input_coord: dict[Coord, EffectCharacter] = { (character.input_coord): character for character in self._input_characters } self._inner_fill_characters, self._outer_fill_characters = self._make_fill_characters() self._setup_character_neighbors() self._visible_characters: set[EffectCharacter] = set() self._frame_rate = self.config.frame_rate self._last_time_printed = time.monotonic() self._update_terminal_state() def _preprocess_input_data(self, input_data: str) -> list[list[EffectCharacter]]: # noqa: PLR0915 """Preprocess the input data. Input is decomposed into `EffectCharacter` rows while tracking supported SGR foreground/background color sequences and fetch-style cursor movement sequences. Unsupported ANSI/control sequences raise `UnsupportedAnsiSequenceError`. Args: input_data (str): The input data to be displayed in the terminal. Returns: list[list[EffectCharacter]]: Input characters decomposed into rows. """ def build_color_sequence(color_code: int | str, sequence_type: str) -> str: """Build a normalized supported color SGR sequence.""" if isinstance(color_code, int): return f"\x1b[{sequence_type};5;{color_code}m" color_ints = [int(color_code[index : index + 2], 16) for index in range(0, 6, 2)] return f"\x1b[{sequence_type};2;{color_ints[0]};{color_ints[1]};{color_ints[2]}m" def parse_csi_parameters(parameters: str) -> list[int]: """Parse CSI parameters, treating omitted values as zero.""" if any(char not in "0123456789;" for char in parameters): msg = f"\x1b[{parameters}" raise UnsupportedAnsiSequenceError(msg) if not parameters: return [] return [int(parameter) if parameter else 0 for parameter in parameters.split(";")] def apply_sgr_sequence( # noqa: PLR0915 sequence: str, active_sequences: dict[str, str], active_colors: dict[str, Color | None], active_styles: dict[str, bool], standard_fg_parameter: dict[str, int | None], ) -> None: """Apply supported SGR color parameters to the active input color state.""" parameters = parse_csi_parameters(sequence[2:-1]) if not parameters: parameters = [0] param_index = 0 while param_index < len(parameters): parameter = parameters[param_index] if parameter == 0: # SGR 0: reset all attributes active_sequences["fg_color"] = active_sequences["bg_color"] = "" active_colors["fg_color"] = active_colors["bg_color"] = None active_styles["bold"] = False standard_fg_parameter["fg_color"] = None elif parameter == 1: # SGR 1: bold / increased intensity active_styles["bold"] = True if standard_fg_parameter["fg_color"] is not None: active_colors["fg_color"] = Color(standard_fg_parameter["fg_color"] - 30 + 8) elif parameter == 22: # SGR 22: normal intensity (not bold) active_styles["bold"] = False if standard_fg_parameter["fg_color"] is not None: active_colors["fg_color"] = Color(standard_fg_parameter["fg_color"] - 30) elif parameter == 39: # SGR 39: default foreground color active_sequences["fg_color"] = "" active_colors["fg_color"] = None standard_fg_parameter["fg_color"] = None elif parameter == 49: # SGR 49: default background color active_sequences["bg_color"] = "" active_colors["bg_color"] = None elif 30 <= parameter <= 37: # SGR 30-37: standard foreground colors color = Color(parameter - 30 + (8 if active_styles["bold"] else 0)) active_sequences["fg_color"] = f"\x1b[{parameter}m" active_colors["fg_color"] = color standard_fg_parameter["fg_color"] = parameter elif 90 <= parameter <= 97: # SGR 90-97: bright foreground colors color = Color(parameter - 90 + 8) active_sequences["fg_color"] = f"\x1b[{parameter}m" active_colors["fg_color"] = color standard_fg_parameter["fg_color"] = None elif 40 <= parameter <= 47: # SGR 40-47: standard background colors color = Color(parameter - 40) active_sequences["bg_color"] = f"\x1b[{parameter}m" active_colors["bg_color"] = color elif 100 <= parameter <= 107: # SGR 100-107: bright background colors color = Color(parameter - 100 + 8) active_sequences["bg_color"] = f"\x1b[{parameter}m" active_colors["bg_color"] = color elif parameter in (38, 48): # SGR 38/48: extended foreground/background color if param_index + 1 >= len(parameters): raise UnsupportedAnsiSequenceError(sequence) sequence_type = "fg_color" if parameter == 38 else "bg_color" color_sequence_type = str(parameter) color_mode = parameters[param_index + 1] if color_mode == 5: # SGR ...;5;n: 8-bit indexed color if param_index + 2 >= len(parameters): raise UnsupportedAnsiSequenceError(sequence) color_code: int | str = parameters[param_index + 2] color = Color(color_code) param_index += 2 elif color_mode == 2: # SGR ...;2;r;g;b: 24-bit RGB color if param_index + 4 >= len(parameters): raise UnsupportedAnsiSequenceError(sequence) color_code = "".join(f"{parameters[param_index + offset]:02X}" for offset in range(2, 5)) color = Color(color_code) param_index += 4 else: raise UnsupportedAnsiSequenceError(sequence) active_sequences[sequence_type] = build_color_sequence(color_code, color_sequence_type) active_colors[sequence_type] = color if sequence_type == "fg_color": standard_fg_parameter["fg_color"] = None param_index += 1 def default_parameter(parameters: list[int]) -> int: """Return the first CSI parameter, defaulting zero/omitted values to one.""" if not parameters: return 1 return max(parameters[0], 1) def is_supported_private_mode_sequence(sequence: str) -> bool: """Return whether a CSI private mode sequence is safe to ignore while parsing input.""" return sequence in {"\x1b[?25h", "\x1b[?25l", "\x1b[?7h", "\x1b[?7l"} def apply_cursor_sequence(sequence: str, row: int, column: int) -> tuple[int, int]: """Apply a supported cursor movement sequence and return the new cursor position.""" csi_match = self.csi_sequence_pattern.fullmatch(sequence) if not csi_match: raise UnsupportedAnsiSequenceError(sequence) parameters_text, intermediates, final_byte = csi_match.groups() if intermediates: raise UnsupportedAnsiSequenceError(sequence) if parameters_text.startswith("?"): raise UnsupportedAnsiSequenceError(sequence) parameters = parse_csi_parameters(parameters_text) if final_byte == "A": # CSI A: cursor up row -= default_parameter(parameters) elif final_byte == "B": # CSI B: cursor down row += default_parameter(parameters) elif final_byte == "C": # CSI C: cursor forward column += default_parameter(parameters) elif final_byte == "D": # CSI D: cursor back column -= default_parameter(parameters) elif final_byte == "E": # CSI E: cursor next line row += default_parameter(parameters) column = 0 elif final_byte == "F": # CSI F: cursor previous line row -= default_parameter(parameters) column = 0 elif final_byte == "G": # CSI G: cursor horizontal absolute column = default_parameter(parameters) - 1 elif final_byte in ("H", "f"): # CSI H/f: cursor position / horizontal-vertical position row = default_parameter(parameters) - 1 column = (parameters[1] if len(parameters) > 1 and parameters[1] else 1) - 1 else: raise UnsupportedAnsiSequenceError(sequence) return max(row, 0), max(column, 0) def build_character( symbol: str, active_sequences: dict[str, str], active_colors: dict[str, Color | None], active_styles: dict[str, bool], ) -> EffectCharacter: """Build an input character with the current terminal configuration and input colors.""" character = EffectCharacter(self._next_character_id, symbol, 0, 0) self._next_character_id += 1 for sequence_type, sequence in active_sequences.items(): color = active_colors[sequence_type] if sequence and color: character._input_ansi_sequences[sequence_type] = sequence self._input_colors_frequency[color] = self._input_colors_frequency.get(color, 0) + 1 if sequence_type == "fg_color": character.animation.input_fg_color = color else: character.animation.input_bg_color = color character.animation.input_bold = active_styles["bold"] character.animation.no_color = self.config.no_color character.animation.use_xterm_colors = self.config.xterm_colors character.animation.existing_color_handling = self.config.existing_color_handling character.uses_input_preexisting_colors = True if character.animation.existing_color_handling == "always": character.animation.set_appearance(character.input_symbol) return character screen: dict[tuple[int, int], EffectCharacter] = {} active_sequences = {"fg_color": "", "bg_color": ""} active_colors: dict[str, Color | None] = {"fg_color": None, "bg_color": None} active_styles = {"bold": False} standard_fg_parameter: dict[str, int | None] = {"fg_color": None} row = column = 0 char_index = max_row = max_column = 0 while char_index < len(input_data): if input_data[char_index] == "\x1b": sequence_match = self.ansi_escape_sequence_pattern.match(input_data, char_index) if not sequence_match: raise UnsupportedAnsiSequenceError(input_data[char_index]) sequence = sequence_match.group(0) if sequence.startswith("\x1b["): csi_match = self.csi_sequence_pattern.fullmatch(sequence) if not csi_match: raise UnsupportedAnsiSequenceError(sequence) final_byte = csi_match.group(3) if final_byte == "m": apply_sgr_sequence( sequence, active_sequences, active_colors, active_styles, standard_fg_parameter, ) elif is_supported_private_mode_sequence(sequence): pass else: row, column = apply_cursor_sequence(sequence, row, column) max_row = max(max_row, row) max_column = max(max_column, column) else: raise UnsupportedAnsiSequenceError(sequence) char_index = sequence_match.end() elif input_data[char_index] == "\n": row += 1 column = 0 max_row = max(max_row, row) char_index += 1 elif input_data[char_index] == "\r": column = 0 char_index += 1 else: symbol = input_data[char_index] if symbol == "\t": symbol = " " spaces_to_next_tab = self.config.tab_width - (column % self.config.tab_width) else: spaces_to_next_tab = 1 for _ in range(spaces_to_next_tab): screen[(row, column)] = build_character(symbol, active_sequences, active_colors, active_styles) max_row = max(max_row, row) max_column = max(max_column, column) column += 1 char_index += 1 characters: list[list[EffectCharacter]] = [] for screen_row in range(max_row + 1): character_line: list[EffectCharacter] = [] for screen_column in range(max_column + 1): character = screen.get((screen_row, screen_column)) if character is None: character = build_character( " ", {"fg_color": "", "bg_color": ""}, {"fg_color": None, "bg_color": None}, {"bold": False}, ) character_line.append(character) while character_line and character_line[-1].input_symbol == " " and not any( (character_line[-1].animation.input_fg_color, character_line[-1].animation.input_bg_color), ): character_line.pop() characters.append(character_line) while characters and not characters[-1]: characters.pop() return characters or [[build_character(" ", active_sequences, active_colors, active_styles)]] def _calc_canvas_offsets(self) -> tuple[int, int]: """Calculate terminal-space offsets for the anchored canvas. The returned column and row offsets position the canvas within the available terminal area according to `config.anchor_canvas`. These offsets are later applied when determining visible bounds and when rendering character positions. Returns: tuple[int, int]: Canvas column offset and row offset. """ canvas_column_offset = canvas_row_offset = 0 if self.config.anchor_canvas in ("s", "n", "c"): canvas_column_offset = (self._terminal_width // 2) - (self.canvas.width // 2) elif self.config.anchor_canvas in ("se", "e", "ne"): canvas_column_offset = self._terminal_width - self.canvas.width if self.config.anchor_canvas in ("w", "e", "c"): canvas_row_offset = self._terminal_height // 2 - self.canvas.height // 2 elif self.config.anchor_canvas in ("nw", "n", "ne"): canvas_row_offset = self._terminal_height - self.canvas.height return canvas_column_offset, canvas_row_offset def _get_canvas_dimensions(self) -> tuple[int, int]: """Determine the canvas dimensions from terminal config and input geometry. Explicit positive canvas dimensions take precedence. A configured value of `0` uses the terminal dimension, while `-1` derives the dimension from the input text, subject to terminal limits unless `ignore_terminal_dimensions` is enabled. When `wrap_text` is enabled, canvas height is based on the wrapped input lines for the selected width. Returns: tuple[int, int]: Canvas height and width. """ if self.config.canvas_width > 0: canvas_width = self.config.canvas_width elif self.config.canvas_width == 0: canvas_width = self._terminal_width else: input_width = max([len(line) for line in self._preprocessed_character_lines]) if self.config.ignore_terminal_dimensions: canvas_width = input_width else: canvas_width = min(self._terminal_width, input_width) if self.config.canvas_height > 0: canvas_height = self.config.canvas_height elif self.config.canvas_height == 0: canvas_height = self._terminal_height else: input_height = len(self._preprocessed_character_lines) if self.config.ignore_terminal_dimensions: canvas_height = input_height elif self.config.wrap_text: canvas_height = min( len(self._wrap_lines(self._preprocessed_character_lines, canvas_width)), self._terminal_height, ) else: canvas_height = min(self._terminal_height, input_height) return canvas_height, canvas_width def _get_terminal_dimensions(self) -> tuple[int, int]: """Get the terminal dimensions. Use `shutil.get_terminal_size()` to get terminal width and height. If that call raises `OSError`, return the fallback size `(80, 24)`. Returns: tuple[int, int]: Terminal width and height. """ try: terminal_width, terminal_height = shutil.get_terminal_size() except OSError: # If the terminal size cannot be determined, return default values return 80, 24 return terminal_width, terminal_height @staticmethod def get_piped_input() -> str: """Return piped input from `stdin`. If `stdin` is attached to a TTY, return an empty string. Otherwise, read and return the full contents of `stdin`. Returns: str: The piped input, or an empty string when running interactively. """ if sys.stdin.isatty(): return "" return sys.stdin.read() def _wrap_lines( self, lines: list[list[EffectCharacter]], width: int, ) -> list[list[EffectCharacter]]: """Wrap the given lines of text to fit within the width of the canvas. Args: lines (list[list[EffectCharacter]]): The lines of text to be wrapped. width (int): The maximum length of a line. Returns: list[list[EffectCharacter]]: The wrapped lines of text. """ wrapped_lines = [] for line in lines: current_line = line while len(current_line) > width: wrapped_lines.append(current_line[:width]) current_line = current_line[width:] wrapped_lines.append(current_line) return wrapped_lines def _setup_input_characters(self) -> list[EffectCharacter]: """Set up the input characters discovered during preprocessing. Characters are positioned based on row/column coordinates relative to the anchor point in the Canvas. Space characters from the input are excluded from the returned input-character list and are instead represented by fill characters when the canvas is populated. Coordinates are relative to the cursor row position at the time of execution. 1,1 is the bottom left corner of the row above the cursor. Returns: list[EffectCharacter]: list of EffectCharacter objects """ formatted_lines = [] formatted_lines = ( self._wrap_lines(self._preprocessed_character_lines, self.canvas.right) if self.config.wrap_text else self._preprocessed_character_lines ) input_height = len(formatted_lines) input_characters: list[EffectCharacter] = [] for row, line in enumerate(formatted_lines): for column, character in enumerate(line, start=1): character._input_coord = Coord(column, input_height - row) if character._input_symbol != " " or any( (character.animation.input_fg_color, character.animation.input_bg_color), ): input_characters.append(character) anchored_characters = self.canvas._anchor_text(input_characters, self.config.anchor_text) return [char for char in anchored_characters if self.canvas.coord_is_in_canvas(char._input_coord)] def _make_fill_characters(self) -> tuple[list[EffectCharacter], list[EffectCharacter]]: """Create fill characters for unoccupied canvas coordinates. Fill characters use a space as `input_symbol` and are inserted into `character_by_input_coord` for any canvas coordinate not already occupied by an input character. They are split into inner and outer fill characters based on whether the coordinate falls within the anchored text bounds. Returns: tuple[list[EffectCharacter], list[EffectCharacter]]: Lists of inner and outer fill characters. """ inner_fill_characters = [] outer_fill_characters = [] for row in range(1, self.canvas.top + 1): for column in range(1, self.canvas.right + 1): coord = Coord(column, row) if coord not in self.character_by_input_coord: fill_char = EffectCharacter(self._next_character_id, " ", column, row) fill_char.is_fill_character = True fill_char.animation.no_color = self.config.no_color fill_char.animation.use_xterm_colors = self.config.xterm_colors fill_char.animation.existing_color_handling = self.config.existing_color_handling fill_char.uses_input_preexisting_colors = False self.character_by_input_coord[coord] = fill_char self._next_character_id += 1 if ( self.canvas.text_left <= column <= self.canvas.text_right and self.canvas.text_bottom <= row <= self.canvas.text_top ): inner_fill_characters.append(fill_char) else: outer_fill_characters.append(fill_char) return inner_fill_characters, outer_fill_characters def _setup_character_neighbors(self) -> None: """Create the neighbor map for characters tracked in `character_by_input_coord`.""" delta_map = {"north": (0, 1), "east": (1, 0), "south": (0, -1), "west": (-1, 0)} for coord, char in self.character_by_input_coord.items(): for direction, delta in delta_map.items(): neighbor_coord = Coord(column=coord.column + delta[0], row=coord.row + delta[1]) char.neighbors[direction] = self.character_by_input_coord.get(neighbor_coord) def add_character(self, symbol: str, coord: Coord) -> EffectCharacter: """Add a character to the terminal for printing. Used to create characters that are not in the input data. Added characters are stored in `_added_characters` and are not inserted into `character_by_input_coord`. As a result, they are not returned by `get_character_by_input_coord()` and are not included in the neighbor map built from `character_by_input_coord`. Args: symbol (str): symbol to add coord (Coord): set character's input coordinates Returns: EffectCharacter: the character that was added """ character = EffectCharacter(self._next_character_id, symbol, coord.column, coord.row) character.animation.no_color = self.config.no_color character.animation.use_xterm_colors = self.config.xterm_colors character.animation.existing_color_handling = self.config.existing_color_handling character.uses_input_preexisting_colors = False self._added_characters.append(character) self._next_character_id += 1 return character def get_input_colors(self, sort: ColorSort = ColorSort.MOST_TO_LEAST) -> list[Color]: """Get colors derived from supported input color sequences with an optional sort. Args: sort (ColorSort, optional): Sort order for the colors. Defaults to `ColorSort.MOST_TO_LEAST`. Raises: InvalidColorSortError: If an invalid sort option is provided. Returns: list[Color]: Input colors tracked during preprocessing. """ if sort == ColorSort.MOST_TO_LEAST: return sorted( self._input_colors_frequency.keys(), key=lambda color: self._input_colors_frequency[color], reverse=True, ) if sort == ColorSort.RANDOM: colors = list(self._input_colors_frequency.keys()) random.shuffle(colors) return colors if sort == ColorSort.LEAST_TO_MOST: return sorted( self._input_colors_frequency.keys(), key=lambda color: self._input_colors_frequency[color], ) raise InvalidColorSortError(sort) def get_characters( self, *, input_chars: bool = True, inner_fill_chars: bool = False, outer_fill_chars: bool = False, added_chars: bool = False, sort: CharacterSort = CharacterSort.TOP_TO_BOTTOM_LEFT_TO_RIGHT, ) -> list[EffectCharacter]: """Get a list of all EffectCharacters in the terminal with an optional sort. Sorting is based on character input coordinates. The row-based "outside/middle" sort options interleave characters from the beginning and end of the default top-to-bottom, left-to-right ordering. Args: input_chars (bool, optional): whether to include input characters. Defaults to True. inner_fill_chars (bool, optional): whether to include inner fill characters. Defaults to False. outer_fill_chars (bool, optional): whether to include outer fill characters. Defaults to False. added_chars (bool, optional): whether to include added characters. Defaults to False. sort (CharacterSort, optional): order to sort the characters. Defaults to CharacterSort.TOP_TO_BOTTOM_LEFT_TO_RIGHT. Returns: list[EffectCharacter]: list of EffectCharacters in the terminal Raises: InvalidCharacterSortError: If an invalid sort option is provided. """ all_characters: list[EffectCharacter] = [] if input_chars: all_characters.extend(self._input_characters) if inner_fill_chars: all_characters.extend(self._inner_fill_characters) if outer_fill_chars: all_characters.extend(self._outer_fill_characters) if added_chars: all_characters.extend(self._added_characters) # default sort TOP_TO_BOTTOM_LEFT_TO_RIGHT all_characters.sort( key=lambda character: (-character.input_coord.row, character.input_coord.column), ) if sort is CharacterSort.RANDOM: random.shuffle(all_characters) elif sort in ( CharacterSort.TOP_TO_BOTTOM_LEFT_TO_RIGHT, CharacterSort.BOTTOM_TO_TOP_RIGHT_TO_LEFT, ): if sort is CharacterSort.BOTTOM_TO_TOP_RIGHT_TO_LEFT: all_characters.reverse() elif sort in ( CharacterSort.BOTTOM_TO_TOP_LEFT_TO_RIGHT, CharacterSort.TOP_TO_BOTTOM_RIGHT_TO_LEFT, ): all_characters.sort( key=lambda character: (character.input_coord.row, character.input_coord.column), ) if sort is CharacterSort.TOP_TO_BOTTOM_RIGHT_TO_LEFT: all_characters.reverse() elif sort in ( CharacterSort.OUTSIDE_ROW_TO_MIDDLE, CharacterSort.MIDDLE_ROW_TO_OUTSIDE, ): all_characters = [ all_characters.pop(0) if i % 2 == 0 else all_characters.pop(-1) for i in range(len(all_characters)) ] if sort is CharacterSort.MIDDLE_ROW_TO_OUTSIDE: all_characters.reverse() else: raise InvalidCharacterSortError(sort) return all_characters def get_characters_grouped( self, grouping: CharacterGroup = CharacterGroup.ROW_TOP_TO_BOTTOM, *, input_chars: bool = True, inner_fill_chars: bool = False, outer_fill_chars: bool = False, added_chars: bool = False, ) -> list[list[EffectCharacter]]: """Get a list of all EffectCharacters grouped by the specified CharacterGroup grouping. Args: grouping (CharacterGroup, optional): order to group the characters. Defaults to ROW_TOP_TO_BOTTOM. input_chars (bool, optional): whether to include input characters. Defaults to True. inner_fill_chars (bool, optional): whether to include inner fill characters. Defaults to False. outer_fill_chars (bool, optional): whether to include outer fill characters. Defaults to False. added_chars (bool, optional): whether to include added characters. Defaults to False. Returns: list[list[EffectCharacter]]: list of lists of EffectCharacters in the terminal. Inner lists correspond to groups as specified in the grouping. Raises: InvalidCharacterGroupError: If an invalid grouping option is provided. """ all_characters: list[EffectCharacter] = [] if input_chars: all_characters.extend(self._input_characters) if inner_fill_chars: all_characters.extend(self._inner_fill_characters) if outer_fill_chars: all_characters.extend(self._outer_fill_characters) if added_chars: all_characters.extend(self._added_characters) all_characters.sort( key=lambda character: (character.input_coord.row, character.input_coord.column), ) if grouping in ( CharacterGroup.COLUMN_LEFT_TO_RIGHT, CharacterGroup.COLUMN_RIGHT_TO_LEFT, ): columns = [] for column_index in range(self.canvas.right + 1): characters_in_column = [ character for character in all_characters if character.input_coord.column == column_index ] if characters_in_column: columns.append(characters_in_column) if grouping == CharacterGroup.COLUMN_RIGHT_TO_LEFT: columns.reverse() return columns if grouping in ( CharacterGroup.ROW_BOTTOM_TO_TOP, CharacterGroup.ROW_TOP_TO_BOTTOM, ): rows = [] for row_index in range(self.canvas.top + 1): characters_in_row = [ character for character in all_characters if character.input_coord.row == row_index ] if characters_in_row: rows.append(characters_in_row) if grouping == CharacterGroup.ROW_TOP_TO_BOTTOM: rows.reverse() return rows if grouping in ( CharacterGroup.DIAGONAL_BOTTOM_LEFT_TO_TOP_RIGHT, CharacterGroup.DIAGONAL_TOP_RIGHT_TO_BOTTOM_LEFT, ): diagonals = [] for diagonal_index in range(self.canvas.top + self.canvas.right + 1): characters_in_diagonal = [ character for character in all_characters if character.input_coord.row + character.input_coord.column == diagonal_index ] if characters_in_diagonal: diagonals.append(characters_in_diagonal) if grouping == CharacterGroup.DIAGONAL_TOP_RIGHT_TO_BOTTOM_LEFT: diagonals.reverse() return diagonals if grouping in ( CharacterGroup.DIAGONAL_TOP_LEFT_TO_BOTTOM_RIGHT, CharacterGroup.DIAGONAL_BOTTOM_RIGHT_TO_TOP_LEFT, ): diagonals = [] for diagonal_index in range( self.canvas.left - self.canvas.top, self.canvas.right - self.canvas.bottom + 1, ): characters_in_diagonal = [ character for character in all_characters if character.input_coord.column - character.input_coord.row == diagonal_index ] if characters_in_diagonal: diagonals.append(characters_in_diagonal) if grouping == CharacterGroup.DIAGONAL_BOTTOM_RIGHT_TO_TOP_LEFT: diagonals.reverse() return diagonals if grouping in ( CharacterGroup.CENTER_TO_OUTSIDE, CharacterGroup.OUTSIDE_TO_CENTER, ): distance_map: dict[int, list[EffectCharacter]] = {} for character in all_characters: distance = abs(character.input_coord.column - self.canvas.text_center.column) + abs( character.input_coord.row - self.canvas.text_center.row, ) if distance not in distance_map: distance_map[distance] = [] distance_map[distance].append(character) ordered_distances = sorted( distance_map.keys(), reverse=grouping is CharacterGroup.OUTSIDE_TO_CENTER, ) return [distance_map[distance] for distance in ordered_distances] raise InvalidCharacterGroupError(grouping) def get_character_by_input_coord(self, coord: Coord) -> EffectCharacter | None: """Get an EffectCharacter by its input coordinates. Lookup is limited to characters stored in `character_by_input_coord`, which includes input and fill characters but not characters added through `add_character()`. Args: coord (Coord): input coordinates of the character Returns: EffectCharacter | None: the character at the specified coordinates, or None if no character is found """ return self.character_by_input_coord.get(coord, None) def set_character_visibility(self, character: EffectCharacter, is_visible: bool) -> None: # noqa: FBT001 """Set whether a character participates in terminal rendering. This updates both the character's internal visibility flag and the terminal's tracked set of currently visible characters. Args: character (EffectCharacter): Character whose visibility should be updated. is_visible (bool): Whether the character should be visible. """ character._is_visible = is_visible if is_visible: self._visible_characters.add(character) else: self._visible_characters.discard(character) def get_formatted_output_string(self) -> str: """Get the formatted output string based on the current terminal state. This method refreshes the internal terminal representation and returns it as a newline-delimited string ordered for terminal printing from top row to bottom row. Returns: str: The formatted output string. """ self._update_terminal_state() return "\n".join(self.terminal_state[::-1]) def _update_terminal_state(self) -> None: """Rebuild the internal representation of the visible terminal state. A blank buffer covering the visible terminal area is created, then visible characters are rendered into it in ascending layer order using their current motion coordinates adjusted by the canvas offsets. Characters outside the visible bounds are skipped. """ rows = [[" " for _ in range(self.visible_right)] for _ in range(self.visible_top)] for character in sorted(self._visible_characters, key=lambda c: c.layer): row = character.motion.current_coord.row + self.canvas_row_offset column = character.motion.current_coord.column + self.canvas_column_offset if self.visible_bottom <= row <= self.visible_top and self.visible_left <= column <= self.visible_right: rows[row - 1][column - 1] = character.animation.current_character_visual.formatted_symbol terminal_state = ["".join(row) for row in rows] self.terminal_state = terminal_state def prep_canvas(self) -> None: """Prepare the terminal for the effect. Hide the cursor, position the canvas, and write blank canvas rows. If `config.reuse_canvas` is `True`, the cursor is first moved to the previously saved canvas position before the blank rows are written. This is intended to let the current effect reuse the prior canvas area rather than advancing output further down the terminal. Note: Use of `config.reuse_canvas` is less predictable if other canvas dimension options differ between the last run and the current run. """ sys.stdout.write(ansitools.hide_cursor()) if self.config.reuse_canvas: self.move_cursor_to_top() for _ in range(self.visible_top): sys.stdout.write((" " * self.visible_right) + "\n") sys.stdout.write(ansitools.dec_save_cursor_position()) def restore_cursor(self, end_symbol: str = "\n") -> None: """Restore cursor visibility when enabled and write the configured end symbol. If the `--no-eol` option is enabled, no end symbol is printed. If the `--no-restore-cursor` option is enabled, cursor visibility is not restored. Args: end_symbol (str, optional): Symbol to print after the effect completes. Defaults to a newline. """ if self.config.no_eol: end_symbol = "" if not self.config.no_restore_cursor: sys.stdout.write(ansitools.show_cursor()) sys.stdout.write(end_symbol) def print(self, output_string: str) -> None: """Print the provided output string at the top of the current canvas. The cursor is restored to the saved canvas position, moved to the top of the canvas, and the output string is written to stdout. Args: output_string (str): The string to print. """ self.move_cursor_to_top() sys.stdout.write(output_string) sys.stdout.flush() def enforce_framerate(self) -> None: """Enforce the frame rate set in the terminal config. Frame rate is enforced by sleeping if the time since the last frame is shorter than the expected frame delay. If the configured frame rate is `0`, frame rate limiting is disabled and this method returns immediately. """ if self._frame_rate == 0: return frame_delay = 1 / self._frame_rate if (time_since_last_print := time.monotonic() - self._last_time_printed) < frame_delay: time.sleep(frame_delay - time_since_last_print) self._last_time_printed = time.monotonic() def move_cursor_to_top(self) -> None: """Restore the saved canvas cursor position and move to the top of the canvas. The saved cursor position is restored, immediately saved again as the current canvas origin, and then the cursor is moved up by the visible canvas height. """ sys.stdout.write(ansitools.dec_restore_cursor_position()) sys.stdout.write(ansitools.dec_save_cursor_position()) sys.stdout.write(ansitools.move_cursor_up(self.visible_top)) terminaltexteffects-release-0.15.0/terminaltexteffects/py.typed000066400000000000000000000000001517776150200250530ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/template/000077500000000000000000000000001517776150200252015ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/template/effect_template.py000066400000000000000000000137721517776150200307140ustar00rootroot00000000000000"""Effect Description. Classes: """ # noqa: INP001 from __future__ import annotations from dataclasses import dataclass import terminaltexteffects as tte from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator from terminaltexteffects.utils import argutils from terminaltexteffects.utils.argutils import ArgSpec, ParserSpec def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: """Get the command, effect class, and configuration class for the effect. Returns: tuple[str, type[BaseEffect], type[BaseConfig]]: The command name, effect class, and configuration class. """ return "effect", Effect, EffectConfig @dataclass class EffectConfig(BaseConfig): """Effect configuration dataclass.""" parser_spec: ParserSpec = ParserSpec( name="effect", help="effect_description", description="effect_description", epilog=f"""{argutils.EASING_EPILOG} """, ) color_single: tte.Color = ArgSpec( name="--color-single", type=argutils.ColorArg.type_parser, default=tte.Color(0), metavar=argutils.ColorArg.METAVAR, help="Color for the ___.", ) # pyright: ignore[reportAssignmentType] "Color: Color for the ___." final_gradient_stops: tuple[tte.Color, ...] = ArgSpec( name="--final-gradient-stops", type=argutils.ColorArg.type_parser, nargs="+", action=argutils.TupleAction, default=(tte.Color("#8A008A"), tte.Color("#00D1FF"), tte.Color("#FFFFFF")), metavar=argutils.ColorArg.METAVAR, help=( "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." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[Color, ...]: 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." ) final_gradient_steps: tuple[int, ...] | int = ArgSpec( name="--final-gradient-steps", type=argutils.PositiveInt.type_parser, nargs="+", action=argutils.TupleAction, default=12, metavar=argutils.PositiveInt.METAVAR, help=( "Space separated, unquoted, list of the number of gradient steps to use. More steps will " "create a smoother and longer gradient animation." ), ) # pyright: ignore[reportAssignmentType] ( "tuple[int, ...] | int: Space separated, unquoted, list of the number of gradient steps to use. More " "steps will create a smoother and longer gradient animation." ) final_gradient_frames: int = ArgSpec( name="--final-gradient-frames", type=argutils.PositiveInt.type_parser, default=5, metavar=argutils.PositiveInt.METAVAR, help="Number of frames to display each gradient step. Increase to slow down the gradient animation.", ) # pyright: ignore[reportAssignmentType] "int: Number of frames to display each gradient step. Increase to slow down the gradient animation." final_gradient_direction: tte.Gradient.Direction = ArgSpec( name="--final-gradient-direction", type=argutils.GradientDirection.type_parser, default=tte.Gradient.Direction.VERTICAL, metavar=argutils.GradientDirection.METAVAR, help="Direction of the final gradient.", ) # pyright: ignore[reportAssignmentType] "Gradient.Direction : Direction of the final gradient." movement_speed: float = ArgSpec( name="--movement-speed", type=argutils.PositiveFloat.type_parser, default=1, metavar=argutils.PositiveFloat.METAVAR, help="Speed of the ___.", ) # pyright: ignore[reportAssignmentType] "float: Speed of the ___." easing: tte.easing.EasingFunction = ArgSpec( name="--easing", default=tte.easing.in_out_sine, type=argutils.Ease.type_parser, help="Easing function to use for character movement.", ) # pyright: ignore[reportAssignmentType] "easing.EasingFunction: Easing function to use for character movement." class EffectIterator(BaseEffectIterator[EffectConfig]): """Effect iterator for the NamedEffect effect.""" def __init__(self, effect: Effect) -> None: """Initialize the effect iterator. Args: effect (NamedEffect): The effect to iterate over. """ super().__init__(effect) self.pending_chars: list[tte.EffectCharacter] = [] self.character_final_color_map: dict[tte.EffectCharacter, tte.Color] = {} self.build() def build(self) -> None: """Build the effect.""" final_gradient = tte.Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) final_gradient_mapping = final_gradient.build_coordinate_color_mapping( self.terminal.canvas.text_bottom, self.terminal.canvas.text_top, self.terminal.canvas.text_left, self.terminal.canvas.text_right, self.config.final_gradient_direction, ) for character in self.terminal.get_characters(): self.character_final_color_map[character] = final_gradient_mapping[character.input_coord] # do something with the data if needed (sort, adjust positions, etc) def __next__(self) -> str: """Return the next frame of the effect.""" if self.pending_chars or self.active_characters: # perform effect logic self.update() return self.frame raise StopIteration class Effect(BaseEffect[EffectConfig]): """Effect description.""" @property def _config_cls(self) -> type[EffectConfig]: return EffectConfig @property def _iterator_cls(self) -> type[EffectIterator]: return EffectIterator terminaltexteffects-release-0.15.0/terminaltexteffects/utils/000077500000000000000000000000001517776150200245265ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/__init__.py000066400000000000000000000000541517776150200266360ustar00rootroot00000000000000"""TerminalTextEffects utilities module.""" terminaltexteffects-release-0.15.0/terminaltexteffects/utils/ansitools.py000066400000000000000000000113261517776150200271160ustar00rootroot00000000000000"""Collection of functions that generate ANSI escape codes for various terminal formatting effects. These escape codes can be used to modify the appearance of text in a terminal. Functions: parse_ansi_color_sequence(sequence: str) -> int | str: Parse an 8-bit or 24-bit ANSI color sequence, normalizing empty 24-bit channels to 0. dec_save_cursor_position() -> str: Save the cursor position using DEC sequence. dec_restore_cursor_position() -> str: Restore the cursor position using DEC sequence. hide_cursor() -> str: Hide the cursor. show_cursor() -> str: Show the cursor. move_cursor_up(y: int) -> str: Move the cursor up y lines. move_cursor_to_column(x: int) -> str: Move the cursor to the specified column. reset_all() -> str: Reset all formatting. apply_bold() -> str: Apply bold formatting. apply_dim() -> str: Apply dim formatting. apply_italic() -> str: Apply italic formatting. apply_underline() -> str: Apply underline formatting. apply_blink() -> str: Apply blink formatting. apply_reverse() -> str: Apply reverse formatting. apply_hidden() -> str: Apply hidden formatting. apply_strikethrough() -> str: Apply strikethrough formatting. """ from __future__ import annotations import re def parse_ansi_color_sequence(sequence: str) -> int | str: """Parse an 8-bit or 24-bit ANSI color sequence. Returns the color code as an integer, in the case of 8-bit, or a hex string in the case of 24-bit. For 24-bit color sequences, empty channel fields are normalized to `0` before the RGB value is returned. Args: sequence (str): ANSI color sequence Returns: int | str: 8-bit color int or 24-bit color str """ # Remove escape characters sequence = re.sub(r"(\033\[|\x1b\[)", "", sequence).strip("m") # detect 24-bit colors if re.match(r"^(38;2|48;2)", sequence): sequence = re.sub(r"^(38;2;|48;2;)", "", sequence) colors = [] for color in sequence.split(";"): if color: colors.append(int(color)) else: colors.append(0) # default to 0 if no value in field (e.g. 38;2;;0m) return "".join(f"{color:02X}" for color in colors) # detect 8-bit colors if re.match(r"^(38;5|48;5)", sequence): sequence = re.sub(r"^(38;5;|48;5;)", "", sequence) return int(sequence) msg = "Invalid ANSI color sequence" raise ValueError(msg) def dec_save_cursor_position() -> str: """Save the cursor position using DEC sequence. Returns: str: ANSI escape code """ return "\0337" def dec_restore_cursor_position() -> str: """Restore the cursor position using DEC sequence. Returns: str: ANSI escape code """ return "\0338" def hide_cursor() -> str: """Hide the cursor. Returns: str: ANSI escape code """ return "\033[?25l" def show_cursor() -> str: """Show the cursor. Returns: str: ANSI escape code """ return "\033[?25h" def move_cursor_up(y: int) -> str: """Move the cursor up by a relative number of rows. Args: y (int): Number of rows to move upward from the current cursor position. Returns: str: ANSI escape code """ return f"\033[{y}A" def move_cursor_to_column(x: int) -> str: """Move the cursor to the specified 1-based column. Args: x (int): Destination column number, using ANSI's 1-based column indexing. Returns: str: ANSI escape code """ return f"\033[{x}G" def reset_all() -> str: """Reset all formatting. Returns: str: ANSI escape code """ return "\033[0m" def apply_bold() -> str: """Apply bold formatting. Returns: str: ANSI escape code """ return "\033[1m" def apply_dim() -> str: """Apply dim formatting. Returns: str: ANSI escape code """ return "\033[2m" def apply_italic() -> str: """Apply italic formatting. Returns: str: ANSI escape code """ return "\033[3m" def apply_underline() -> str: """Apply underline formatting. Returns: str: ANSI escape code """ return "\033[4m" def apply_blink() -> str: """Apply blink formatting. Returns: str: ANSI escape code """ return "\033[5m" def apply_reverse() -> str: """Apply reverse formatting. Returns: str: ANSI escape code """ return "\033[7m" def apply_hidden() -> str: """Apply hidden formatting. Returns: str: ANSI escape code """ return "\033[8m" def apply_strikethrough() -> str: """Apply strikethrough formatting. Returns: str: ANSI escape code """ return "\033[9m" terminaltexteffects-release-0.15.0/terminaltexteffects/utils/argutils.py000066400000000000000000000606731517776150200267460ustar00rootroot00000000000000"""Command line argument validators and METAVARs for consistent type parsing and help output. This module includes a custom formatter for argparse, which combines the features of `argparse.ArgumentDefaultsHelpFormatter` and `argparse.RawDescriptionHelpFormatter`. Classes: CustomFormatter: A custom formatter for argparse that combines the features of `argparse.ArgumentDefaultsHelpFormatter` and `argparse.RawDescriptionHelpFormatter`. CharacterGroup: An enum specifying character groupings. CharacterGroupArg: Argument type for character groupings. CharacterSort: An enum specifying character sorts. CharacterSortArg: Argument type for character sorts. ColorSort: An enum specifying color sorts. ColorSortArg: Argument type for color sorts. TupleAction: Custom argparse action to convert a list of values into a tuple. ParserSpec: Specification for a parser in the argument parser. ArgSpec: Specification for a command-line argument and default value. GradientDirection: Argument type for gradient directions. ColorArg: Argument type for color values. Symbol: Argument type for single ASCII/UTF-8 characters. Ease: Argument type for easing functions. PositiveInt: Argument type for positive integers. NonNegativeInt: Argument type for nonnegative integers. PositiveIntRange: Argument type for integer ranges. PositiveFloat: Argument type for positive floats. NonNegativeFloat: Argument type for nonnegative floats. PositiveFloatRange: Argument type for float ranges. TerminalDimension: Argument type for terminal dimensions. CanvasDimension: Argument type for canvas dimensions. NonNegativeRatio: Argument type for float values between zero and one. PositiveRatio: Argument type for positive float values greater than zero and less than or equal to one. EasingStep: Argument type for easing step size values. Constants: EASING_EPILOG (str): A detailed description of the easing functions supported. """ from __future__ import annotations import argparse import typing from dataclasses import dataclass from enum import Enum, auto from terminaltexteffects.utils import easing from terminaltexteffects.utils.graphics import Color, Gradient EASING_EPILOG = """\ Easing ------ Note: A prefix must be added to the function name (except LINEAR). All easing functions support the following prefixes: IN_ - Ease in OUT_ - Ease out IN_OUT_ - Ease in and out Easing Functions ---------------- LINEAR - Linear easing SINE - Sine easing QUAD - Quadratic easing CUBIC - Cubic easing QUART - Quartic easing QUINT - Quintic easing EXPO - Exponential easing CIRC - Circular easing BACK - Back easing ELASTIC - Elastic easing BOUNCE - Bounce easing Visit: https://easings.net/ for visualizations of the easing functions. """ _MISSING = object() @dataclass(frozen=True) class ParserSpec: """Specification for creating an argparse subparser for an effect config. Each field maps directly to keyword arguments passed to `argparse._SubParsersAction.add_parser()`. """ name: str help: str description: str epilog: str @dataclass(frozen=True) class ArgSpec: """Specification for a command-line argument and config default value. Non-missing fields map directly to keyword arguments for `argparse.ArgumentParser.add_argument()`. The `default` value is also used by `BaseConfig._build_config()` when constructing configs without parsed CLI input. """ name: str default: typing.Any metavar: str = _MISSING # type: ignore[arg-type] type: typing.Any = _MISSING # type: ignore[arg-type] required: bool = _MISSING # type: ignore[arg-type] help: str = _MISSING # type: ignore[arg-type] action: str | type[argparse.Action] = _MISSING # type: ignore[arg-type] choices: list[typing.Any] = _MISSING # type: ignore[arg-type] nargs: str | int = _MISSING # type: ignore[arg-type] class CustomFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): """Combine ArgumentDefaultsHelpFormatter and RawDescriptionHelpFormatter for argparse.""" class CharacterGroup(Enum): """An enum specifying character groupings.""" COLUMN_LEFT_TO_RIGHT = auto() COLUMN_RIGHT_TO_LEFT = auto() ROW_TOP_TO_BOTTOM = auto() ROW_BOTTOM_TO_TOP = auto() DIAGONAL_TOP_LEFT_TO_BOTTOM_RIGHT = auto() DIAGONAL_BOTTOM_LEFT_TO_TOP_RIGHT = auto() DIAGONAL_TOP_RIGHT_TO_BOTTOM_LEFT = auto() DIAGONAL_BOTTOM_RIGHT_TO_TOP_LEFT = auto() CENTER_TO_OUTSIDE = auto() OUTSIDE_TO_CENTER = auto() class CharacterGroupArg: """Validate argument is a valid CharacterGroup. Raises: argparse.ArgumentTypeError: Value is not a valid CharacterGroup. """ METAVAR = tuple(n.lower() for n in CharacterGroup._member_names_) @staticmethod def type_parser(arg: str) -> CharacterGroup: """Validate argument is a valid CharacterGroup. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not a valid CharacterGroup. Returns: CharacterGroup: validated CharacterGroup """ try: return CharacterGroup[arg.upper()] except KeyError: msg = f"invalid CharacterGroup: '{arg}' is not a valid CharacterGroup." raise argparse.ArgumentTypeError(msg) from None class CharacterSort(Enum): """An enum for specifying character sorts.""" RANDOM = auto() TOP_TO_BOTTOM_LEFT_TO_RIGHT = auto() TOP_TO_BOTTOM_RIGHT_TO_LEFT = auto() BOTTOM_TO_TOP_LEFT_TO_RIGHT = auto() BOTTOM_TO_TOP_RIGHT_TO_LEFT = auto() OUTSIDE_ROW_TO_MIDDLE = auto() MIDDLE_ROW_TO_OUTSIDE = auto() class CharacterSortArg: """Validate argument is a valid CharacterSort. Raises: argparse.ArgumentTypeError: Value is not a valid CharacterSort. """ METAVAR = tuple(n.lower() for n in CharacterSort._member_names_) @staticmethod def type_parser(arg: str) -> CharacterSort: """Validate argument is a valid CharacterSort. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not a valid CharacterSort. Returns: CharacterSort: validated CharacterSort """ try: return CharacterSort[arg.upper()] except KeyError: msg = f"invalid CharacterSort: '{arg}' is not a valid CharacterSort." raise argparse.ArgumentTypeError(msg) from None class ColorSort(Enum): """An enum for specifying color sorts for the colors derived from the input text ansi sequences.""" LEAST_TO_MOST = auto() MOST_TO_LEAST = auto() RANDOM = auto() class ColorSortArg: """Validate argument is a valid ColorSort. Raises: argparse.ArgumentTypeError: Value is not a valid ColorSort. """ METAVAR = tuple(n.lower() for n in ColorSort._member_names_) @staticmethod def type_parser(arg: str) -> ColorSort: """Validate argument is a valid ColorSort. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not a valid ColorSort. Returns: ColorSort: validated ColorSort """ try: return ColorSort[arg.upper()] except KeyError: msg = f"invalid ColorSort: '{arg}' is not a valid ColorSort." raise argparse.ArgumentTypeError(msg) from None class TupleAction(argparse.Action): """Convert parsed multi-value arguments into tuples. Used for arguments that accept multiple values via `nargs`. If argparse provides `None`, the destination is set to an empty tuple. """ def __call__( self, _: argparse.ArgumentParser, namespace: argparse.Namespace, values: typing.Sequence[typing.Any] | None, __: str | None = None, ) -> None: """Convert a list of values into a tuple.""" if values is None: setattr(namespace, self.dest, ()) return setattr(namespace, self.dest, tuple(values)) class PositiveInt: """Validate argument is a positive integer. n > 0. int(n) > 0 Raises: argparse.ArgumentTypeError: Value is not a positive integer. """ METAVAR = "(int > 0)" @staticmethod def type_parser(arg: str) -> int: """Validate argument is a positive integer. n > 0. Args: arg (str): argument to validate Returns: int: validated positive integer """ try: arg_int = int(arg) except ValueError: msg = f"invalid value: '{arg}' is not a valid integer." raise argparse.ArgumentTypeError(msg) from None if arg_int > 0: return arg_int msg = f"invalid value: '{arg}' is not a valid value. Argument must be an integer > 0." raise argparse.ArgumentTypeError(msg) class NonNegativeInt: """Validate argument is a nonnegative integer. n >= 0. Raises: argparse.ArgumentTypeError: Value is not in range. """ METAVAR = "(int >= 0)" @staticmethod def type_parser(arg: str) -> int: """Validate argument is a nonnegative integer. n >= 0. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not in range. Returns: int: validated gap value """ try: arg_int = int(arg) except ValueError: msg = f"invalid value: '{arg}' is not a valid integer." raise argparse.ArgumentTypeError(msg) from None if arg_int < 0: msg = f"invalid value: '{arg}' Argument must be int >= 0." raise argparse.ArgumentTypeError(msg) from None return arg_int class PositiveIntRange: """Validate argument is a nondecreasing range that starts with a positive integer. Positive integer ranges are expressed as two integers separated by a hyphen, for example `1-10`. Example: '1-10' is a valid input. Raises: argparse.ArgumentTypeError: Value is not a valid positive integer range. """ METAVAR = "(hyphen separated positive int range e.g. '1-10')" @staticmethod def type_parser(arg: str) -> tuple[int, int]: """Validate argument is a valid range of integers n > 0. Args: arg (str): argument to validate Returns: tuple[int,int]: validated range """ try: start, end = map(int, arg.split("-")) if start <= 0: msg = f"invalid range: '{arg}' is not a valid range of positive ints. Must be start > 0. Ex: 1-10" raise argparse.ArgumentTypeError( msg, ) if start > end: msg = f"invalid range: '{arg}' is not a valid range of positive ints. Must be start <= end. Ex: 1-10" raise argparse.ArgumentTypeError( msg, ) except ValueError: msg = f"invalid range: '{arg}' is not a valid range of positive ints. Must be start-end. Ex: 1-10" raise argparse.ArgumentTypeError( msg, ) from None else: return start, end class PositiveFloat: """Validate argument is a positive float. n > 0. Raises: argparse.ArgumentTypeError: Value is not in range. """ METAVAR = "(float > 0)" @staticmethod def type_parser(arg: str) -> float: """Validate argument is a positive float. n > 0. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: value is not in range. Returns: float: validated positive float """ try: float(arg) except ValueError: msg = f"invalid value: '{arg}' is not a valid float." raise argparse.ArgumentTypeError(msg) from None if float(arg) > 0: return float(arg) msg = f"invalid value: '{arg}' is not a valid value. Argument must be a float > 0." raise argparse.ArgumentTypeError(msg) class NonNegativeFloat: """Validate argument is a nonnegative float. n >= 0. Raises: argparse.ArgumentTypeError: Argument value is not in range. """ METAVAR = "(float >= 0)" @staticmethod def type_parser(arg: str) -> float: """Validate argument is a nonnegative float. n >= 0. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Argument value is not in range. Returns: float: validated value """ try: float(arg) except ValueError: msg = f"invalid argument value: '{arg}' is not a valid float." raise argparse.ArgumentTypeError(msg) from None if float(arg) < 0: msg = f"invalid argument value: '{arg}' is out of range. Must be float >= 0." raise argparse.ArgumentTypeError(msg) return float(arg) class PositiveFloatRange: """Validate argument is a nondecreasing, nonzero float range. Float ranges are expressed as two floats separated by a hyphen, for example `0.1-1.0`. Raises: argparse.ArgumentTypeError: Value is not a valid float range. """ METAVAR = "(hyphen separated float range e.g. '0.25-0.5')" @staticmethod def type_parser(arg: str) -> tuple[float, float]: """Validate argument is a valid range of positive floats. Args: arg (str): argument to validate Returns: tuple[float,float]: validated range """ try: start, end = map(float, arg.split("-")) if start > end: msg = f"invalid range: '{arg}' is not a valid range of floats. Must be start <= end. Ex: 0.1-1.0" raise argparse.ArgumentTypeError( msg, ) if start == 0 or end == 0: msg = f"invalid range: '{arg}' is not a valid range of floats. Must be start > 0. Ex: 0.1-1.0" raise argparse.ArgumentTypeError( msg, ) except ValueError: msg = f"invalid range: '{arg}' is not a valid range. Must be start-end. Ex: 0.1-1.0" raise argparse.ArgumentTypeError(msg) from None else: return start, end class NonNegativeRatio: """Validate argument is a float value between zero and one. 0 <= float(n) <= 1 Raises: argparse.ArgumentTypeError: Value is not in range. """ METAVAR = "(0 <= float(n) <= 1)" @staticmethod def type_parser(arg: str) -> float: """Validate argument is a float value between zero and one. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not in range. Returns: float: validated float value """ try: float(arg) except ValueError: msg = f"invalid value: '{arg}' is not a float or int." raise argparse.ArgumentTypeError(msg) from None if 0 <= float(arg) <= 1: return float(arg) msg = f"invalid value: '{arg}' is not a float >= 0 and <= 1. Example: 0.5" raise argparse.ArgumentTypeError(msg) class PositiveRatio: """Validate argument is a positive float. 0 < float(n) <= 1 Raises: argparse.ArgumentTypeError: Value is not in range. """ METAVAR = "(0 < float(n) <= 1)" @staticmethod def type_parser(arg: str) -> float: """Validate argument is a positive float. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not in range. Returns: float: validated float value """ try: float(arg) except ValueError: msg = f"invalid value: '{arg}' is not a float or int." raise argparse.ArgumentTypeError(msg) from None if 0 < float(arg) <= 1: return float(arg) msg = f"invalid value: '{arg}' must be 0 < n <=1. Example: 0.5" raise argparse.ArgumentTypeError(msg) class GradientDirection: """Validate argument is a valid gradient direction. Raises: argparse.ArgumentTypeError: Argument value is not a valid gradient direction. """ METAVAR = "(diagonal, horizontal, vertical, radial)" @staticmethod def type_parser(arg: str) -> Gradient.Direction: """Validate argument is a valid gradient direction. Args: arg (str): argument to validate Returns: Gradient.Direction: validated gradient direction Raises: argparse.ArgumentTypeError: Argument value is not a valid gradient direction. """ direction_map = { "horizontal": Gradient.Direction.HORIZONTAL, "vertical": Gradient.Direction.VERTICAL, "diagonal": Gradient.Direction.DIAGONAL, "radial": Gradient.Direction.RADIAL, } if arg.lower() in direction_map: return direction_map[arg.lower()] msg = ( f"invalid gradient direction: '{arg}' is not a valid gradient direction. Choices are diagonal," " horizontal, vertical, or radial." ) raise argparse.ArgumentTypeError(msg) class ColorArg: """Validate argument is a valid color value. Color values can be either an XTerm color value (0-255) or an RGB hex value (000000-ffffff). Raises: argparse.ArgumentTypeError: Value is not in range of valid XTerm colors or RGB hex colors. """ METAVAR = "(XTerm [0-255] OR RGB Hex [000000-ffffff])" @staticmethod def type_parser(arg: str) -> Color: """Validate argument is a valid color value. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Color value is not in range. Returns: Color : validated color value """ xterm_min = 0 xterm_max = 255 if len(arg) <= 3: try: return Color(int(arg)) except ValueError: msg = ( f"invalid color value: '{arg}' is not a valid XTerm or RGB color." f" Must be in range {xterm_min}-{xterm_max} or 000000-FFFFFF." ) raise argparse.ArgumentTypeError(msg) from None else: try: return Color(arg) except ValueError: msg = ( f"invalid color value: '{arg}' is not a valid XTerm or RGB color." f" Must be in range {xterm_min}-{xterm_max} or 000000-FFFFFF." ) raise argparse.ArgumentTypeError(msg) from None class Symbol: """Validate argument is a single printable character. Raises: argparse.ArgumentTypeError: Value is not a valid symbol. """ METAVAR = "(ASCII/UTF-8 character)" @staticmethod def type_parser(arg: str) -> str: """Validate argument is a single printable character. Args: arg (str): argument to validate Returns: str: validated symbol """ if len(arg) == 1 and arg.isprintable(): return arg msg = f"invalid symbol: '{arg}' is not a valid symbol. Must be a single ASCII/UTF-8 character." raise argparse.ArgumentTypeError(msg) class CanvasDimension: """Validate argument is a nonnegative integer or `-1`. Raises: argparse.ArgumentTypeError: Value is not a valid canvas dimension. """ METAVAR = "int >= -1" @staticmethod def type_parser(arg: str) -> int: """Validate argument is a nonnegative integer or `-1`. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not a valid canvas dimension. Returns: int: validated canvas dimension """ if arg.isdigit() or arg == "-1": return int(arg) msg = f"invalid value '{arg}' is not a valid integer. Must be >= -1." raise argparse.ArgumentTypeError(msg) class TerminalDimension: """Validate argument is a valid terminal dimension. A Terminal Dimension is an integer >= 0. Raises: argparse.ArgumentTypeError: Value is not a valid terminal dimension. """ METAVAR = "int >= 0" @staticmethod def type_parser(arg: str) -> int: """Validate argument is a valid terminal dimension. Args: arg (str): argument to validate Returns: int: validated terminal dimension """ try: dimension = int(arg) if dimension < 0: msg = f"invalid terminal dimensions: '{arg}' is not a valid terminal dimension. Must be >= 0." raise argparse.ArgumentTypeError(msg) except ValueError: msg = f"invalid terminal dimensions: '{arg}' is not a valid terminal dimension. Must be >= 0." raise argparse.ArgumentTypeError(msg) from None else: return dimension class Ease: """Validate argument is a valid easing function. Easing functions are prefixed by "in", "out", or "in_out" and suffixed by a valid easing function. Raises: argparse.ArgumentTypeError: Value is not a valid easing function. """ METAVAR = "(Easing Function)" @staticmethod def type_parser(arg: str) -> typing.Callable: """Validate argument is a valid easing function. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Ease value is not a valid easing function. Returns: typing.Callable: The validated easing function. """ easing_func_map = { "linear": easing.linear, "in_sine": easing.in_sine, "out_sine": easing.out_sine, "in_out_sine": easing.in_out_sine, "in_quad": easing.in_quad, "out_quad": easing.out_quad, "in_out_quad": easing.in_out_quad, "in_cubic": easing.in_cubic, "out_cubic": easing.out_cubic, "in_out_cubic": easing.in_out_cubic, "in_quart": easing.in_quart, "out_quart": easing.out_quart, "in_out_quart": easing.in_out_quart, "in_quint": easing.in_quint, "out_quint": easing.out_quint, "in_out_quint": easing.in_out_quint, "in_expo": easing.in_expo, "out_expo": easing.out_expo, "in_out_expo": easing.in_out_expo, "in_circ": easing.in_circ, "out_circ": easing.out_circ, "in_out_circ": easing.in_out_circ, "in_back": easing.in_back, "out_back": easing.out_back, "in_out_back": easing.in_out_back, "in_elastic": easing.in_elastic, "out_elastic": easing.out_elastic, "in_out_elastic": easing.in_out_elastic, "in_bounce": easing.in_bounce, "out_bounce": easing.out_bounce, "in_out_bounce": easing.in_out_bounce, } try: return easing_func_map[arg.lower()] except KeyError: msg = f"invalid ease value: '{arg}' is not a valid ease." raise argparse.ArgumentTypeError(msg) from None class EasingStep: """Validate argument is a valid easing step size value. Raises: argparse.ArgumentTypeError: Value is not a valid easing step size. """ METAVAR = "0 < float(n) <= 1" @staticmethod def type_parser(arg: str) -> float: """Validate argument is a valid easing step size value. Args: arg (str): argument to validate Raises: argparse.ArgumentTypeError: Value is not a valid easing step size. Returns: float: validated easing step size value """ try: f = float(arg) except ValueError: msg = f"invalid value: '{arg}' is not a valid float." raise argparse.ArgumentTypeError(msg) from None if 0 < f <= 1: return f msg = f"invalid value: '{arg}' is not a float > 0 and <= 1. Example: 0.5" raise argparse.ArgumentTypeError(msg) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/colorterm.py000066400000000000000000000054251517776150200271140ustar00rootroot00000000000000"""Convert XTerm 256 color codes and RGB hex colors into ANSI escape sequences. Functions: fg(color_code: str | int) -> str: Set the foreground color using an XTerm code or RGB hex string. bg(color_code: str | int) -> str: Set the background color using an XTerm code or RGB hex string. """ from __future__ import annotations def _hex_to_int(hex_color: str) -> tuple[int, int, int]: """Convert a hex color string into an RGB integer tuple. Args: hex_color (str): Hex color string in the range 000000 -> FFFFFF. '#' is optional. Returns: tuple[int, int, int]: A tuple of integers (red, green, blue) representing the color. """ hex_color = hex_color.strip("#") ints = [int(hex_color[i : i + 2], 16) for i in range(0, 6, 2)] return ints[0], ints[1], ints[2] def _color(color_code: str | int, location: int) -> str: """Return an ANSI escape sequence to color the foreground/background of text. This is a helper function for fg() and bg(). Args: color_code (str | int): The color code to be converted. location (int): ANSI SGR color selector, where `38` applies foreground color and `48` applies background color. Returns: str: The ANSI escape sequence for the color. Raises: ValueError: If the color code is not in the range 000000 -> FFFFFF or 0 -> 255. """ if isinstance(color_code, str): color_ints = _hex_to_int(color_code) sequence = f"\x1b[{location};2;{color_ints[0]};{color_ints[1]};{color_ints[2]}m" elif isinstance(color_code, int): if color_code not in range(256): msg = f"Got color code ({color_code}): xterm color codes must be an integer: 0 <= n <= 255" raise ValueError(msg) sequence = f"\x1b[{location};5;{color_code}m" else: msg = ( f"Got color code ({color_code}): Color must be either hex string #000000 -> #FFFFFF or" f" int xterm color code 0 <= n <= 255" ) raise TypeError( msg, ) return sequence def fg(color_code: str | int) -> str: """Set the foreground color of the terminal text. Args: color_code (str | int): The foreground color as an XTerm 256 color code or an RGB hex string, with or without a leading `#`. Returns: str: The ANSI escape sequence to set the foreground color. """ return _color(color_code, 38) def bg(color_code: str | int) -> str: """Set the background color of the terminal text. Args: color_code (str | int): The background color as an XTerm 256 color code or an RGB hex string, with or without a leading `#`. Returns: str: The ANSI escape sequence to set the background color. """ return _color(color_code, 48) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/easing.py000066400000000000000000000544411517776150200263560ustar00rootroot00000000000000"""Functions and Classes for easing calculations. Classes: EasingTracker: Tracks the progression of an easing function over a set number of steps. SequenceEaser: Eases over the leading portion of a sequence, tracking added, removed, and total elements. Functions: linear: Linear easing function. in_sine: Ease in using a sine function. out_sine: Ease out using a sine function. in_out_sine: Ease in/out using a sine function. in_quad: Ease in using a quadratic function. out_quad: Ease out using a quadratic function. in_out_quad: Ease in/out using a quadratic function. in_cubic: Ease in using a cubic function. out_cubic: Ease out using a cubic function. in_out_cubic: Ease in/out using a cubic function. in_quart: Ease in using a quartic function. out_quart: Ease out using a quartic function. in_out_quart: Ease in/out using a quartic function. in_quint: Ease in using a quintic function. out_quint: Ease out using a quintic function. in_out_quint: Ease in/out using a quintic function. in_expo: Ease in using an exponential function. out_expo: Ease out using an exponential function. in_out_expo: Ease in/out using an exponential function. in_circ: Ease in using a circular function. out_circ: Ease out using a circular function. in_out_circ: Ease in/out using a circular function. in_back: Ease in using a back function. out_back: Ease out using a back function. in_out_back: Ease in/out using a back function. in_elastic: Ease in using an elastic function. out_elastic: Ease out using an elastic function. in_out_elastic: Ease in/out using an elastic function. in_bounce: Ease in using a bounce function. out_bounce: Ease out using a bounce function. in_out_bounce: Ease in/out using a bounce function. make_easing: Create a cubic Bezier easing function using the provided control points. """ from __future__ import annotations import functools import math import typing from dataclasses import InitVar, dataclass, field # EasingFunction is a type alias for a function that takes a float between 0 and 1 and returns a float between 0 and 1. EasingFunction = typing.Callable[[float], float] "EasingFunctions take a float between 0 and 1 and return a float between 0 and 1." def linear(progress_ratio: float) -> float: """Linear easing function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return progress_ratio def in_sine(progress_ratio: float) -> float: """Ease in using a sine function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return 1 - math.cos((progress_ratio * math.pi) / 2) def out_sine(progress_ratio: float) -> float: """Ease out using a sine function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return math.sin((progress_ratio * math.pi) / 2) def in_out_sine(progress_ratio: float) -> float: """Ease in/out using a sine function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return -(math.cos(math.pi * progress_ratio) - 1) / 2 def in_quad(progress_ratio: float) -> float: """Ease in using a quadratic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return progress_ratio**2 def out_quad(progress_ratio: float) -> float: """Ease out using a quadratic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return 1 - (1 - progress_ratio) * (1 - progress_ratio) def in_out_quad(progress_ratio: float) -> float: """Ease in/out using a quadratic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio < 0.5: return 2 * progress_ratio**2 return 1 - (-2 * progress_ratio + 2) ** 2 / 2 def in_cubic(progress_ratio: float) -> float: """Ease in using a cubic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return progress_ratio**3 def out_cubic(progress_ratio: float) -> float: """Ease out using a cubic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return 1 - (1 - progress_ratio) ** 3 def in_out_cubic(progress_ratio: float) -> float: """Ease in/out using a cubic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio < 0.5: return 4 * progress_ratio**3 return 1 - (-2 * progress_ratio + 2) ** 3 / 2 def in_quart(progress_ratio: float) -> float: """Ease in using a quartic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return progress_ratio**4 def out_quart(progress_ratio: float) -> float: """Ease out using a quartic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return 1 - (1 - progress_ratio) ** 4 def in_out_quart(progress_ratio: float) -> float: """Ease in/out using a quartic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio < 0.5: return 8 * progress_ratio**4 return 1 - (-2 * progress_ratio + 2) ** 4 / 2 def in_quint(progress_ratio: float) -> float: """Ease in using a quintic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return progress_ratio**5 def out_quint(progress_ratio: float) -> float: """Ease out using a quintic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return 1 - (1 - progress_ratio) ** 5 def in_out_quint(progress_ratio: float) -> float: """Ease in/out using a quintic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio < 0.5: return 16 * progress_ratio**5 return 1 - (-2 * progress_ratio + 2) ** 5 / 2 def in_expo(progress_ratio: float) -> float: """Ease in using an exponential function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio == 0: return 0 return 2 ** (10 * progress_ratio - 10) def out_expo(progress_ratio: float) -> float: """Ease out using an exponential function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio == 1: return 1 return 1 - 2 ** (-10 * progress_ratio) def in_out_expo(progress_ratio: float) -> float: """Ease in/out using an exponential function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio == 0: return 0 if progress_ratio == 1: return 1 if progress_ratio < 0.5: return 2 ** (20 * progress_ratio - 10) / 2 return (2 - 2 ** (-20 * progress_ratio + 10)) / 2 def in_circ(progress_ratio: float) -> float: """Ease in using a circular function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return 1 - math.sqrt(1 - progress_ratio**2) def out_circ(progress_ratio: float) -> float: """Ease out using a circular function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return math.sqrt(1 - (progress_ratio - 1) ** 2) def in_out_circ(progress_ratio: float) -> float: """Ease in/out using a circular function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio < 0.5: return (1 - math.sqrt(1 - (2 * progress_ratio) ** 2)) / 2 return (math.sqrt(1 - (-2 * progress_ratio + 2) ** 2) + 1) / 2 def in_back(progress_ratio: float) -> float: """Ease in using a back function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ c1 = 1.70158 c3 = c1 + 1 return c3 * progress_ratio**3 - c1 * progress_ratio**2 def out_back(progress_ratio: float) -> float: """Ease out using a back function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ c1 = 1.70158 c3 = c1 + 1 return 1 + c3 * (progress_ratio - 1) ** 3 + c1 * (progress_ratio - 1) ** 2 def in_out_back(progress_ratio: float) -> float: """Ease in/out using a back function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ c1 = 1.70158 c2 = c1 * 1.525 if progress_ratio < 0.5: return ((2 * progress_ratio) ** 2 * ((c2 + 1) * 2 * progress_ratio - c2)) / 2 return ((2 * progress_ratio - 2) ** 2 * ((c2 + 1) * (progress_ratio * 2 - 2) + c2) + 2) / 2 def in_elastic(progress_ratio: float) -> float: """Ease in using an elastic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ c4 = (2 * math.pi) / 3 if progress_ratio == 0: return 0 if progress_ratio == 1: return 1 return -(2 ** (10 * progress_ratio - 10)) * math.sin((progress_ratio * 10 - 10.75) * c4) def out_elastic(progress_ratio: float) -> float: """Ease out using an elastic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ c4 = (2 * math.pi) / 3 if progress_ratio == 0: return 0 if progress_ratio == 1: return 1 return 2 ** (-10 * progress_ratio) * math.sin((progress_ratio * 10 - 0.75) * c4) + 1 def in_out_elastic(progress_ratio: float) -> float: """Ease in/out using an elastic function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ c5 = (2 * math.pi) / 4.5 if progress_ratio == 0: return 0 if progress_ratio == 1: return 1 if progress_ratio < 0.5: return -(2 ** (20 * progress_ratio - 10) * math.sin((20 * progress_ratio - 11.125) * c5)) / 2 return (2 ** (-20 * progress_ratio + 10) * math.sin((20 * progress_ratio - 11.125) * c5)) / 2 + 1 def in_bounce(progress_ratio: float) -> float: """Ease in using a bounce function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ return 1 - out_bounce(1 - progress_ratio) def out_bounce(progress_ratio: float) -> float: """Ease out using a bounce function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ n1 = 7.5625 d1 = 2.75 if progress_ratio < 1 / d1: return n1 * progress_ratio**2 if progress_ratio < 2 / d1: return n1 * (progress_ratio - 1.5 / d1) ** 2 + 0.75 if progress_ratio < 2.5 / d1: return n1 * (progress_ratio - 2.25 / d1) ** 2 + 0.9375 return n1 * (progress_ratio - 2.625 / d1) ** 2 + 0.984375 def in_out_bounce(progress_ratio: float) -> float: """Ease in/out using a bounce function. Args: progress_ratio (float): the ratio of the current step to the maximum steps Returns: float: 0 <= n <= 1 eased value """ if progress_ratio < 0.5: return (1 - out_bounce(1 - 2 * progress_ratio)) / 2 return (1 + out_bounce(2 * progress_ratio - 1)) / 2 def make_easing(x1: float, y1: float, x2: float, y2: float) -> EasingFunction: """Create a cubic Bezier easing function using the provided control points. The easing function maps an input progress ratio (0 to 1) to an output value (0 to 1) according to a cubic Bezier curve defined by four points: - Start point: (0, 0) - First control point: (x1, y1) - Second control point: (x2, y2) - End point: (1, 1) Args: x1 (float): Determines the horizontal position of the first control point. Smaller values make the curve start off steeper, while larger values delay the initial acceleration. y1 (float): Determines the vertical position of the first control point. Smaller values create a gentler ease-in effect; larger values increase the initial acceleration. x2 (float): Determines the horizontal position of the second control point. Larger values extend the period of change, affecting how late the acceleration or deceleration begins. y2 (float): Determines the vertical position of the second control point. Larger values can create a more abrupt ease-out effect; smaller values result in a smoother finish. Note: Use a resource such as cubic-bezier.com to design an appropriate easing curve for your needs. Returns: EasingFunction: A function that takes a progress_ratio (0 <= progress_ratio <= 1) and returns the eased value computed from the cubic Bezier curve. """ # Compute Bezier curve x for a given parameter t. def sample_curve_x(t: float) -> float: return 3 * x1 * (1 - t) ** 2 * t + 3 * x2 * (1 - t) * t**2 + t**3 # Compute Bezier curve y for a given parameter t. def sample_curve_y(t: float) -> float: return 3 * y1 * (1 - t) ** 2 * t + 3 * y2 * (1 - t) * t**2 + t**3 # Compute derivative of curve x with respect to t. def sample_curve_derivative_x(t: float) -> float: return 3 * (1 - t) ** 2 * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t**2 * (1 - x2) def bezier_easing(progress: float) -> float: # Clamp progress between 0 and 1. if progress <= 0: return 0 if progress >= 1: return 1 # Find t such that sample_curve_x(t) is close to progress. t = progress # initial guess for _ in range(20): x_est = sample_curve_x(t) dx = x_est - progress if abs(dx) < 1e-5: break d = sample_curve_derivative_x(t) if abs(d) < 1e-6: break t -= dx / d return sample_curve_y(t) return functools.wraps(bezier_easing)(functools.lru_cache(maxsize=8192)(bezier_easing)) make_easing = functools.wraps(make_easing)(functools.lru_cache(maxsize=8192)(make_easing)) @dataclass class EasingTracker: """Describe the progression of items as an easing function is applied over a sequence. Attributes: easing_function (EasingFunction): The easing function being tracked. total_steps (int): The total number of steps for the easing function. clamp (bool): Whether eased values should be clamped to the range `[0, 1]` after each step. current_step (int): The current step in the easing progression. progress_ratio (float): The ratio of the current step to the total steps. step_delta (float): The change in eased value from the last step to the current step. eased_value (float): The current eased value based on the easing function and progress ratio. Methods: step() -> float: Advance the easing tracker by one step and return the new eased value. is_complete() -> bool: Check if the easing tracker has completed all steps. """ easing_function: EasingFunction total_steps: int = 100 clamp: InitVar[bool] = field(default=False) def __post_init__(self, clamp: bool) -> None: """Initialize the EasingTracker. Args: clamp (bool, optional): If True, clamp the eased value between 0 and 1. Defaults to False. """ self._clamp = clamp self.current_step: int = 0 self.progress_ratio: float = 0.0 self.step_delta: float = 0.0 self.eased_value: float = 0.0 self._last_eased_value: float = 0.0 def step(self) -> float: """Advance the easing tracker by one step. If the current step is less than the total steps, increment the current step, update the progress ratio, compute the new eased value using the easing function, and calculate the step delta. If clamp is enabled, the eased value is constrained between 0 and 1. Returns: float: The new eased value after advancing one step. """ if self.current_step < self.total_steps: self.current_step += 1 self.progress_ratio = self.current_step / self.total_steps self.eased_value = self.easing_function(self.progress_ratio) if self._clamp: self.eased_value = max(0.0, min(self.eased_value, 1.0)) self.step_delta = self.eased_value - self._last_eased_value self._last_eased_value = self.eased_value return self.eased_value def reset(self) -> None: """Reset the easing tracker to the initial state.""" self.current_step = 0 self.progress_ratio = 0.0 self.step_delta = 0.0 self.eased_value = 0.0 self._last_eased_value = 0.0 def is_complete(self) -> bool: """Check if the easing tracker has completed all steps. Returns: bool: True if all steps have been completed, False otherwise. """ return self.current_step >= self.total_steps def __iter__(self) -> typing.Iterator[float]: """Iterate over eased values until completion. Yields: float: The eased value at each step. """ while not self.is_complete(): yield self.step() _T = typing.TypeVar("_T") @dataclass class SequenceEaser(typing.Generic[_T]): """Eases over the leading portion of a sequence, tracking added, removed, and total elements. At each step, `total` is the contiguous slice `sequence[:length]` determined by the current eased progress. `added` and `removed` describe the change from the previous step. Attributes: sequence (Sequence[_T]): The sequence to ease over. easing_function (EasingFunction): The easing function to use. total_steps (int): The total number of steps for the easing function. added (Sequence[_T]): Contiguous slice of newly included elements from the current step. removed (Sequence[_T]): Contiguous slice of elements removed from the previous step. total (Sequence[_T]): Current leading slice of `sequence` selected by eased progress. """ sequence: typing.Sequence[_T] easing_function: EasingFunction total_steps: int = 100 added: typing.Sequence[_T] = field(init=False, default_factory=list) removed: typing.Sequence[_T] = field(init=False, default_factory=list) total: typing.Sequence[_T] = field(init=False, default_factory=list) def __post_init__(self) -> None: """Initialize the SequenceEaser.""" self.easing_tracker = EasingTracker( easing_function=self.easing_function, total_steps=self.total_steps, clamp=True, ) def step(self) -> typing.Sequence[_T]: """Advance the easing tracker by one step and update sequence state. `total` is updated to the current eased leading slice of `sequence`. If that slice grows, `added` contains the newly included elements. If it shrinks, `removed` contains the elements that were dropped. If its length is unchanged, both are empty. Returns: typing.Sequence[_T]: The elements added in the current step. """ previous_eased = self.easing_tracker.eased_value eased_value = self.easing_tracker.step() seq_len = len(self.sequence) if seq_len == 0: self.added = self.sequence[:0] self.removed = self.sequence[:0] self.total = self.sequence[:0] return self.added length = int(eased_value * seq_len) previous_length = int(previous_eased * seq_len) if length > previous_length: self.added = self.sequence[previous_length:length] self.removed = self.sequence[:0] elif length < previous_length: self.added = self.sequence[:0] self.removed = self.sequence[length:previous_length] else: self.added = self.sequence[:0] self.removed = self.sequence[:0] self.total = self.sequence[:length] return self.added def is_complete(self) -> bool: """Check if the easing over the sequence is complete. Returns: bool: True if all steps have been completed, False otherwise. """ return self.easing_tracker.is_complete() def reset(self) -> None: """Reset the SequenceEaser to the initial state.""" self.easing_tracker.reset() self.added = self.sequence[:0] self.removed = self.sequence[:0] self.total = self.sequence[:0] terminaltexteffects-release-0.15.0/terminaltexteffects/utils/exceptions/000077500000000000000000000000001517776150200267075ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/exceptions/__init__.py000066400000000000000000000015031517776150200310170ustar00rootroot00000000000000"""TerminalTextEffects exceptions module.""" from terminaltexteffects.utils.exceptions.animation_exceptions import ( ActivateEmptySceneError, AnimationSceneError, FrameDurationError, SceneNotFoundError, ) from terminaltexteffects.utils.exceptions.base_character_exceptions import ( DuplicateEventRegistrationError, EventRegistrationCallerError, EventRegistrationTargetError, ) from terminaltexteffects.utils.exceptions.motion_exceptions import ( ActivateEmptyPathError, DuplicatePathIDError, DuplicateWaypointIDError, PathInvalidSpeedError, PathNotFoundError, WaypointNotFoundError, ) from terminaltexteffects.utils.exceptions.terminal_exceptions import ( InvalidCharacterGroupError, InvalidCharacterSortError, InvalidColorSortError, UnsupportedAnsiSequenceError, ) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/exceptions/animation_exceptions.py000066400000000000000000000050771517776150200335120ustar00rootroot00000000000000"""Custom exceptions for handling errors related to animations in the terminaltexteffects package. Classes: 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. """ from __future__ import annotations from typing import TYPE_CHECKING from terminaltexteffects.utils.exceptions.base_terminaltexteffects_exception import TerminalTextEffectsError if TYPE_CHECKING: from terminaltexteffects import Scene class FrameDurationError(TerminalTextEffectsError): """Raised when a frame is added to a Scene with an invalid duration. A frame duration must be a positive integer. This error is raised when a frame is added to a Scene with a duration that is not a positive integer. """ def __init__(self, duration: int) -> None: """Initialize a FrameDurationError. Args: duration (int): The duration provided to the frame. """ self.duration = duration self.message = f"Frame duration must be a positive integer. Received: `{duration}`." super().__init__(self.message) class ActivateEmptySceneError(TerminalTextEffectsError): """Raised when a Scene is without any frames is activated. A Scene must have at least one frame to be activated. """ def __init__(self, scene: Scene) -> None: """Initialize an ActivateEmptySceneError. Args: scene (Scene): The Scene that was activated. """ self.scene = scene self.message = f"Scene `{scene.scene_id}` has no frames. A Scene must have at least one frame to be activated." super().__init__(self.message) class AnimationSceneError(TerminalTextEffectsError): """Generic Scene/animation error with a provided message.""" def __init__(self, message: str) -> None: """Initialize an AnimationSceneError. Args: message (str): The message to display. """ self.message = message super().__init__(message) class SceneNotFoundError(TerminalTextEffectsError): """Raised when `query_scene` is called with a scene_id that does not exist.""" def __init__(self, scene_id: str) -> None: """Initialize a SceneNotFoundError. Args: scene_id (str): The scene_id that was not found. """ self.scene_id = scene_id self.message = f"Scene with scene_id `{scene_id}` not found." super().__init__(self.message) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/exceptions/base_character_exceptions.py000066400000000000000000000120031517776150200344440ustar00rootroot00000000000000"""Custom exceptions for handling errors related to EffectCharacters in the terminaltexteffects package.""" from __future__ import annotations from typing import TYPE_CHECKING from terminaltexteffects.utils.exceptions.base_terminaltexteffects_exception import TerminalTextEffectsError if TYPE_CHECKING: from terminaltexteffects import Coord, EventHandler, Path, Scene, Waypoint class EventRegistrationCallerError(TerminalTextEffectsError): """Raised when an event is registered with an invalid event -> caller relationship. Each event can only be registered with a related caller type. This error is raised when an event is registered with a caller that is not of the required type. For example, a Scene will never trigger a Path related event, and vice versa. The following are the valid caller types for each event: Event -> Caller - SEGMENT_* -> Path - PATH_* -> Path - SCENE_* -> Scene """ def __init__( self, event: EventHandler.Event, caller: Scene | Waypoint | Path | str, required: type[Scene | Waypoint | Path], ) -> None: """Initialize an EventRegistrationCallerError. Args: event (EventHandler.Event): The event that was registered. caller (Scene | Waypoint | Path | str): The object provided to trigger the event. required (Scene | Waypoint | Path): The valid caller types for the event. """ self.event = event self.caller = caller self.required = required self.message = ( f"Event `{event.name}` registered with caller type `{caller.__class__.__name__}`. Event `{event.name}` " f"requires caller type `{required.__name__}`." ) super().__init__(self.message) class EventRegistrationTargetError(TerminalTextEffectsError): """Raised when an event is registered with an invalid action -> target relationship. Each event action can only be registered with a related target type. This error is raised when an event action is registered with a target that is not of the required type. For example, an ACTIVATE_SCENE action will can not activate on a Path target. The following are the valid target types for each action: Action -> Target - *_SCENE -> Scene - *_PATH -> Path - SET_LAYER -> Int - SET_COORDINATE -> Coord - CALLBACK -> EventHandler.Callback - RESET_APPEARANCE -> None """ def __init__( self, action: EventHandler.Action, target: Scene | Path | int | Coord | EventHandler.Callback | str | None, required: type[Scene | Path | int | Coord | EventHandler.Callback | str | None], ) -> None: """Initialize an EventRegistrationTargetError. Args: action (EventHandler.Action): The action that was registered. target (Scene | Path | int | Coord | EventHandler.Callback | str | None): The target provided to the action. required (type[Scene | Path | int | Coord | EventHandler.Callback | str | None]): The valid target types. """ self.action = action self.target = target self.required = required self.message = ( f"Event action `{action.name}` registered with target type `{target.__class__.__name__}`. " f"Action `{action.name}` requires target type `{required.__name__}`." ) super().__init__(self.message) class DuplicateEventRegistrationError(TerminalTextEffectsError): """Raised when attempting to register a duplicate event-action combination. This error is raised when trying to register the same event-caller-action-target combination that has already been registered. Each unique combination can only be registered once to prevent duplicate event handling. """ def __init__( self, event: EventHandler.Event, caller: Scene | Waypoint | Path, action: EventHandler.Action, target: Scene | Path | int | Coord | EventHandler.Callback | str | None, ) -> None: """Initialize a DuplicateEventRegistrationError. Args: event (EventHandler.Event): The event that was already registered. caller (Scene | Waypoint | Path): The caller object that was already registered. action (EventHandler.Action): The action that was already registered. target (Scene | Path | int | Coord | EventHandler.Callback | str | None): The target that was already registered. """ self.event = event self.caller = caller self.action = action self.target = target self.message = ( f"Duplicate event registration: Event `{event.name}` with caller `{caller.__class__.__name__}`, " f"action `{action.name}`, and target `{target.__class__.__name__ if target is not None else 'None'}` " f"has already been registered." ) super().__init__(self.message) base_terminaltexteffects_exception.py000066400000000000000000000002711517776150200363320ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/exceptions"""Base class for exceptions in the terminaltexteffects package.""" class TerminalTextEffectsError(Exception): """Base class for exceptions in the terminaltexteffects package.""" terminaltexteffects-release-0.15.0/terminaltexteffects/utils/exceptions/motion_exceptions.py000066400000000000000000000070121517776150200330270ustar00rootroot00000000000000"""Custom exceptions for handling errors related to motion in the terminaltexteffects package.""" from __future__ import annotations from terminaltexteffects.utils.exceptions.base_terminaltexteffects_exception import TerminalTextEffectsError class PathInvalidSpeedError(TerminalTextEffectsError): """Raised when a Path is initialized with an invalid speed. A Path must be initialized with a speed that is a positive float. This error is raised when a Path is initialized with a speed that is not a positive float. """ def __init__(self, speed: float) -> None: """Initialize a PathInvalidSpeedError. Args: speed (float): The speed provided to the Path. """ self.speed = speed self.message = f"Path speed must be a positive float. Received: `{speed}`." super().__init__(self.message) class WaypointNotFoundError(TerminalTextEffectsError): """Raised when a Waypoint is not found in a Path. A WaypointNotFoundError is raised when a Waypoint with the given ID is not found in a Path. """ def __init__(self, waypoint_id: str) -> None: """Initialize a WaypointNotFoundError. Args: waypoint_id (str): The waypoint ID queried. """ self.waypoint_id = waypoint_id self.message = f"Waypoint `{waypoint_id}` not found in Path." super().__init__(self.message) class PathNotFoundError(TerminalTextEffectsError): """Raised when a Path is not found. A PathNotFoundError is raised when a Path with the given ID is not found. """ def __init__(self, path_id: str) -> None: """Initialize a PathNotFoundError. Args: path_id (str): The path ID queried. """ self.path_id = path_id self.message = f"Path `{path_id}` not found." super().__init__(self.message) class ActivateEmptyPathError(TerminalTextEffectsError): """Raised when attempting to activate an empty Path. An ActivateEmptyPathError is raised when attempting to activate a Path that has no Waypoints. """ def __init__(self, path_id: str) -> None: """Initialize an ActivateEmptyPathError. Args: path_id (str): The ID of the Path that is empty. """ self.path_id = path_id self.message = f"Cannot activate an empty Path `{path_id}`." super().__init__(self.message) class DuplicatePathIDError(TerminalTextEffectsError): """Raised when a Path is initialized with a duplicate ID. A DuplicatePathIDError is raised when a Path is initialized with an ID that has already been used. """ def __init__(self, path_id: str) -> None: """Initialize a DuplicatePathIDError. Args: path_id (str): The ID provided to the Path. """ self.path_id = path_id self.message = f"Path ID `{path_id}` has already been used." super().__init__(self.message) class DuplicateWaypointIDError(TerminalTextEffectsError): """Raised when a Waypoint is initialized with a duplicate ID. A DuplicateWaypointIDError is raised when a Waypoint is initialized with an ID that has already been used in the Path. """ def __init__(self, waypoint_id: str) -> None: """Initialize a DuplicateWaypointIDError. Args: waypoint_id (str): The ID provided to the Waypoint. """ self.waypoint_id = waypoint_id self.message = f"Waypoint ID `{waypoint_id}` has already been used." super().__init__(self.message) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/exceptions/terminal_exceptions.py000066400000000000000000000063471517776150200333470ustar00rootroot00000000000000"""Custom exceptions for handling errors related to the Terminal in the terminaltexteffects package.""" from __future__ import annotations from typing import TYPE_CHECKING from terminaltexteffects.utils.exceptions.base_terminaltexteffects_exception import TerminalTextEffectsError if TYPE_CHECKING: from terminaltexteffects.utils.argutils import CharacterGroup, CharacterSort, ColorSort class InvalidCharacterGroupError(TerminalTextEffectsError): """Raised when an invalid character group is provided to a Terminal method. An InvalidCharacterGroupError is raised when a character group is provided to a Terminal method that is not a valid character group. Ref CharacterGroup. """ def __init__(self, character_group: CharacterGroup | str) -> None: """Initialize an InvalidCharacterGroupError. Args: character_group (CharacterGroup | str): The character group provided to the Terminal method. """ self.character_group = character_group self.message = f"Invalid character group provided: `{character_group}`. Ref CharacterGroup." super().__init__(self.message) class InvalidCharacterSortError(TerminalTextEffectsError): """Raised when an invalid character sort is provided to a Terminal method. An InvalidCharacterSortError is raised when a character sort is provided to a Terminal method that is not a valid character sort. Ref CharacterSort. """ def __init__(self, character_sort: CharacterSort | str) -> None: """Initialize an InvalidCharacterSortError. Args: character_sort (CharacterSort | str): The character sort provided to the Terminal method. """ self.character_sort = character_sort self.message = f"Invalid character sort provided: `{character_sort}`. Ref CharacterSort." super().__init__(self.message) class InvalidColorSortError(TerminalTextEffectsError): """Raised when an invalid color sort is provided to a Terminal method. An InvalidColorSortError is raised when a color sort is provided to a Terminal method that is not a valid color sort. Ref ColorSort. """ def __init__(self, color_sort: ColorSort) -> None: """Initialize an InvalidColorSortError. Args: color_sort (ColorSort): The color sort provided to the Terminal method. """ self.color_sort = color_sort self.message = f"Invalid color sort provided: `{color_sort}`. Ref ColorSort." super().__init__(self.message) class UnsupportedAnsiSequenceError(TerminalTextEffectsError): """Raised when terminal input contains ANSI/control sequences TTE does not support.""" def __init__(self, sequence: str) -> None: """Initialize an UnsupportedAnsiSequenceError. Args: sequence (str): The unsupported ANSI/control sequence found in the input. """ self.sequence = sequence self.message = ( f"Unsupported ANSI/control sequence in input: {sequence!r}. " "TerminalTextEffects supports common SGR foreground/background color sequences, fetch-style CSI cursor " "movement, and selected DEC private mode toggles." ) super().__init__(self.message) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/geometry.py000066400000000000000000000325051517776150200267400ustar00rootroot00000000000000"""Utility functions for geometric calculations and operations. The purpose of these functions is to find terminal coordinates that fall within certain regions or along certain paths. These functions are used by effects to enable more complex animations and movement paths. Functions: find_coords_on_circle: Finds points on a circle given the origin, radius, and number of points. find_coords_in_circle: Finds coordinates within an ellipse given the center and major axis length. find_coords_in_rect: Finds coordinates within a rectangle given the origin and distance. extrapolate_along_ray: Finds the coordinate past a target along the ray from an origin. find_coord_on_bezier_curve: Finds points on a bezier curve. find_coord_on_line: Finds points on a line. find_length_of_bezier_curve: Finds the length of a quadratic or cubic bezier curve. find_length_of_line: Finds the length of a line intersecting two coordinates. find_normalized_distance_from_center: Returns the normalized distance from the center of the Canvas. """ from __future__ import annotations import functools import math from dataclasses import dataclass from typing import Iterator @dataclass(eq=True, frozen=True) class Coord: """A coordinate with row and column values. Args: column (int): column value row (int): row value """ def __iter__(self) -> Iterator[int]: """Allow tuple unpacking by yielding the column and row. Yields: column, row: yield the column, followed by the row """ yield self.column yield self.row column: int row: int def find_coords_on_circle(origin: Coord, radius: int, coords_limit: int = 0, *, unique: bool = True) -> list[Coord]: """Find points on a circle. Args: origin (Coord): origin of the circle radius (int): radius of the circle coords_limit (int): limit the number of coords returned, if 0, the number of points is calculated based on the circumference of the circle unique (bool): whether to remove duplicate points. Defaults to True. Returns: list (Coord): list of Coord points on the circle """ points: list[Coord] = [] if not radius: return points seen_points = set() if not coords_limit: coords_limit = round(2 * math.pi * radius) angle_step = 2 * math.pi / coords_limit for i in range(coords_limit): angle = angle_step * i x = origin.column + radius * math.cos(angle) # correct for terminal character height/width ratio by doubling the x distance from origin x_diff = x - origin.column x += x_diff y = origin.row + radius * math.sin(angle) point_coord = Coord(round(x), round(y)) if unique: if point_coord not in seen_points: points.append(point_coord) else: points.append(point_coord) seen_points.add(point_coord) return points find_coords_on_circle = functools.wraps(find_coords_on_circle)(functools.lru_cache(maxsize=8192)(find_coords_on_circle)) def find_coords_in_circle(center: Coord, diameter: int) -> list[Coord]: """Find the coordinates within a circle with the given center and diameter. The actual shape calculated is an ellipse with a major axis of length diameter, however the terminal cell height/width ratio creates a circle visually. Args: center (Coord): The center coordinate of the circle. diameter (int): The length of the major axis of the circle. Returns: list[Coord]: A list of coordinates within the circle. """ h, k = center.column, center.row coords_in_ellipse: list[Coord] = [] if not diameter: return coords_in_ellipse a_squared = diameter**2 b_squared = (diameter / 2) ** 2 for x in range(h - diameter, h + diameter + 1): x_component = ((x - h) ** 2) / a_squared max_y_offset = int((b_squared * (1 - x_component)) ** 0.5) for y in range(k - max_y_offset, k + max_y_offset + 1): coords_in_ellipse.append(Coord(x, y)) # noqa: PERF401 return coords_in_ellipse find_coords_in_circle = functools.wraps(find_coords_in_circle)(functools.lru_cache(maxsize=8192)(find_coords_in_circle)) def find_coords_in_rect(origin: Coord, distance: int) -> list[Coord]: """Find coords that fall within a rectangle. Distance specifies the number of units in each direction from the origin. For positive distances, the resulting rectangle has width and height `2 * distance + 1`. A distance of `0` returns an empty list. Args: origin (Coord): center of the rectangle distance (int): distance from the origin Returns: list[Coord]: list of Coord points in the rectangle """ left_boundary = origin.column - distance right_boundary = origin.column + distance top_boundary = origin.row - distance bottom_boundary = origin.row + distance coords: list[Coord] = [] if not distance: return coords for column in range(left_boundary, right_boundary + 1): for row in range(top_boundary, bottom_boundary + 1): coords.append(Coord(column, row)) # noqa: PERF401 return coords find_coords_in_rect = functools.wraps(find_coords_in_rect)(functools.lru_cache(maxsize=8192)(find_coords_in_rect)) def find_coords_on_rect(origin: Coord, half_width: int, half_height: int) -> list[Coord]: """Find coords on the perimeter of a rectangle. Half width and half height specify the distance in each direction from the origin. Returns coordinates that fall on the perimeter (edges) of the rectangle only. If either `half_width` or `half_height` is `0`, an empty list is returned. Args: origin (Coord): center of the rectangle half_width (int): half the width of the rectangle half_height (int): half the height of the rectangle Returns: list[Coord]: list of Coord points in the rectangle """ coords: list[Coord] = [] if not half_width or not half_height: return coords for column in range(origin.column - half_width, origin.column + half_width + 1): if column == origin.column - half_width or column == origin.column + half_width: for row in range(origin.row - half_height, origin.row + half_height + 1): coords.append(Coord(column, row)) # noqa: PERF401 else: coords.append(Coord(column, origin.row - half_height)) coords.append(Coord(column, origin.row + half_height)) return coords find_coords_on_rect = functools.wraps(find_coords_on_rect)(functools.lru_cache(maxsize=8192)(find_coords_on_rect)) def extrapolate_along_ray(origin: Coord, target: Coord, offset_from_target: float) -> Coord: """Return the point `offset_from_target` units past `target` along the `origin -> target` ray. The coordinate returned is approximately `offset_from_target` units away from the target coordinate, away from the origin coordinate. Args: origin (Coord): origin coordinate (a) target (Coord): target coordinate (b) offset_from_target (float): distance from the target coordinate (b), away from the origin coordinate (a) Returns: Coord: Coordinate at the given distance (c). """ total_distance = find_length_of_line(origin, target) + offset_from_target if total_distance == 0 or origin == target: return target t = total_distance / find_length_of_line(origin, target) next_column, next_row = ( ((1 - t) * origin.column + t * target.column), ((1 - t) * origin.row + t * target.row), ) return Coord(round(next_column), round(next_row)) extrapolate_along_ray = functools.wraps(extrapolate_along_ray)( functools.lru_cache(maxsize=8192)(extrapolate_along_ray), ) def find_coord_on_bezier_curve(start: Coord, control: tuple[Coord, ...], end: Coord, t: float) -> Coord: """Find points on a bezier curve of any degree. Args: start (Coord): The starting coordinate of the curve. control (tuple[Coord, ...]): The control points of the curve. end (Coord): The ending coordinate of the curve. t (float): The distance factor between the start and end coordinates. Returns: Coord: The coordinate on the bezier curve corresponding to the given parameter value. """ points = [start, *list(control), end] def de_casteljau(points: list[Coord], t: float): # noqa: ANN202 if len(points) == 1: return points[0] new_points = [] for i in range(len(points) - 1): x = (1 - t) * points[i].column + t * points[i + 1].column y = (1 - t) * points[i].row + t * points[i + 1].row new_points.append(Coord(x, y)) # type: ignore[arg-type] return de_casteljau(new_points, t) result = de_casteljau(points, t) return Coord(round(result.column), round(result.row)) find_coord_on_bezier_curve = functools.wraps(find_coord_on_bezier_curve)( functools.lru_cache(maxsize=16384)(find_coord_on_bezier_curve), ) def find_coord_on_line(start: Coord, end: Coord, t: float) -> Coord: """Find points on a line. Args: start (Coord): The starting coordinate of the line. end (Coord): The ending coordinate of the line. t (float): The distance factor between the start and end coordinates. Returns: Coord: The coordinate on the line corresponding to the given parameter value. """ x = (1 - t) * start.column + t * end.column y = (1 - t) * start.row + t * end.row return Coord(round(x), round(y)) find_coord_on_line = functools.wraps(find_coord_on_line)(functools.lru_cache(maxsize=16384)(find_coord_on_line)) def find_length_of_bezier_curve(start: Coord, control: tuple[Coord, ...] | Coord, end: Coord) -> float: """Approximate the length of a bezier curve. The curve is sampled at evenly spaced parameter values and the total length is estimated by summing line lengths between successive sampled points using terminal-adjusted row distances. Args: start (Coord): The starting coordinate of the curve. control (tuple[Coord, ...] | Coord): The control point(s) of the curve. end (Coord): The ending coordinate of the curve. Returns: float: The length of the bezier curve. """ if isinstance(control, Coord): control = (control,) length = 0.0 prev_coord = start for t in range(1, 10): coord = find_coord_on_bezier_curve(start, control, end, t / 10) length += find_length_of_line(prev_coord, coord, double_row_diff=True) prev_coord = coord prev_coord = coord return length find_length_of_bezier_curve = functools.wraps(find_length_of_bezier_curve)( functools.lru_cache(maxsize=4096)(find_length_of_bezier_curve), ) def find_length_of_line(coord1: Coord, coord2: Coord, *, double_row_diff: bool = False) -> float: """Return the length of the line intersecting coord1 and coord2. If double_row_diff is True, the row (y) distance is doubled to account for the terminal character height/width ratio. Args: coord1 (Coord): first coordinate. coord2 (Coord): second coordinate. double_row_diff (bool, optional): whether to double the row difference to account for terminal character height/width ratio. Defaults to False. Returns: float: length of the line """ column_diff = coord2.column - coord1.column row_diff = coord2.row - coord1.row if double_row_diff: return math.hypot(column_diff, 2 * row_diff) return math.hypot(column_diff, row_diff) find_length_of_line = functools.wraps(find_length_of_line)(functools.lru_cache(maxsize=8192)(find_length_of_line)) def find_normalized_distance_from_center(bottom: int, top: int, left: int, right: int, other_coord: Coord) -> float: """Return the normalized distance from the center of a rectangle on the Canvas as a float between 0 and 1. The distance is calculated using the Pythagorean theorem and accounts for the aspect ratio of the terminal. Args: bottom (int): Bottom row of the rectangle on the Canvas. top (int): Top row of the rectangle on the Canvas. left (int): Left column of the rectangle on the Canvas. right (int): Right column of the rectangle on the Canvas. other_coord (Coord): Other coordinate from which to calculate the distance to the center of the rectangle. Returns: float: Normalized distance from the center of the rectangle on the Canvas, float between 0 and 1. """ y_offset = bottom - 1 x_offset = left - 1 right = right - x_offset top = top - y_offset center_x = right / 2 center_y = top / 2 if (other_coord.column - x_offset) not in range(left - x_offset, right + 1) or ( other_coord.row - y_offset ) not in range(bottom - y_offset, top + 1): msg = "Coordinate is not within the rectangle." raise ValueError(msg) max_distance = ((right**2) + ((top * 2) ** 2)) ** 0.5 distance = ( ((other_coord.column - x_offset) - center_x) ** 2 + (((other_coord.row - y_offset) - center_y) * 2) ** 2 ) ** 0.5 return distance / (max_distance / 2) find_normalized_distance_from_center = functools.wraps(find_normalized_distance_from_center)( functools.lru_cache(maxsize=8192)(find_normalized_distance_from_center), ) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/graphics.py000066400000000000000000000506731517776150200267130ustar00rootroot00000000000000"""Classes for storing and manipulating character graphics. Classes: Color: Represents a color in the RGB color space. Can be initialized with an XTerm-256 color code or an RGB hex color string. ColorPair: Represents a pair of colors to specify a character's foreground and background colors. Gradient: A list of `Color` objects transitioning from one color stop to another. Supports various gradient directions. """ from __future__ import annotations import functools import itertools import random import typing from dataclasses import InitVar, dataclass, field from enum import Enum, auto from terminaltexteffects.utils import ansitools, colorterm, geometry, hexterm if typing.TYPE_CHECKING: from collections.abc import Iterator class Color: """Represents a color in the RGB color space. The color can be initialized with an XTerm-256 color code or an RGB hex color string. Can be printed to display the color code and appearance as a color block. Attributes: color_arg (int | str): The color value as an XTerm-256 color code or an RGB hex color string. xterm_color (int | None): The XTerm-256 color code. None if the color is an RGB hex color string. rgb_color (str): The RGB hex color string. Properties: rgb_ints (tuple[int, int, int]): Returns the RGB values as a tuple of integers. Raises: ValueError: If the color value is not a valid XTerm-256 color code or an RGB hex color string. """ def __init__(self, color_value: int | str) -> None: """Initialize a Color object. Args: color_value (int | str): The color value as an XTerm-256 color code or an RGB hex color string. Example: 255 or 'ffffff' or '#ffffff' Raises: ValueError: If the color value is not a valid XTerm-256 color code or an RGB hex color string. """ if isinstance(color_value, str): color_value = color_value.strip("#") self.color_arg = color_value self.xterm_color: int | None = None if hexterm.is_valid_color(color_value): if isinstance(color_value, int): self.xterm_color = color_value self.rgb_color = hexterm.xterm_to_hex(color_value) else: self.rgb_color = color_value self.xterm_color = None else: msg = ( "Invalid color value. Color must be an XTerm-256 color code or an RGB hex color string. " "Example: 255 or 'ffffff' or '#ffffff'" ) raise ValueError( msg, ) @property def rgb_ints(self) -> tuple[int, int, int]: """Returns the RGB values as a tuple of integers. Returns: tuple[int, int, int]: The RGB values as a tuple of integers. """ return colorterm._hex_to_int(self.rgb_color) def __repr__(self) -> str: """Return a string representation of the Color object.""" return f"Color('{self.color_arg}')" def __str__(self) -> str: """Return a string representation of the Color object.""" color_block = f"{colorterm.fg(self.rgb_color)}█████{ansitools.reset_all()}" xterm_display = f" | XTerm Color: {self.xterm_color}" if self.xterm_color is not None else "" return ( f"Color Code: {self.rgb_color}{xterm_display}" f"\nColor Appearance: {color_block}" ) def __eq__(self, other: object) -> bool: """Return whether this color is equal to another `Color`. Returns `NotImplemented` when `other` is not a `Color`. """ if not isinstance(other, Color): return NotImplemented return self.color_arg == other.color_arg def __ne__(self, other: object) -> bool: """Return whether this color is not equal to another `Color`. Returns `NotImplemented` when `other` is not a `Color`. """ if not isinstance(other, Color): return NotImplemented return self.color_arg != other.color_arg def __hash__(self) -> int: """Return the hash value of the Color object.""" return hash(self.color_arg) def __iter__(self) -> Iterator[Color]: """Return an iterator yielding this `Color` instance once.""" return iter((self,)) @dataclass() class ColorPair: """Represents a pair of colors to specify a character's foreground and background colors. On init, `Color` instances are preserved, non-`Color` non-`None` values are converted to `Color`, and `None` values remain unset. Attributes: fg_color (Color | None): The foreground color. None if no foreground color is specified. bg_color (Color | None): The background color. None if no background color is specified. fg (InitVar[Color | str | int | None]): The initial foreground color value. bg (InitVar[Color | str | int | None]): The initial background color value. """ fg_color: Color | None = field(init=False, default=None) bg_color: Color | None = field(init=False, default=None) fg: InitVar[Color | str | int | None] = None bg: InitVar[Color | str | int | None] = None def __post_init__(self, init_fg_color: Color | str | int | None, init_bg_color: Color | str | int | None) -> None: """Normalize the initial foreground and background values. `Color` instances are preserved, non-`Color` non-`None` values are converted to `Color`, and `None` values remain unset. """ if init_fg_color is not None and not isinstance(init_fg_color, Color): self.fg_color = Color(init_fg_color) else: self.fg_color = init_fg_color if init_bg_color is not None and not isinstance(init_bg_color, Color): self.bg_color = Color(init_bg_color) else: self.bg_color = init_bg_color def __str__(self) -> str: """Return a string representation of the ColorPair object.""" color_block = ( f"{colorterm.fg(self.fg_color.rgb_color) if self.fg_color else ''}" f"{colorterm.bg(self.bg_color.rgb_color) if self.bg_color else ''}####{ansitools.reset_all()}" ) return ( f"Foreground Color Code: {self.fg_color.rgb_color if self.fg_color else ''}" f"{f' | Foreground XTerm Color: {self.fg_color.xterm_color}' if self.fg_color and self.fg_color.xterm_color is not None else ''}\n" # noqa: E501 f"Background Color Code: {self.bg_color.rgb_color if self.bg_color else ''}" f"{f' | Background XTerm Color: {self.bg_color.xterm_color}' if self.bg_color and self.bg_color.xterm_color is not None else ''}" # noqa: E501 f"\nColor Appearance: {color_block}" ) class Gradient: """A Gradient is a list of `Color` objects transitioning from one color stop to another. The gradient color list is calculated using linear interpolation based on the provided start and end colors and the number of steps. Gradients can be iterated over to get the next color in the gradient color list. If there is only one color in the stops list, the gradient will be a list of the same color. If multiple steps are given, the gradient between pairs of colors will be equal to the number of steps for the pair based on the order of stops and steps. Ex: stops = ("ffffff", "aaaaaa", "000000"), steps = (6, 3) "fffffff" -> (6 steps) -> "aaaaaa" -> (3 steps) -> "000000" The step count includes the stop for each pair. Total number of colors in the resulting gradient spectrum is the sum of the steps between each pair of stops plus 1. Attributes: spectrum (list[Color]): List (length=sum(steps) + 1) of generated `Color` objects. """ class Direction(Enum): """Enum for specifying the direction of the gradient.""" VERTICAL = auto() HORIZONTAL = auto() RADIAL = auto() DIAGONAL = auto() def __init__(self, *stops: Color, steps: tuple[int, ...] | int = 1, loop: bool = False) -> None: """Initialize a Gradient object. Args: stops (Color): One ore more variables of type Color representing the color stops. steps (int | tuple[int, ...], optional): Number of steps or a tuple of step values for generating the spectrum. Defaults to 1. loop (bool, optional): Loop the gradient. This causes the final gradient color to transition back to the first gradient color. Defaults to False. Raises: ValueError: If no color stops are provided. Attributes: _stops (tuple[Color]): Tuple of Color objects representing the color stops. _steps (int | tuple[int, ...]): Number of steps or a tuple of step values for generating the spectrum. _loop (bool): Loop the gradient. This causes the final gradient color to transition back to the first gradient color. spectrum (list[Color]): List of generated `Color` objects representing the spectrum. _index (int): Current index of the spectrum. Returns: None """ self._stops = stops if len(self._stops) < 1: msg = "At least one stop must be provided." raise ValueError(msg) self._steps = steps self._loop = loop self.spectrum: list[Color] = self._generate(self._steps) self._index: int = 0 def get_color_at_fraction(self, fraction: float) -> Color: """Return the precomputed spectrum color corresponding to a normalized fraction. The fraction is matched against the generated spectrum from start to end, so `0` returns the first color and `1` returns the final color. Args: fraction (float): The fraction of the gradient to get the color for. Returns: Color: The color at the fraction of the gradient. """ if fraction < 0 or fraction > 1: msg = "Fraction must be 0 <= fraction <= 1." raise ValueError(msg) for i in range(1, len(self.spectrum) + 1): if fraction <= i / len(self.spectrum): return self.spectrum[i - 1] return self.spectrum[-1] def _generate(self, steps: int | tuple[int, ...]) -> list[Color]: """Calculate a gradient of colors between two colors using linear interpolation. If there is only one color in the stops tuple, the gradient will be a list of the same color. If multiple steps are given, the gradient between pairs of colors will be equal to the number of steps for the pair based on the order of stops and steps. Ex: stops = ("ffffff", "aaaaaa", "000000"), steps = (6, 3) Distance from "ffffff" to "aaaaaa" = 6 steps (7 colors including start and end) Distance from "aaaaaa" to "000000" = 3 steps (4 colors including start and end) Total colors in the gradient spectrum = 10 ("aaaaaa" is not repeated when transitioning from "ffffff" to "aaaaaa" and from "aaaaaa" to "000000") The step count includes the stop for each pair. Total number of colors in the resulting gradient spectrum: sum(steps) + 1 Returns: list[Color]: List (length=sum(steps) + 1) of generated `Color` objects. The first and last colors are the start and end stops, respectively. """ if isinstance(steps, int): steps = (steps,) for step in steps: if step < 1: msg = "Steps must be greater than 0." raise ValueError(msg) spectrum: list[Color] = [] if len(self._stops) == 1: color = self._stops[0] spectrum.extend(color for _ in range(steps[0])) return spectrum if self._loop: self._stops = (*self._stops, self._stops[0]) a, b = itertools.tee(self._stops) next(b, None) color_pairs = list(zip(a, b)) steps = steps[: len(color_pairs)] if len(steps) < len(color_pairs): steps = steps + (steps[-1],) * (len(color_pairs) - len(steps)) color_pair: tuple[Color, Color] for color_pair, step_count in zip(color_pairs, steps): if step_count < 1: msg = f"Invalid steps: {step_count} | Steps must be greater than 0." raise ValueError(msg) start, end = color_pair start_color_ints = start.rgb_ints end_color_ints = end.rgb_ints # Initialize an empty list to store the gradient colors gradient_colors: list[Color] = [] # Calculate the color deltas for each RGB value red_delta = (end_color_ints[0] - start_color_ints[0]) // step_count green_delta = (end_color_ints[1] - start_color_ints[1]) // step_count blue_delta = (end_color_ints[2] - start_color_ints[2]) // step_count # Calculate the intermediate colors and add them to the gradient colors list range_start = int(len(spectrum) > 0) # if this is the first pair, add the start color to the spectrum for i in range(range_start, max(step_count, 0)): red = start_color_ints[0] + (red_delta * i) green = start_color_ints[1] + (green_delta * i) blue = start_color_ints[2] + (blue_delta * i) # Ensure that the RGB values are within the valid range of 0-255 red = max(0, min(red, 255)) green = max(0, min(green, 255)) blue = max(0, min(blue, 255)) # Convert the RGB values to a hex color string and add it to the gradient colors list gradient_colors.append(Color(f"{red:02x}{green:02x}{blue:02x}")) # Add the end color to the gradient colors list gradient_colors.append(end) spectrum.extend(gradient_colors) return spectrum def build_coordinate_color_mapping( self, min_row: int, max_row: int, min_column: int, max_column: int, direction: Gradient.Direction, ) -> dict[geometry.Coord, Color]: """Build a mapping of coordinates to colors based on the gradient and a direction. For example, a vertical gradient will have the same color for each character in a row. When applied across all characters in the canvas, the gradient will be visible as a vertical gradient. The mapping respects the provided row and column bounds for every direction. Args: min_row (int): The minimum row value. Must be greater than 0 and less than or equal to max_row. max_row (int): The maximum row value. Must be greater than 0 and greater than or equal to min_row. min_column (int): The minimum column value. Must be greater than 0 and less than or equal to max_column. max_column (int): The maximum column value. Must be greater than 0 and greater than or equal to min_column. direction (Gradient.Direction): The direction of the gradient. Returns: dict[geometry.Coord, Color]: A mapping of coordinates to `Color` objects. """ if any(value < 1 for value in (max_row, max_column, min_row, min_column)): msg = "max_row and max_column must be greater than 0." raise ValueError(msg) if min_row > max_row or min_column > max_column: msg = "min_row and min_column must be less than or equal to max_row and max_column." raise ValueError(msg) row_offset = min_row - 1 column_offset = min_column - 1 gradient_mapping: dict[geometry.Coord, Color] = {} if direction == Gradient.Direction.VERTICAL: for row_value in range(min_row, max_row + 1): fraction = (row_value - row_offset) / (max_row - row_offset) color = self.get_color_at_fraction(fraction) for column_value in range(min_column, max_column + 1): gradient_mapping[geometry.Coord(column_value, row_value)] = color elif direction == Gradient.Direction.HORIZONTAL: for column_value in range(min_column, max_column + 1): fraction = (column_value - column_offset) / (max_column - column_offset) color = self.get_color_at_fraction(fraction) for row_value in range(min_row, max_row + 1): gradient_mapping[geometry.Coord(column_value, row_value)] = color elif direction == Gradient.Direction.RADIAL: for row_value in range(min_row, max_row + 1): for column_value in range(min_column, max_column + 1): distance_from_center = geometry.find_normalized_distance_from_center( min_row, max_row, min_column, max_column, geometry.Coord(column_value, row_value), ) color = self.get_color_at_fraction(distance_from_center) gradient_mapping[geometry.Coord(column_value, row_value)] = color elif direction == Gradient.Direction.DIAGONAL: for row_value in range(min_row, max_row + 1): for column_value in range(min_column, max_column + 1): fraction = (((row_value - row_offset) * 2) + (column_value - column_offset)) / ( ((max_row - row_offset) * 2) + (max_column - column_offset) ) color = self.get_color_at_fraction(fraction) gradient_mapping[geometry.Coord(column_value, row_value)] = color return gradient_mapping def __iter__(self) -> Iterator[Color]: """Return an iterator over the Gradient object.""" yield from self.spectrum def __len__(self) -> int: """Return the length of the Gradient object.""" return len(self.spectrum) @typing.overload def __getitem__(self, index: int) -> Color: ... @typing.overload def __getitem__(self, index: slice) -> list[Color]: ... def __getitem__(self, index: int | slice) -> Color | list[Color]: """Return the color at the given index or a list of colors based on the slice.""" return self.spectrum[index] def __str__(self) -> str: """Return a string representation of the Gradient object.""" color_blocks = [f"{colorterm.fg(color.rgb_color)}█{ansitools.reset_all()}" for color in self.spectrum] return f"Gradient: Stops({', '.join(c.rgb_color for c in self._stops)}), Steps({self._steps})\n" + "".join( color_blocks, ) def random_color() -> Color: """Return a random `Color` created from a six-digit RGB hex value. Returns: Color: A random color. """ return Color(f"{random.randint(0, 0xFFFFFF):06x}") def shift_color_towards(color: Color, target_color: Color, factor: float) -> Color: """Shift one color towards another by a given factor. A factor of `0` returns the original color and a factor of `1` returns the target color. Values between `0` and `1` interpolate between the two colors, while values outside that range extrapolate past the target or away from it. Args: color (Color): The original color. target_color (Color): The target color to shift towards. factor (float): Interpolation or extrapolation factor used to shift the color. Returns: Color: The resulting color after shifting. """ def interpolate(start: float, end: float, factor: float) -> float: """Interpolate between two values by a given factor.""" return start + (end - start) * factor # Normalize RGB values color_red = int(color.rgb_color[0:2], 16) / 255 color_green = int(color.rgb_color[2:4], 16) / 255 color_blue = int(color.rgb_color[4:6], 16) / 255 target_red = int(target_color.rgb_color[0:2], 16) / 255 target_green = int(target_color.rgb_color[2:4], 16) / 255 target_blue = int(target_color.rgb_color[4:6], 16) / 255 # Interpolate RGB values new_red = interpolate(color_red, target_red, factor) new_green = interpolate(color_green, target_green, factor) new_blue = interpolate(color_blue, target_blue, factor) # Convert back to hex shifted_color = f"{int(new_red * 255):02x}{int(new_green * 255):02x}{int(new_blue * 255):02x}" return Color(shifted_color) shift_color_towards = functools.wraps(shift_color_towards)(functools.lru_cache(maxsize=8192)(shift_color_towards)) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/hexterm.py000066400000000000000000000165521517776150200265650ustar00rootroot00000000000000"""Mappings for XTerm-256 color codes and helper functions for converting between RGB hex colors and XTerm-256 color codes. Functions: hex_to_xterm: Convert RGB Hex colors to their closest XTerm-256 color. xterm_to_hex: Convert XTerm-256 color codes to RGB Hex colors. is_valid_color: Check if the input is a valid XTerm-256 or RGB hex color code. """ from __future__ import annotations xterm_to_hex_map = { 0: "#000000", 1: "#800000", 2: "#008000", 3: "#808000", 4: "#000080", 5: "#800080", 6: "#008080", 7: "#c0c0c0", 8: "#808080", 9: "#ff0000", 10: "#00ff00", 11: "#ffff00", 12: "#0000ff", 13: "#ff00ff", 14: "#00ffff", 15: "#ffffff", 16: "#000000", 17: "#00005f", 18: "#000087", 19: "#0000af", 20: "#0000d7", 21: "#0000ff", 22: "#005f00", 23: "#005f5f", 24: "#005f87", 25: "#005faf", 26: "#005fd7", 27: "#005fff", 28: "#008700", 29: "#00875f", 30: "#008787", 31: "#0087af", 32: "#0087d7", 33: "#0087ff", 34: "#00af00", 35: "#00af5f", 36: "#00af87", 37: "#00afaf", 38: "#00afd7", 39: "#00afff", 40: "#00d700", 41: "#00d75f", 42: "#00d787", 43: "#00d7af", 44: "#00d7d7", 45: "#00d7ff", 46: "#00ff00", 47: "#00ff5f", 48: "#00ff87", 49: "#00ffaf", 50: "#00ffd7", 51: "#00ffff", 52: "#5f0000", 53: "#5f005f", 54: "#5f0087", 55: "#5f00af", 56: "#5f00d7", 57: "#5f00ff", 58: "#5f5f00", 59: "#5f5f5f", 60: "#5f5f87", 61: "#5f5faf", 62: "#5f5fd7", 63: "#5f5fff", 64: "#5f8700", 65: "#5f875f", 66: "#5f8787", 67: "#5f87af", 68: "#5f87d7", 69: "#5f87ff", 70: "#5faf00", 71: "#5faf5f", 72: "#5faf87", 73: "#5fafaf", 74: "#5fafd7", 75: "#5fafff", 76: "#5fd700", 77: "#5fd75f", 78: "#5fd787", 79: "#5fd7af", 80: "#5fd7d7", 81: "#5fd7ff", 82: "#5fff00", 83: "#5fff5f", 84: "#5fff87", 85: "#5fffaf", 86: "#5fffd7", 87: "#5fffff", 88: "#870000", 89: "#87005f", 90: "#870087", 91: "#8700af", 92: "#8700d7", 93: "#8700ff", 94: "#875f00", 95: "#875f5f", 96: "#875f87", 97: "#875faf", 98: "#875fd7", 99: "#875fff", 100: "#878700", 101: "#87875f", 102: "#878787", 103: "#8787af", 104: "#8787d7", 105: "#8787ff", 106: "#87af00", 107: "#87af5f", 108: "#87af87", 109: "#87afaf", 110: "#87afd7", 111: "#87afff", 112: "#87d700", 113: "#87d75f", 114: "#87d787", 115: "#87d7af", 116: "#87d7d7", 117: "#87d7ff", 118: "#87ff00", 119: "#87ff5f", 120: "#87ff87", 121: "#87ffaf", 122: "#87ffd7", 123: "#87ffff", 124: "#af0000", 125: "#af005f", 126: "#af0087", 127: "#af00af", 128: "#af00d7", 129: "#af00ff", 130: "#af5f00", 131: "#af5f5f", 132: "#af5f87", 133: "#af5faf", 134: "#af5fd7", 135: "#af5fff", 136: "#af8700", 137: "#af875f", 138: "#af8787", 139: "#af87af", 140: "#af87d7", 141: "#af87ff", 142: "#afaf00", 143: "#afaf5f", 144: "#afaf87", 145: "#afafaf", 146: "#afafd7", 147: "#afafff", 148: "#afd700", 149: "#afd75f", 150: "#afd787", 151: "#afd7af", 152: "#afd7d7", 153: "#afd7ff", 154: "#afff00", 155: "#afff5f", 156: "#afff87", 157: "#afffaf", 158: "#afffd7", 159: "#afffff", 160: "#d70000", 161: "#d7005f", 162: "#d70087", 163: "#d700af", 164: "#d700d7", 165: "#d700ff", 166: "#d75f00", 167: "#d75f5f", 168: "#d75f87", 169: "#d75faf", 170: "#d75fd7", 171: "#d75fff", 172: "#d78700", 173: "#d7875f", 174: "#d78787", 175: "#d787af", 176: "#d787d7", 177: "#d787ff", 178: "#d7af00", 179: "#d7af5f", 180: "#d7af87", 181: "#d7afaf", 182: "#d7afd7", 183: "#d7afff", 184: "#d7d700", 185: "#d7d75f", 186: "#d7d787", 187: "#d7d7af", 188: "#d7d7d7", 189: "#d7d7ff", 190: "#d7ff00", 191: "#d7ff5f", 192: "#d7ff87", 193: "#d7ffaf", 194: "#d7ffd7", 195: "#d7ffff", 196: "#ff0000", 197: "#ff005f", 198: "#ff0087", 199: "#ff00af", 200: "#ff00d7", 201: "#ff00ff", 202: "#ff5f00", 203: "#ff5f5f", 204: "#ff5f87", 205: "#ff5faf", 206: "#ff5fd7", 207: "#ff5fff", 208: "#ff8700", 209: "#ff875f", 210: "#ff8787", 211: "#ff87af", 212: "#ff87d7", 213: "#ff87ff", 214: "#ffaf00", 215: "#ffaf5f", 216: "#ffaf87", 217: "#ffafaf", 218: "#ffafd7", 219: "#ffafff", 220: "#ffd700", 221: "#ffd75f", 222: "#ffd787", 223: "#ffd7af", 224: "#ffd7d7", 225: "#ffd7ff", 226: "#ffff00", 227: "#ffff5f", 228: "#ffff87", 229: "#ffffaf", 230: "#ffffd7", 231: "#ffffff", 232: "#080808", 233: "#121212", 234: "#1c1c1c", 235: "#262626", 236: "#303030", 237: "#3a3a3a", 238: "#444444", 239: "#4e4e4e", 240: "#585858", 241: "#626262", 242: "#6c6c6c", 243: "#767676", 244: "#808080", 245: "#8a8a8a", 246: "#949494", 247: "#9e9e9e", 248: "#a8a8a8", 249: "#b2b2b2", 250: "#bcbcbc", 251: "#c6c6c6", 252: "#d0d0d0", 253: "#dadada", 254: "#e4e4e4", 255: "#eeeeee", } xterm_to_rgb_map = {k: (int(v[1:3], 16), int(v[3:5], 16), int(v[5:7], 16)) for k, v in xterm_to_hex_map.items()} def hex_to_xterm(hex_color: str) -> int: """Convert RGB Hex colors to their closest XTerm-256 color. Closeness is determined by the mean absolute difference across the red, green, and blue channels. Args: hex_color (str): RGB Hex color code, '#' is optional Returns: int: (0-255) XTerm-256 color code """ # Strip '#' if present and convert hex to RGB color_string = hex_color.strip("#") input_rgb = tuple(int(color_string[i : i + 2], 16) for i in range(0, 6, 2)) # Compute the differences between input color and each xterm color min_diff = float("inf") for xterm_color, xterm_rgb in xterm_to_rgb_map.items(): diff = sum(abs(input_rgb[i] - xterm_rgb[i]) for i in range(3)) / 3 if diff < min_diff: min_diff = diff closest_color = xterm_color return closest_color # type: ignore[unbound] def xterm_to_hex(xterm_color: int) -> str: """Convert XTerm-256 color code to RGB Hex color code. Args: xterm_color (int): (0-255) XTerm-256 color code Returns: str: RGB hex color code without a leading `#`. Raises: ValueError: The input is not a valid XTerm-256 color code (0-255). """ if xterm_color not in xterm_to_hex_map: msg = f"Invalid XTerm-256 color code: {xterm_color}" raise ValueError(msg) return xterm_to_hex_map[xterm_color].strip("#") def is_valid_color(color: int | str) -> bool: """Check if the input is a valid XTerm-256 or RGB hex color code. Args: color (int | str): X-Term 256 color code or RGB Hex color code, '#' is optional Returns: bool: True if the input is a valid color code """ if isinstance(color, str): if len(color.lstrip("#")) not in [6, 7]: return False try: int(color.strip("#"), 16) except ValueError: return False return True return color in range(256) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/shell_completion.py000066400000000000000000000151201517776150200304370ustar00rootroot00000000000000"""Shell completion generation for the terminaltexteffects CLI.""" from __future__ import annotations import argparse from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Iterable SUPPORTED_SHELLS = ("bash", "zsh") @dataclass(frozen=True) class CompletionOption: """Static completion metadata for a CLI option.""" option_strings: tuple[str, ...] choices: tuple[str, ...] = () takes_value: bool = False file_completion: bool = False def _escape_for_shell(words: Iterable[str]) -> str: """Escape static completion words for safe inclusion in a shell script.""" return " ".join(word.replace("\\", "\\\\").replace('"', '\\"') for word in words) def _takes_value(action: argparse.Action) -> bool: """Return whether an argparse action consumes a value.""" if isinstance( action, ( argparse._HelpAction, argparse._StoreTrueAction, argparse._StoreFalseAction, argparse._VersionAction, argparse._CountAction, argparse._AppendConstAction, argparse._StoreConstAction, ), ): return False return action.nargs != 0 def _normalize_choices(action: argparse.Action) -> tuple[str, ...]: """Return stringified argparse choices for completion, if any.""" if action.choices is None: return () return tuple(str(choice) for choice in action.choices) def _build_option_spec(action: argparse.Action) -> CompletionOption | None: """Convert an argparse action into static completion metadata.""" if not action.option_strings: return None return CompletionOption( option_strings=tuple(action.option_strings), choices=_normalize_choices(action), takes_value=_takes_value(action), file_completion="--input-file" in action.option_strings or "-i" in action.option_strings, ) def _collect_parser_options(parser: argparse.ArgumentParser) -> tuple[CompletionOption, ...]: """Collect static option metadata from a parser.""" options: list[CompletionOption] = [] for action in parser._actions: option = _build_option_spec(action) if option is not None: options.append(option) return tuple(options) def _find_subparser_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction | None: """Return the parser's subparser action, if present.""" for action in parser._actions: if isinstance(action, argparse._SubParsersAction): return action return None def _format_case_patterns(option: CompletionOption) -> str: """Format shell case patterns for the option's aliases.""" return "|".join(option.option_strings) def _build_prev_case_block(options: Iterable[CompletionOption], indent: str) -> str: """Build a shell case block that completes option values based on the previous word.""" lines: list[str] = [] for option in options: if option.file_completion: lines.extend( [ f"{indent}{_format_case_patterns(option)})", f'{indent} COMPREPLY=($(compgen -f -- "$cur"))', f"{indent} return 0", f"{indent} ;;", ], ) elif option.choices: choices = _escape_for_shell(option.choices) lines.extend( [ f"{indent}{_format_case_patterns(option)})", f'{indent} COMPREPLY=($(compgen -W "{choices}" -- "$cur"))', f"{indent} return 0", f"{indent} ;;", ], ) return "\n".join(lines) def _build_bash_completion(parser: argparse.ArgumentParser) -> str: """Generate a bash completion script from an argparse parser.""" global_options = _collect_parser_options(parser) subparser_action = _find_subparser_action(parser) effect_parsers = subparser_action.choices if subparser_action else {} global_words = _escape_for_shell( [ *[option for spec in global_options for option in spec.option_strings], *effect_parsers, ], ) global_prev_case = _build_prev_case_block(global_options, " ") effect_blocks: list[str] = [] for effect_name, effect_parser in effect_parsers.items(): effect_options = _collect_parser_options(effect_parser) effect_words = _escape_for_shell([option for spec in effect_options for option in spec.option_strings]) effect_prev_case = _build_prev_case_block(effect_options, " ") effect_blocks.append( "\n".join( [ f" {effect_name})", ' case "$prev" in', effect_prev_case or " *) ;;", " esac", f' COMPREPLY=($(compgen -W "{effect_words}" -- "$cur"))', " return 0", " ;;", ], ), ) effect_case = "\n".join(effect_blocks) if effect_blocks else " *) ;;" return f"""# shellcheck shell=bash _tte_completion() {{ local cur prev effect word COMPREPLY=() cur="${{COMP_WORDS[COMP_CWORD]}}" prev="${{COMP_WORDS[COMP_CWORD-1]}}" effect="" case "$prev" in {global_prev_case or ' *) ;;'} esac for word in "${{COMP_WORDS[@]:1}}"; do case "$word" in {"|".join(effect_parsers)}) effect="$word"; break ;; esac done if [[ -n "$effect" ]]; then case "$effect" in {effect_case} esac fi COMPREPLY=($(compgen -W "{global_words}" -- "$cur")) return 0 }} complete -F _tte_completion tte complete -F _tte_completion terminaltexteffects """ def _build_zsh_completion(parser: argparse.ArgumentParser) -> str: """Generate a zsh completion script by enabling bash-style completion.""" bash_script = _build_bash_completion(parser).rstrip() return f"""autoload -Uz compinit bashcompinit if ! whence compdef >/dev/null 2>&1; then compinit fi if ! whence complete >/dev/null 2>&1; then bashcompinit fi {bash_script} """ def get_completion_script(shell: str, parser: argparse.ArgumentParser) -> str: """Return the shell completion script for the requested shell.""" if shell == "bash": return _build_bash_completion(parser) if shell == "zsh": return _build_zsh_completion(parser) msg = f"Unsupported shell: {shell}" raise ValueError(msg) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/000077500000000000000000000000001517776150200272235ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/__init__.py000066400000000000000000000000001517776150200313220ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/algo/000077500000000000000000000000001517776150200301455ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/algo/__init__.py000066400000000000000000000000001517776150200322440ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/algo/aldousbroder.py000066400000000000000000000101651517776150200332070ustar00rootroot00000000000000"""Aldous-Broder spanning tree generator. This module provides the `AldousBroder` spanning tree generator. The algorithm performs a random walk over the terminal character graph, linking each newly encountered character to the current tree. The walk continues until every reachable terminal character has been linked. """ from __future__ import annotations import random from typing import TYPE_CHECKING from terminaltexteffects.utils.spanningtree.base_generator import SpanningTreeGenerator if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.terminal import Terminal class AldousBroder(SpanningTreeGenerator): """Aldous-Broder spanning tree generator. The generator starts from a provided character or from a random character resolved from a random canvas coordinate. It then performs a random walk across neighboring characters, linking only characters that have not yet been linked into the tree. Attributes: char_last_linked (EffectCharacter | None): Character most recently linked into the tree. During initialization this is the starting character. On later steps it is `None` when the random walk moves to an already-linked character. char_link_order (list[EffectCharacter]): Characters in the order they were linked into the tree, beginning with the starting character. linked_char_last_visited (EffectCharacter | None): Character most recently visited without creating a new link. During initialization this is the starting character. On later steps it is set only when the random walk moves to a character that already has at least one link. complete (bool): Whether the algorithm is complete. """ def __init__(self, terminal: Terminal, starting_char: EffectCharacter | None = None) -> None: """Initialize the algorithm. Args: terminal (Terminal): TTE Terminal. starting_char (EffectCharacter | None, optional): Starting character for the random walk. When `None`, a character is selected by resolving a random canvas coordinate to a terminal character. Raises: ValueError: No starting character could be resolved. """ super().__init__(terminal) starting_char = starting_char or terminal.get_character_by_input_coord(terminal.canvas.random_coord()) if starting_char is None: msg = "Unable to find a starting character." raise ValueError(msg) self._unlinked_chars = set(self.terminal.get_characters(inner_fill_chars=True, outer_fill_chars=True)) self._unlinked_chars.remove(starting_char) self._current_char = starting_char self.char_last_linked: EffectCharacter | None = self._current_char self.char_link_order: list[EffectCharacter] = [self._current_char] self.linked_char_last_visited: EffectCharacter | None = self._current_char self.complete = False def step(self) -> None: """Advance the random walk by one neighboring character. If the chosen neighbor is not yet linked, it is linked to the current character and recorded in `char_link_order`. Otherwise, `linked_char_last_visited` records the already-linked character that was visited. Note: If all characters have already been linked, this method sets `complete` to `True` and returns immediately without advancing the walk. """ self.linked_char_last_visited = self.char_last_linked = None if not self._unlinked_chars: self.complete = True return next_char = random.choice([n for n in self._current_char.neighbors.values() if n]) if not next_char.links: self._current_char._link(next_char) self._unlinked_chars.remove(next_char) self.char_last_linked = next_char self.char_link_order.append(next_char) else: self.linked_char_last_visited = next_char self._current_char = next_char terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/algo/breadthfirst.py000066400000000000000000000075421517776150200332100ustar00rootroot00000000000000"""Breadth-first traversal over an already linked character graph. This module provides the `BreadthFirst` traversal helper. It performs a breadth-first walk over existing `EffectCharacter.links` relationships instead of generating a new spanning tree. """ from __future__ import annotations from typing import TYPE_CHECKING from terminaltexteffects.utils.spanningtree.base_generator import SpanningTreeGenerator if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.terminal import Terminal class BreadthFirst(SpanningTreeGenerator): """Breadth-first traversal helper. Uses breadth-first traversal to explore an already linked graph of `EffectCharacter` nodes. Attributes: starting_char (EffectCharacter): Character where the traversal begins. explored_last_step (list[EffectCharacter]): Characters newly discovered during the most recent step. char_explore_order (list[EffectCharacter]): Characters in the order they were first discovered by the traversal. complete (bool): Whether the algorithm is complete. """ def __init__( self, terminal: Terminal, starting_char: EffectCharacter | None = None, *, limit_to_text_boundary: bool = False, ) -> None: """Initialize the algorithm. Args: terminal (Terminal): TTE Terminal. starting_char (EffectCharacter | None, optional): Starting character for the traversal. When `None`, a character is selected by resolving a random canvas coordinate to a terminal character. limit_to_text_boundary (bool, optional): If True, the starting character, if not provided, will be chosen within the text boundary. This should be True if the spanning tree was generated with limit_to_text_boundary=True. Raises: ValueError: Unable to find a starting character. """ super().__init__(terminal) self._limit_to_text_boundary = limit_to_text_boundary self.starting_char = starting_char or terminal.get_character_by_input_coord( terminal.canvas.random_coord(within_text_boundary=limit_to_text_boundary), ) if self.starting_char is None: msg = "Unable to find a starting character." raise ValueError(msg) self._frontier = [self.starting_char] self._explored: dict[EffectCharacter, EffectCharacter] = {self.starting_char: self.starting_char} self.explored_last_step: list[EffectCharacter] = [] self.char_explore_order: list[EffectCharacter] = [] self.complete = False def step(self) -> None: """Advance the traversal by one breadth-first layer. Each step consumes the current frontier, records any newly discovered linked neighbors, and stores those newly discovered characters as the next frontier. Note: `complete` becomes `True` only when `_frontier` is already empty at the start of a call. """ self.explored_last_step.clear() if not self._frontier: self.complete = True return new_edges = [] while self._frontier: position = self._frontier.pop(0) position_new_edges = [ neighbor for neighbor in position.links if neighbor not in self._explored and neighbor not in self._frontier and neighbor not in new_edges ] new_edges.extend(position_new_edges) for character in position_new_edges: self._explored[character] = position self.explored_last_step.append(character) self.char_explore_order.append(character) self._frontier.extend(new_edges) terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/algo/primssimple.py000066400000000000000000000124621517776150200330700ustar00rootroot00000000000000"""Simplified Prim-style spanning tree generator. A simplified Prim-style algorithm uses equal weight for all links and selects from the current edge set at random. The algorithm starts from a chosen starting character (or a random one from the terminal) and grows by randomly linking to an unlinked neighbor. When a neighbor is linked, the neighbor is checked for unlinked neighbors. If it has unlinked neighbors, it is considered an edge. On each step, a random edge is chosen and the process repeats. """ from __future__ import annotations import random from typing import TYPE_CHECKING from terminaltexteffects.utils.spanningtree.base_generator import SpanningTreeGenerator if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.terminal import Terminal class PrimsSimple(SpanningTreeGenerator): """Simplified Prim-style spanning tree generator. Attributes: char_last_linked (EffectCharacter | None): Character most recently linked into the tree. During initialization this is the starting character. On later steps it retains its previous value if no new character is linked. char_link_order (list[EffectCharacter]): Characters in the order they were linked into the tree, beginning with the starting character. edge_chars (list[EffectCharacter]): Characters currently considered edge candidates. During initialization this contains the starting character. edge_last_added (EffectCharacter | None): Character most recently added to the edge list. During initialization this is the starting character. On later steps it retains its previous value if no new edge character is added. edge_last_popped (EffectCharacter | None): Character popped off the edge list on the last step. None if no character was popped during the last step. complete (bool): Whether the algorithm is complete. """ def __init__( self, terminal: Terminal, starting_char: EffectCharacter | None = None, *, limit_to_text_boundary: bool = False, ) -> None: """Initialize the algorithm. Args: terminal (Terminal): TTE Terminal. starting_char (EffectCharacter | None, optional): Starting character for the tree generation. When `None`, a character is selected by resolving a random canvas coordinate to a terminal character. limit_to_text_boundary (bool, optional): If True, the graph will not link to neighbors outside the text boundary. Raises: ValueError: Unable to find a starting character. """ super().__init__(terminal) starting_char = starting_char or terminal.get_character_by_input_coord( terminal.canvas.random_coord(within_text_boundary=limit_to_text_boundary), ) if starting_char is None: msg = "Unable to find a starting character." raise ValueError(msg) self.limit_to_text_boundary = limit_to_text_boundary self._current_char = starting_char self.char_last_linked: EffectCharacter | None = self._current_char self.char_link_order: list[EffectCharacter] = [self._current_char] self.edge_chars = [self._current_char] self.edge_last_added: EffectCharacter | None = self._current_char self.edge_last_popped: EffectCharacter | None = None self.complete = False def step(self) -> None: """Advance the tree generation by one step. Each step pops one character from the current edge list. If that character has any eligible unlinked neighbors, one is linked into the tree, the popped character may be returned to the edge list if it still has eligible neighbors, and the newly linked character may be added to the edge list as well. Note: `complete` becomes `True` only when `edge_chars` is already empty at the start of a call. If a step pops the last edge character and links nothing, the generator ends that call with an empty edge list and `complete` still set to `False`. """ if self.edge_chars: self._current_char = self.edge_chars.pop(random.randrange(len(self.edge_chars))) self.edge_last_popped = self._current_char unlinked_neighbors = self.get_neighbors( self._current_char, limit_to_text_boundary=self.limit_to_text_boundary, ) if unlinked_neighbors: next_char = unlinked_neighbors.pop(random.randrange(len(unlinked_neighbors))) self._current_char._link(next_char) self.char_link_order.append(next_char) self.char_last_linked = next_char if unlinked_neighbors: self.edge_chars.append(self._current_char) unlinked_neighbors = self.get_neighbors( next_char, limit_to_text_boundary=self.limit_to_text_boundary, ) if unlinked_neighbors: self.edge_chars.append(next_char) self.edge_last_added = next_char else: self.complete = True terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/algo/primsweighted.py000066400000000000000000000153261517776150200334010ustar00rootroot00000000000000"""Weighted Prim-style spanning tree generator. This module provides the `PrimsWeighted` spanning tree generator. The algorithm builds a spanning tree over a graph of `EffectCharacter` nodes using a Prim-style approach with randomly assigned per-character weights. The algorithm starts from a chosen starting character (or a random one from the terminal) and grows the tree by linking to the unlinked neighbor with the lowest weight. When a neighbor is linked, its unlinked neighbors are added to the pool of potential links. On each step, the pending link whose target character has the lowest assigned weight is chosen and the process repeats. """ from __future__ import annotations import random from collections import defaultdict from dataclasses import dataclass from typing import TYPE_CHECKING from terminaltexteffects.utils.spanningtree.base_generator import SpanningTreeGenerator if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.terminal import Terminal @dataclass class WeightedLink: """Weighted link. Attributes: char_a (EffectCharacter): One end of the link. char_b (EffectCharacter): The other end of the link. weight (int): Weight of the link. """ char_a: EffectCharacter char_b: EffectCharacter weight: int class PrimsWeighted(SpanningTreeGenerator): """Weighted Prim-style spanning tree generator. Attributes: char_last_linked (EffectCharacter | None): Character most recently linked into the tree. During initialization this is the starting character. On later steps it may retain its previous value when completion is reached through stale pending links. char_link_order (list[EffectCharacter]): Characters in the order they were linked into the tree, beginning with the starting character. neighbors_last_added (list[EffectCharacter]): Characters most recently added to the pending weighted-link pool. During initialization this is populated from the starting character's eligible neighbors. complete (bool): Whether the algorithm is complete. """ def __init__( self, terminal: Terminal, starting_char: EffectCharacter | None = None, *, limit_to_text_boundary: bool = False, ) -> None: """Initialize the algorithm. Args: terminal (Terminal): TTE Terminal. starting_char (EffectCharacter | None, optional): Starting character for the tree generation. When `None`, a character is selected by resolving a random canvas coordinate to a terminal character. limit_to_text_boundary (bool, optional): If True, the graph will not link to neighbors outside the text boundary, and random starting-character selection is limited to the text boundary. Raises: ValueError: Unable to find a starting character. """ super().__init__(terminal) self.limit_to_text_boundary = limit_to_text_boundary starting_char = starting_char or terminal.get_character_by_input_coord( terminal.canvas.random_coord(within_text_boundary=limit_to_text_boundary), ) if starting_char is None: msg = "Unable to find a starting character." raise ValueError(msg) self._char_weights: dict[EffectCharacter, int] = {} for char in self.terminal.get_characters(inner_fill_chars=True, outer_fill_chars=True): self._char_weights[char] = random.randint(0, 99) self._current_char = starting_char self.char_last_linked: EffectCharacter | None = self._current_char self.char_link_order: list[EffectCharacter] = [self._current_char] self.neighbors_last_added: list[EffectCharacter] = [] self.complete = False self._pending_weighted_links: dict[int, list[WeightedLink]] = defaultdict(list) self.add_weighted_links(self._current_char) def add_weighted_links(self, char: EffectCharacter) -> None: """Add weighted links for the given character's unlinked neighbors. Args: char (EffectCharacter): Character for which weighted links are added. """ self.neighbors_last_added.clear() for neighbor in self.get_neighbors(char, limit_to_text_boundary=self.limit_to_text_boundary): self.neighbors_last_added.append(neighbor) self._pending_weighted_links[self._char_weights[neighbor]].append( WeightedLink(char, neighbor, self._char_weights[neighbor]), ) def get_lowest_weight_link(self) -> WeightedLink | None: """Get the weighted link with the lowest weight. Stale pending links whose target character has already been linked are discarded while searching for the next valid link. Returns: WeightedLink | None: The weighted link with the lowest weight, or None if no links are available. """ while self._pending_weighted_links: lowest_weight = min(self._pending_weighted_links) links_at_weight = self._pending_weighted_links[lowest_weight] link = links_at_weight.pop(random.randrange(len(links_at_weight))) if not links_at_weight: self._pending_weighted_links.pop(lowest_weight) if not link.char_b.links: return link return None def step(self) -> None: """Advance the tree generation by one step. Each step chooses the pending weighted link with the lowest target weight, links its target character into the tree, and adds the new character's eligible neighbors to the pending pool. Note: If the pending pool starts empty, this method sets `complete` to `True`, clears `neighbors_last_added`, and sets `char_last_linked` to `None`. If pending links exist but all remaining candidates are stale, `get_lowest_weight_link()` returns `None` and this method sets `complete` to `True` without resetting `char_last_linked` or `neighbors_last_added`. """ if self._pending_weighted_links: next_link = self.get_lowest_weight_link() if next_link is None: self.complete = True return next_link.char_a._link(next_link.char_b) self.char_last_linked = next_link.char_b self.char_link_order.append(next_link.char_b) self.add_weighted_links(next_link.char_b) else: self.complete = True self.char_last_linked = None self.neighbors_last_added.clear() return recursivebacktracker.py000066400000000000000000000113121517776150200346420ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/algo"""Recursive backtracker spanning tree generator. This module provides the `RecursiveBacktracker` spanning tree generator. The algorithm builds a spanning tree over a graph of `EffectCharacter` nodes using a depth-first recursive backtracker approach implemented iteratively with an explicit stack. The algorithm starts from a chosen starting character (or a random one from the terminal) and grows the tree by repeatedly linking to a randomly selected unvisited neighbor. Linked cells are added to the stack. When a node has no unvisited neighbors the algorithm backtracks by popping the stack until it finds a node with unvisited neighbors. """ from __future__ import annotations import random from typing import TYPE_CHECKING from terminaltexteffects.utils.spanningtree.base_generator import SpanningTreeGenerator if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.terminal import Terminal class RecursiveBacktracker(SpanningTreeGenerator): """Recursive backtracker spanning tree generator. Attributes: char_last_linked (EffectCharacter | None): Character most recently linked into the tree. During initialization this is the starting character. On later steps it is `None` when the generator backtracks instead of linking a new character. char_link_order (list[EffectCharacter]): Characters in the order they were linked into the tree, beginning with the starting character. stack (list[EffectCharacter]): Depth-first traversal stack. During initialization it contains the starting character. stack_last_popped (EffectCharacter | None): Character popped off the stack on the last step. None if no character was popped during the last step. complete (bool): Whether the algorithm is complete. """ def __init__( self, terminal: Terminal, starting_char: EffectCharacter | None = None, *, limit_to_text_boundary: bool = False, ) -> None: """Initialize the algorithm. Args: terminal (Terminal): TTE Terminal. starting_char (EffectCharacter | None, optional): Starting character for the tree generation. When `None`, a character is selected by resolving a random canvas coordinate to a terminal character. limit_to_text_boundary (bool, optional): If True, the graph will not link to neighbors outside the text boundary. Raises: ValueError: Unable to find a starting character. """ super().__init__(terminal) self.limit_to_text_boundary = limit_to_text_boundary starting_char = starting_char or terminal.get_character_by_input_coord( terminal.canvas.random_coord(within_text_boundary=limit_to_text_boundary), ) if starting_char is None: msg = "Unable to find a starting character." raise ValueError(msg) self._current_char = starting_char self.char_last_linked: EffectCharacter | None = self._current_char self.char_link_order: list[EffectCharacter] = [self._current_char] self.stack: list[EffectCharacter] = [self._current_char] self.stack_last_popped: EffectCharacter | None = None self.complete = False def step(self) -> None: """Advance the traversal by one step. Each step either links one unvisited neighbor and descends deeper into the tree, or pops the traversal stack to backtrack when the current character has no unvisited neighbors. Note: `complete` becomes `True` only on a subsequent call after the stack is already empty. If the last stack item is popped during this call, the generator ends the step with an empty stack and `complete` still set to `False`. """ self.char_last_linked = None self.stack_last_popped = None if self.stack: unvisited_neighbors = self.get_neighbors( self._current_char, limit_to_text_boundary=self.limit_to_text_boundary, ) if unvisited_neighbors: next_char = random.choice(unvisited_neighbors) self._current_char._link(next_char) self.char_link_order.append(next_char) self.char_last_linked = next_char self.stack.append(next_char) self._current_char = next_char else: self.stack_last_popped = self.stack.pop() if self.stack: self._current_char = self.stack[-1] else: self.complete = True terminaltexteffects-release-0.15.0/terminaltexteffects/utils/spanningtree/base_generator.py000066400000000000000000000037301517776150200325600ustar00rootroot00000000000000"""Base spanning tree generator.""" from __future__ import annotations from abc import ABC, abstractmethod from typing import TYPE_CHECKING if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.terminal import Terminal class SpanningTreeGenerator(ABC): """Abstract base class for spanning-tree and graph-traversal generators.""" def __init__(self, terminal: Terminal) -> None: """Initialize the tree generator. Args: terminal (Terminal): TTE terminal used as the source of characters, neighbor relationships, and text-boundary checks. """ self.terminal = terminal def get_neighbors( self, character: EffectCharacter, *, unlinked_only: bool = True, limit_to_text_boundary: bool = False, ) -> list[EffectCharacter]: """Get the neighbors for a given character and apply filters. Args: character (EffectCharacter): Subject character. unlinked_only (bool, optional): If True, filter out any neighbors with links. If False, include both linked and unlinked neighbors. Defaults to True. limit_to_text_boundary (bool, optional): If True, filter out neighbors outside the text boundary. Defaults to False. Returns: list[EffectCharacter]: List of EffectCharacter neighbors. """ neighbors = [neighbor for neighbor in character.neighbors.values() if neighbor] if limit_to_text_boundary: neighbors = [ neighbor for neighbor in neighbors if self.terminal.canvas.coord_is_in_text(neighbor.input_coord) ] if unlinked_only: neighbors = [neighbor for neighbor in neighbors if not neighbor.links] return neighbors @abstractmethod def step(self) -> None: """Progress the algorithm by one step.""" terminaltexteffects-release-0.15.0/tests/000077500000000000000000000000001517776150200204505ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/tests/__init__.py000066400000000000000000000001171517776150200225600ustar00rootroot00000000000000"""Marks this directory as a Python package for test discovery and imports.""" terminaltexteffects-release-0.15.0/tests/conftest.py000066400000000000000000000256041517776150200226560ustar00rootroot00000000000000"""Pytest fixtures and constants for terminaltexteffects package.""" from __future__ import annotations from typing import TYPE_CHECKING, Any, Literal import pytest from terminaltexteffects.effects import ( effect_beams, effect_binarypath, effect_blackhole, effect_bouncyballs, effect_bubbles, effect_burn, effect_colorshift, effect_crumble, effect_decrypt, effect_errorcorrect, effect_expand, effect_fireworks, effect_highlight, effect_laseretch, effect_matrix, effect_middleout, effect_orbittingvolley, effect_overflow, effect_pour, effect_print, effect_rain, effect_random_sequence, effect_rings, effect_scattered, effect_slice, effect_slide, effect_smoke, effect_spotlights, effect_spray, effect_swarm, effect_sweep, effect_synthgrid, effect_thunderstorm, effect_unstable, effect_vhstape, effect_waves, effect_wipe, ) from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils import geometry, graphics from terminaltexteffects.utils.argutils import CharacterGroup from terminaltexteffects.utils.easing import ( EasingFunction, in_back, in_bounce, in_circ, in_cubic, in_elastic, in_expo, in_out_back, in_out_bounce, in_out_circ, in_out_cubic, in_out_elastic, in_out_expo, in_out_quad, in_out_quart, in_out_quint, in_out_sine, in_quad, in_quart, in_quint, in_sine, out_back, out_bounce, out_circ, out_cubic, out_elastic, out_expo, out_quad, out_quart, out_quint, out_sine, ) from terminaltexteffects.utils.graphics import Color, Gradient if TYPE_CHECKING: from collections.abc import Generator from terminaltexteffects.engine.base_effect import BaseEffect INPUT_EMPTY = "" INPUT_SINGLE_CHAR = "a" INPUT_SINGLE_COLUMN = """ a b c d e f""" INPUT_SINGLE_ROW = "abcdefg" INPUT_LARGE = """ 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0 23456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01 3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012 456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123 56789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234 6789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345 789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456 89abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567 9abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678 """ INPUT_MEDIUM = """ 0123456789abcdefg 123456789abcdefgh 23456789abcdefghi 3456789abcdefghij 456789abcdefghijk""" INPUT_TABS = """Tabs\tTabs\t\tTabs\t\t\tTabs""" CANVAS_TEST_INPUT = """TL!!!!!!!!!!!!!!!!!!!!!TOP*********************TR + <----50----> # + | # + | # L ^ | R E | | I F MID 13 | CENTER MID G T | | H - v | T - | @ - | @ - | @ BL--------------------BOTTOM...................BR""" COLOR_SEQUENCES = ( "\x1b[38;5;231m....\x1b[39m....| \x1b[38;5;95m\x1b[48;5;128mggggggg\x1b[0m \x1b[38;5;180mggggggg " "\x1b[38;5;146m:gggggg; \x1b[38;5;64mggggggg \x1b[38;5;182mggggggg \x1b[38;5;195m:gggggg; " "\x1b[38;5;214mggggggg \x1b[38;5;146m;gggggg \x1b[38;5;174mggggggg \x1b[0m" ) TEST_INPUTS = { "empty": INPUT_EMPTY, "single_char": INPUT_SINGLE_CHAR, "single_column": INPUT_SINGLE_COLUMN, "single_row": INPUT_SINGLE_ROW, "medium": INPUT_MEDIUM, "tabs": INPUT_TABS, "large": INPUT_LARGE, "canvas": CANVAS_TEST_INPUT, "color_sequences": COLOR_SEQUENCES, } EFFECTS = [ effect_beams.Beams, effect_binarypath.BinaryPath, effect_blackhole.Blackhole, effect_bouncyballs.BouncyBalls, effect_bubbles.Bubbles, effect_burn.Burn, effect_colorshift.ColorShift, effect_crumble.Crumble, effect_decrypt.Decrypt, effect_errorcorrect.ErrorCorrect, effect_expand.Expand, effect_fireworks.Fireworks, effect_highlight.Highlight, effect_laseretch.LaserEtch, effect_matrix.Matrix, effect_middleout.MiddleOut, effect_orbittingvolley.OrbittingVolley, effect_overflow.Overflow, effect_pour.Pour, effect_print.Print, effect_rain.Rain, effect_random_sequence.RandomSequence, effect_rings.Rings, effect_scattered.Scattered, effect_slice.Slice, effect_slide.Slide, effect_smoke.Smoke, effect_spotlights.Spotlights, effect_spray.Spray, effect_thunderstorm.Thunderstorm, effect_swarm.Swarm, effect_sweep.Sweep, effect_synthgrid.SynthGrid, effect_unstable.Unstable, effect_vhstape.VHSTape, effect_waves.Waves, effect_wipe.Wipe, ] EASING_FUNCTIONS = [ in_sine, out_sine, in_out_sine, in_quad, out_quad, in_out_quad, in_cubic, out_cubic, in_out_cubic, in_quart, out_quart, in_out_quart, in_quint, out_quint, in_out_quint, in_expo, out_expo, in_out_expo, in_circ, out_circ, in_out_circ, in_elastic, out_elastic, in_out_elastic, in_back, out_back, in_out_back, in_bounce, out_bounce, in_out_bounce, ] ANCHORS = ["sw", "s", "se", "e", "ne", "n", "nw", "w", "c"] @pytest.fixture(autouse=True) def clear_lru_cache() -> Generator[None, Any, None]: """Fixture to clear utility LRU caches.""" yield graphics.shift_color_towards.cache_clear() # type: ignore[attr-defined] geometry.find_coords_on_circle.cache_clear() # type: ignore[attr-defined] geometry.find_coords_in_circle.cache_clear() # type: ignore[attr-defined] geometry.find_coords_in_rect.cache_clear() # type: ignore[attr-defined] geometry.extrapolate_along_ray.cache_clear() # type: ignore[attr-defined] geometry.find_coord_on_bezier_curve.cache_clear() # type: ignore[attr-defined] geometry.find_coord_on_line.cache_clear() # type: ignore[attr-defined] geometry.find_length_of_bezier_curve.cache_clear() # type: ignore[attr-defined] geometry.find_length_of_line.cache_clear() # type: ignore[attr-defined] geometry.find_normalized_distance_from_center.cache_clear() # type: ignore[attr-defined] @pytest.fixture def input_data(request: pytest.FixtureRequest) -> str: """Fixture to provide input data for tests.""" return TEST_INPUTS[request.param] @pytest.fixture(params=EFFECTS) def effect(request: pytest.FixtureRequest) -> BaseEffect: """Fixture to provide effect instances for tests.""" return request.param @pytest.fixture(params=EASING_FUNCTIONS) def easing_function_1(request: pytest.FixtureRequest) -> EasingFunction: """Fixture to provide the first easing function for tests.""" return request.param @pytest.fixture(params=EASING_FUNCTIONS) def easing_function_2(request: pytest.FixtureRequest) -> EasingFunction: """Fixture to provide the second easing function for tests.""" return request.param @pytest.fixture(params=[True, False]) def no_color(request: pytest.FixtureRequest) -> bool: """Fixture to provide a boolean indicating whether to disable color.""" return request.param @pytest.fixture(params=[True, False]) def xterm_colors(request: pytest.FixtureRequest) -> bool: """Fixture to provide a boolean indicating whether to use xterm colors.""" return request.param @pytest.fixture(params=ANCHORS) def canvas_anchor(request: pytest.FixtureRequest) -> str: """Fixture to provide canvas anchor positions for tests.""" return request.param @pytest.fixture(params=ANCHORS) def text_anchor(request: pytest.FixtureRequest) -> str: """Fixture to provide text anchor positions for tests.""" return request.param @pytest.fixture(params=[(60, 30), (25, 8)], ids=["60x30", "25x8"]) def canvas_dimensions(request: pytest.FixtureRequest) -> tuple[int, int]: """Fixture to provide canvas dimensions for tests.""" return request.param @pytest.fixture def terminal_config_with_color_options(xterm_colors: bool, no_color: bool) -> TerminalConfig: # noqa: FBT001 """Fixture to provide terminal configuration with color options.""" terminal_config = TerminalConfig._build_config() terminal_config.xterm_colors = xterm_colors terminal_config.no_color = no_color terminal_config.frame_rate = 0 return terminal_config @pytest.fixture def terminal_config_default_no_framerate() -> TerminalConfig: """Fixture to provide terminal configuration with default settings and no frame rate.""" terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 return terminal_config @pytest.fixture def terminal_config_with_anchoring( canvas_dimensions: tuple[int, int], canvas_anchor: Literal["sw", "s", "se", "e", "ne", "n", "nw", "w", "c"], text_anchor: Literal["sw", "s", "se", "e", "ne", "n", "nw", "w", "c"], ) -> TerminalConfig: """Fixture to provide terminal configuration with anchoring options.""" terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.canvas_width = canvas_dimensions[0] terminal_config.canvas_height = canvas_dimensions[1] terminal_config.anchor_canvas = canvas_anchor terminal_config.anchor_text = text_anchor return terminal_config @pytest.fixture(params=[(Color("#000000"), Color("#ff00ff"), Color("#0ffff0")), (Color("#ff0fff"),)]) def gradient_stops(request: pytest.FixtureRequest) -> tuple[Color, ...]: """Fixture to provide gradient stops for tests.""" return request.param @pytest.fixture(params=[(1,), (4,), (1, 3)]) def gradient_steps(request: pytest.FixtureRequest) -> tuple[int, ...]: """Fixture to provide gradient steps for tests.""" return request.param @pytest.fixture(params=[1, 4]) def gradient_frames(request: pytest.FixtureRequest) -> int: """Fixture to provide gradient frames for tests.""" return request.param @pytest.fixture( params=[ Gradient.Direction.DIAGONAL, Gradient.Direction.HORIZONTAL, Gradient.Direction.VERTICAL, Gradient.Direction.RADIAL, ], ) def gradient_direction(request: pytest.FixtureRequest) -> Gradient.Direction: """Fixture to provide gradient direction for tests.""" return request.param @pytest.fixture(params=[True, False]) def bool_arg(request: pytest.FixtureRequest) -> bool: """Fixture to provide boolean arguments for tests.""" return request.param @pytest.fixture(params=list(CharacterGroup)) def character_group(request: pytest.FixtureRequest) -> CharacterGroup: """Fixture to provide CharacterGroup arguments for tests.""" return request.param terminaltexteffects-release-0.15.0/tests/effects_tests/000077500000000000000000000000001517776150200233115ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/tests/effects_tests/__init__.py000066400000000000000000000001171517776150200254210ustar00rootroot00000000000000"""Marks this directory as a Python package for test discovery and imports.""" terminaltexteffects-release-0.15.0/tests/effects_tests/test_beams.py000066400000000000000000000123761517776150200260220ustar00rootroot00000000000000"""Test the beams effect with various configuration arguments.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from terminaltexteffects.effects import effect_beams from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color if TYPE_CHECKING: from terminaltexteffects import Color, Gradient from terminaltexteffects.engine.terminal import TerminalConfig def _make_terminal_config(existing_color_handling: str) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_beams_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the beams effect with various input data and default terminal configuration.""" effect = effect_beams.Beams(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_beams_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test the beams effect with terminal color options.""" effect = effect_beams.Beams(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_beams_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Test the final gradient configuration of the beams effect.""" effect = effect_beams.Beams(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.final_gradient_direction = gradient_direction effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_stops = gradient_stops with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("beam_row_symbols", [("a",), ("a", "b", "c")]) @pytest.mark.parametrize("beam_column_symbols", [("a",), ("a", "b", "c")]) @pytest.mark.parametrize("beam_delay", [1, 3]) @pytest.mark.parametrize("beam_row_speed_range", [(1, 3), (2, 4)]) @pytest.mark.parametrize("beam_column_speed_range", [(1, 3), (2, 4)]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_beams_effect_args( input_data: str, terminal_config_default_no_framerate: TerminalConfig, beam_row_symbols: tuple[str, ...], beam_column_symbols: tuple[str, ...], beam_delay: int, beam_row_speed_range: tuple[int, int], beam_column_speed_range: tuple[int, int], gradient_stops: tuple[Color, ...], gradient_steps: tuple[int, ...], gradient_frames: int, ) -> None: """Test the beams effect with various configuration arguments.""" effect = effect_beams.Beams(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.beam_row_symbols = beam_row_symbols effect.effect_config.beam_column_symbols = beam_column_symbols effect.effect_config.beam_delay = beam_delay effect.effect_config.beam_row_speed_range = beam_row_speed_range effect.effect_config.beam_column_speed_range = beam_column_speed_range effect.effect_config.beam_gradient_stops = gradient_stops effect.effect_config.beam_gradient_steps = gradient_steps effect.effect_config.beam_gradient_frames = gradient_frames with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_beams_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: effect = effect_beams.Beams("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["brighten"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_beams.tte.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_beams_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: effect = effect_beams.Beams("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["brighten"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_beams.tte.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None terminaltexteffects-release-0.15.0/tests/effects_tests/test_binarypath.py000066400000000000000000000146301517776150200270670ustar00rootroot00000000000000"""Tests for the BinaryPath effect and its configuration surface.""" from __future__ import annotations from typing import TYPE_CHECKING, Literal import pytest from terminaltexteffects.effects import effect_binarypath from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color if TYPE_CHECKING: from terminaltexteffects import Gradient def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_binarypath_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the BinaryPath effect against a variety of representative inputs.""" effect = effect_binarypath.BinaryPath(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_binarypath_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test BinaryPath output when terminal color toggles change.""" effect = effect_binarypath.BinaryPath(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_binarypath_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the BinaryPath effect respects final gradient settings.""" effect = effect_binarypath.BinaryPath(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("binary_colors", [(Color("#ffffff"),), (Color("#f0f0f0"), Color("#0f0f0f"))]) @pytest.mark.parametrize("movement_speed", [0.5, 1, 4]) @pytest.mark.parametrize("active_binary_groups", [0.0001, 0.5, 1.0]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_binarypath_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, binary_colors: tuple[Color, ...], movement_speed: float, active_binary_groups: float, ) -> None: """Ensure BinaryPath accepts and renders with various configuration arguments.""" effect = effect_binarypath.BinaryPath(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.binary_colors = binary_colors effect.effect_config.movement_speed = movement_speed effect.effect_config.active_binary_groups = active_binary_groups with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_binarypath_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored at the final settle state.""" effect = effect_binarypath.BinaryPath("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["brighten_scn"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_binarypath.tte.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_binarypath_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the final settle state.""" effect = effect_binarypath.BinaryPath("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["brighten_scn"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_binarypath.tte.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_binarypath_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_binarypath.BinaryPath("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["brighten_scn"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_binarypath.tte.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_binarypath_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_binarypath.BinaryPath("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["brighten_scn"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_binarypath.tte.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_blackhole.py000066400000000000000000000045231517776150200266520ustar00rootroot00000000000000import pytest from terminaltexteffects.effects import effect_blackhole from terminaltexteffects.utils.graphics import Color @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_blackhole_effect(input_data, terminal_config_default_no_framerate) -> None: effect = effect_blackhole.Blackhole(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_blackhole_effect_terminal_color_options(input_data, terminal_config_with_color_options) -> None: effect = effect_blackhole.Blackhole(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_blackhole_final_gradient( terminal_config_default_no_framerate, input_data, gradient_direction, gradient_steps, gradient_stops, ) -> None: effect = effect_blackhole.Blackhole(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate effect.effect_config with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("blackhole_color", [Color("#ffffff"), Color("#f0f0f0")]) @pytest.mark.parametrize("star_colors", [(Color("#ffffff"),), (Color("#f0f0f0"), Color("#0f0f0f"))]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_blackhole_args(terminal_config_default_no_framerate, input_data, blackhole_color, star_colors) -> None: effect = effect_blackhole.Blackhole(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.blackhole_color = blackhole_color effect.effect_config.star_colors = star_colors with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) terminaltexteffects-release-0.15.0/tests/effects_tests/test_bouncyballs.py000066400000000000000000000154751517776150200272530ustar00rootroot00000000000000import pytest from terminaltexteffects.effects import effect_bouncyballs from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config(existing_color_handling: str) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_bouncyballs_effect(input_data, terminal_config_default_no_framerate) -> None: effect = effect_bouncyballs.BouncyBalls(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_bouncyballs_effect_terminal_color_options(input_data, terminal_config_with_color_options) -> None: effect = effect_bouncyballs.BouncyBalls(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_bouncyballs_final_gradient( terminal_config_default_no_framerate, input_data, gradient_direction, gradient_steps, gradient_stops, ) -> None: effect = effect_bouncyballs.BouncyBalls(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate effect.effect_config with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("ball_colors", [(Color("#ffffff"),), (Color("#f0f0f0"), Color("#0f0f0f"))]) @pytest.mark.parametrize("ball_symbols", [("a",), ("a", "b", "c")]) @pytest.mark.parametrize("ball_delay", [0, 10]) @pytest.mark.parametrize("movement_speed", [0.01, 0.5, 2.0]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_bouncyballs_args( terminal_config_default_no_framerate, input_data, ball_colors, ball_symbols, ball_delay, movement_speed, easing_function_1, ) -> None: effect = effect_bouncyballs.BouncyBalls(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.ball_colors = ball_colors effect.effect_config.ball_symbols = ball_symbols effect.effect_config.ball_delay = ball_delay effect.effect_config.movement_speed = movement_speed effect.effect_config.movement_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_bouncyballs_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: effect = effect_bouncyballs.BouncyBalls("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bouncyballs.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_bouncyballs_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: effect = effect_bouncyballs.BouncyBalls("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bouncyballs.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_bouncyballs_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: effect = effect_bouncyballs.BouncyBalls("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bouncyballs.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_bouncyballs_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: effect = effect_bouncyballs.BouncyBalls("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bouncyballs.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_bouncyballs_ignore_with_preexisting_colors_uses_effect_gradient() -> None: effect = effect_bouncyballs.BouncyBalls("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bouncyballs.ColorPair(fg=iterator.character_final_color_map[character]) assert final_frame._fg_color_code == iterator.character_final_color_map[character].rgb_color assert final_frame._bg_color_code is None def test_bouncyballs_always_with_preexisting_colors_uses_input_colors() -> None: effect = effect_bouncyballs.BouncyBalls("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bouncyballs.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_bubbles.py000066400000000000000000000156501517776150200263470ustar00rootroot00000000000000import pytest from terminaltexteffects.effects import effect_bubbles from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config(existing_color_handling: str) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_bubbles_effect(input_data, terminal_config_default_no_framerate) -> None: effect = effect_bubbles.Bubbles(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_bubbles_effect_terminal_color_options(input_data, terminal_config_with_color_options) -> None: effect = effect_bubbles.Bubbles(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_bubbles_final_gradient( terminal_config_default_no_framerate, input_data, gradient_direction, gradient_steps, gradient_stops, ) -> None: effect = effect_bubbles.Bubbles(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate effect.effect_config with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("rainbow", [True, False]) @pytest.mark.parametrize("bubble_colors", [(Color("#ff00ff"),), (Color("#0ffff0"), Color("#0000ff"))]) @pytest.mark.parametrize("pop_color", [Color("#ff00ff"), Color("#0ffff0")]) @pytest.mark.parametrize("bubble_speed", [0.1, 4.0]) @pytest.mark.parametrize("bubble_delay", [0, 10]) @pytest.mark.parametrize("pop_condition", ["row", "bottom", "anywhere"]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_bubbles_args( terminal_config_default_no_framerate, input_data, rainbow, bubble_colors, pop_color, bubble_speed, bubble_delay, pop_condition, easing_function_1, ) -> None: effect = effect_bubbles.Bubbles(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.rainbow = rainbow effect.effect_config.bubble_colors = bubble_colors effect.effect_config.pop_color = pop_color effect.effect_config.bubble_speed = bubble_speed effect.effect_config.bubble_delay = bubble_delay effect.effect_config.pop_condition = pop_condition effect.effect_config.movement_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_bubbles_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: effect = effect_bubbles.Bubbles("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["2"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bubbles.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_bubbles_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: effect = effect_bubbles.Bubbles("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["2"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bubbles.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_bubbles_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: effect = effect_bubbles.Bubbles("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["2"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bubbles.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_bubbles_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: effect = effect_bubbles.Bubbles("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["2"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bubbles.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_bubbles_ignore_with_preexisting_colors_uses_effect_gradient() -> None: effect = effect_bubbles.Bubbles("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["2"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bubbles.ColorPair(fg=iterator.character_final_color_map[character]) assert final_frame._fg_color_code == iterator.character_final_color_map[character].rgb_color assert final_frame._bg_color_code is None def test_bubbles_always_with_preexisting_colors_uses_input_colors() -> None: effect = effect_bubbles.Bubbles("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["2"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_bubbles.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_burn.py000066400000000000000000000215611517776150200256750ustar00rootroot00000000000000"""Tests for the burn effect.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_burn from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, Gradient def _make_terminal_config(existing_color_handling: Literal["always", "dynamic", "ignore"]) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_burn_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Render the burn effect across various inputs using the default terminal configuration.""" effect = effect_burn.Burn(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_burn_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Ensure the burn effect works when terminal color options are toggled.""" effect = effect_burn.Burn(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_burn_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Validate custom final gradient settings render without errors.""" effect = effect_burn.Burn(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("starting_color", [Color("#ff00ff"), Color("#0ffff0")]) @pytest.mark.parametrize("burn_colors", [(Color("#ff00ff"),), (Color("#0ffff0"), Color("#0000ff"))]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_burn_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, starting_color: Color, burn_colors: tuple[Color, ...], ) -> None: """Check burn configuration options such as starting color and burn colors.""" effect = effect_burn.Burn(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.starting_color = starting_color effect.effect_config.burn_colors = burn_colors with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_burn_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: """Verify dynamic mode returns uncolored input to terminal default color.""" effect = effect_burn.Burn("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_burn.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_burn_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode resolves final frames to parsed input foreground color.""" effect = effect_burn.Burn("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_burn.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_burn_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode resolves final frames to parsed input foreground and background colors.""" effect = effect_burn.Burn("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_burn.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_burn_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode resolves final frames to parsed input background color.""" effect = effect_burn.Burn("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_burn.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_burn_dynamic_with_preexisting_bg_space_burns_and_restores_input_bg() -> None: """Verify dynamic mode burns bg-colored input spaces and restores their background color.""" effect = effect_burn.Burn("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.smoke_chance = 0 iterator = iter(effect) character = iterator.terminal.get_characters()[0] for _frame in iterator: pass final_visual = character.animation.current_character_visual assert final_visual.symbol == " " assert final_visual.colors == effect_burn.ColorPair(bg=Color(106)) assert final_visual._fg_color_code is None assert final_visual._bg_color_code == Color(106).rgb_color def test_burn_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient.""" effect = effect_burn.Burn("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_burn.BurnIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_burn.ColorPair(fg=iterator.character_final_color_map[character]) assert final_frame._fg_color_code == iterator.character_final_color_map[character].rgb_color assert final_frame._bg_color_code is None def test_burn_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode resolves final frames to parsed input colors.""" effect = effect_burn.Burn("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["1"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_burn.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_burn_always_with_preexisting_bg_space_burns_and_restores_input_bg() -> None: """Verify always mode burns bg-colored input spaces and restores their background color.""" effect = effect_burn.Burn("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("always") effect.effect_config.smoke_chance = 0 iterator = iter(effect) character = iterator.terminal.get_characters()[0] for _frame in iterator: pass final_visual = character.animation.current_character_visual assert final_visual.symbol == " " assert final_visual.colors == effect_burn.ColorPair(bg=Color(106)) assert final_visual._fg_color_code is None assert final_visual._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_colorshift.py000066400000000000000000000162361517776150200271060ustar00rootroot00000000000000import pytest from terminaltexteffects.effects import effect_colorshift from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config(existing_color_handling: str) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_colorshift_effect_all_inputs(input_data, terminal_config_default_no_framerate) -> None: effect = effect_colorshift.ColorShift(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_colorshift_effect_terminal_color_options(input_data, terminal_config_with_color_options) -> None: effect = effect_colorshift.ColorShift(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_colorshift_final_gradient( terminal_config_default_no_framerate, input_data, gradient_direction, gradient_steps, gradient_stops, ) -> None: effect = effect_colorshift.ColorShift(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.final_gradient_direction = gradient_direction effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_stops = gradient_stops with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("no_loop", [True, False]) @pytest.mark.parametrize("no_travel", [True, False]) @pytest.mark.parametrize("reverse_travel_direction", [True, False]) @pytest.mark.parametrize("cycles", [1, 3]) @pytest.mark.parametrize("skip_final_gradient", [True, False]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_colorshift_args( input_data, no_loop, no_travel, reverse_travel_direction, cycles, terminal_config_default_no_framerate, skip_final_gradient, gradient_direction, gradient_stops, gradient_steps, gradient_frames, ) -> None: effect = effect_colorshift.ColorShift(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.gradient_stops = gradient_stops effect.effect_config.gradient_steps = gradient_steps effect.effect_config.gradient_frames = gradient_frames effect.effect_config.no_loop = no_loop effect.effect_config.no_travel = no_travel effect.effect_config.travel_direction = gradient_direction effect.effect_config.reverse_travel_direction = reverse_travel_direction effect.effect_config.cycles = cycles effect.effect_config.skip_final_gradient = skip_final_gradient with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_colorshift_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: effect = effect_colorshift.ColorShift("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["final_gradient"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_colorshift.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_colorshift_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: effect = effect_colorshift.ColorShift("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["final_gradient"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_colorshift.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_colorshift_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: effect = effect_colorshift.ColorShift("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["final_gradient"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_colorshift.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_colorshift_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: effect = effect_colorshift.ColorShift("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["final_gradient"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_colorshift.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_colorshift_ignore_with_preexisting_colors_uses_effect_gradient() -> None: effect = effect_colorshift.ColorShift("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["final_gradient"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_colorshift.ColorPair(fg=iterator.character_final_color_map[character]) assert final_frame._fg_color_code == iterator.character_final_color_map[character].rgb_color assert final_frame._bg_color_code is None def test_colorshift_always_with_preexisting_colors_uses_input_colors() -> None: effect = effect_colorshift.ColorShift("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = iter(effect) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["final_gradient"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_colorshift.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_crumble.py000066400000000000000000000176441517776150200263670ustar00rootroot00000000000000import pytest from terminaltexteffects.effects import effect_crumble from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config(existing_color_handling: str) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True ) def test_crumble_effect(input_data, terminal_config_default_no_framerate) -> None: effect = effect_crumble.Crumble(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_crumble_effect_terminal_color_options(input_data, terminal_config_with_color_options) -> None: effect = effect_crumble.Crumble(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_crumble_final_gradient( terminal_config_default_no_framerate, input_data, gradient_direction, gradient_steps, gradient_stops ) -> None: effect = effect_crumble.Crumble(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate effect.effect_config with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_crumble_args( terminal_config_default_no_framerate, input_data, ) -> None: effect = effect_crumble.Crumble(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_crumble_dynamic_with_preexisting_fg_uses_faded_input_color_initially() -> None: effect = effect_crumble.Crumble("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] initial_scene = character.animation.active_scene initial_frame = initial_scene.frames[-1].character_visual expected_initial_color = character.animation.adjust_color_brightness(Color(196), 0.65) assert initial_frame.symbol == "A" assert initial_frame.colors == effect_crumble.ColorPair(fg=expected_initial_color) assert initial_frame._fg_color_code == expected_initial_color.rgb_color def test_crumble_dynamic_without_preexisting_fg_uses_neutral_gray_initially() -> None: effect = effect_crumble.Crumble("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] initial_scene = character.animation.active_scene initial_frame = initial_scene.frames[-1].character_visual expected_initial_color = character.animation.adjust_color_brightness(effect_crumble.CrumbleIterator.DYNAMIC_NEUTRAL_GRAY, 0.65) assert initial_frame.symbol == "A" assert initial_frame.colors == effect_crumble.ColorPair(fg=expected_initial_color) assert initial_frame._fg_color_code == expected_initial_color.rgb_color def test_crumble_dynamic_with_preexisting_bg_uses_faded_input_bg_initially() -> None: effect = effect_crumble.Crumble("\x1b[48;5;57mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] initial_scene = character.animation.active_scene initial_frame = initial_scene.frames[-1].character_visual expected_initial_bg = character.animation.adjust_color_brightness(Color(57), 0.65) assert initial_frame.symbol == "A" assert initial_frame.colors == effect_crumble.ColorPair(bg=expected_initial_bg) assert initial_frame._fg_color_code is None assert initial_frame._bg_color_code == expected_initial_bg.rgb_color def test_crumble_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: effect = effect_crumble.Crumble("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] strengthen_scene = character.animation.scenes["3"] final_frame = strengthen_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_crumble.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_crumble_dynamic_with_preexisting_fg_ends_on_input_color() -> None: effect = effect_crumble.Crumble("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] strengthen_scene = character.animation.scenes["3"] final_frame = strengthen_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_crumble.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_crumble_dynamic_with_preexisting_bg_ends_on_input_bg_color() -> None: effect = effect_crumble.Crumble("\x1b[48;5;57mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = iter(effect) character = iterator.terminal.get_characters()[0] strengthen_scene = character.animation.scenes["3"] final_frame = strengthen_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_crumble.ColorPair(bg=Color(57)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(57).rgb_color def test_crumble_ignore_with_preexisting_colors_uses_effect_owned_initial_and_final_colors() -> None: effect = effect_crumble.Crumble("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = iter(effect) character = iterator.terminal.get_characters()[0] initial_scene = character.animation.scenes["0"] initial_frame = initial_scene.frames[-1].character_visual strengthen_scene = character.animation.scenes["3"] final_frame = strengthen_scene.frames[-1].character_visual expected_initial_color = character.animation.adjust_color_brightness(iterator.character_final_color_map[character], 0.65) assert initial_frame.symbol == "A" assert initial_frame.colors == effect_crumble.ColorPair(fg=expected_initial_color) assert initial_frame._fg_color_code == expected_initial_color.rgb_color assert final_frame.symbol == "A" assert final_frame.colors == effect_crumble.ColorPair(fg=iterator.character_final_color_map[character]) assert final_frame._fg_color_code == iterator.character_final_color_map[character].rgb_color def test_crumble_always_with_preexisting_colors_uses_input_colors_in_final_scene() -> None: effect = effect_crumble.Crumble("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = iter(effect) character = iterator.terminal.get_characters()[0] strengthen_scene = character.animation.scenes["3"] final_frame = strengthen_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_crumble.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None terminaltexteffects-release-0.15.0/tests/effects_tests/test_decrypt.py000066400000000000000000000175241517776150200264050ustar00rootroot00000000000000"""Tests for the Decrypt effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_decrypt from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_decrypt_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Decrypt effect against a variety of representative inputs.""" effect = effect_decrypt.Decrypt(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_decrypt_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Decrypt output when terminal color toggles change.""" effect = effect_decrypt.Decrypt(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_decrypt_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_decrypt.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Decrypt effect respects final gradient settings.""" effect = effect_decrypt.Decrypt(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("typing_speed", [1, 4]) @pytest.mark.parametrize("ciphertext_colors", [(Color("#ff00ff"),), (Color("#0ffff0"), Color("#0000ff"))]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_decrypt_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, typing_speed: int, ciphertext_colors: tuple[Color, ...], ) -> None: """Ensure Decrypt accepts and renders with various configuration arguments.""" effect = effect_decrypt.Decrypt(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.typing_speed = typing_speed effect.effect_config.ciphertext_colors = ciphertext_colors with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_decrypt_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored in the discovered scene.""" effect = effect_decrypt.Decrypt("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_decrypt.DecryptIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["discovered"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_decrypt.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_decrypt_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the discovered scene.""" effect = effect_decrypt.Decrypt("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_decrypt.DecryptIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["discovered"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_decrypt.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_decrypt_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_decrypt.Decrypt("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_decrypt.DecryptIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["discovered"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_decrypt.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_decrypt_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_decrypt.Decrypt("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_decrypt.DecryptIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["discovered"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_decrypt.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_decrypt_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient instead of input colors.""" effect = effect_decrypt.Decrypt("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_decrypt.DecryptIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["discovered"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" final_color = iterator.character_final_color_map[character].fg_color assert final_frame.colors == effect_decrypt.ColorPair(fg=final_color) assert final_color is not None assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_decrypt_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the discovered scene to the parsed input colors.""" effect = effect_decrypt.Decrypt("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_decrypt.DecryptIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.scenes["discovered"] final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_decrypt.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_errorcorrect.py000066400000000000000000000315721517776150200274450ustar00rootroot00000000000000"""Tests for the ErrorCorrect effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_errorcorrect from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_errorcorrect_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the ErrorCorrect effect against a variety of representative inputs.""" effect = effect_errorcorrect.ErrorCorrect(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_errorcorrect_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test ErrorCorrect output when terminal color toggles change.""" effect = effect_errorcorrect.ErrorCorrect(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_errorcorrect_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_errorcorrect.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the ErrorCorrect effect respects final gradient settings.""" effect = effect_errorcorrect.ErrorCorrect(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("error_pairs", [0.001, 0.5, 1]) @pytest.mark.parametrize("swap_delay", [1, 10]) @pytest.mark.parametrize("error_color", [Color("#ff00ff"), Color("#0ffff0")]) @pytest.mark.parametrize("correct_color", [Color("#ff00ff"), Color("#0ffff0")]) @pytest.mark.parametrize("movement_speed", [0.01, 4]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_errorcorrect_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, error_pairs: float, swap_delay: int, error_color: Color, correct_color: Color, movement_speed: float, ) -> None: """Ensure ErrorCorrect accepts and renders with various configuration arguments.""" effect = effect_errorcorrect.ErrorCorrect(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.error_pairs = error_pairs effect.effect_config.swap_delay = swap_delay effect.effect_config.error_color = error_color effect.effect_config.correct_color = correct_color effect.effect_config.movement_speed = movement_speed with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_errorcorrect_dynamic_unswapped_with_preexisting_fg_uses_input_fg_from_start() -> None: """Verify unswapped characters use parsed foreground color immediately in dynamic mode.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 0 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = iterator.terminal.get_characters()[0] assert character.animation.active_scene is not None initial_frame = character.animation.active_scene.frames[-1].character_visual assert initial_frame.symbol == "A" assert initial_frame.colors == effect_errorcorrect.ColorPair(fg=Color(196)) assert initial_frame._fg_color_code == Color(196).rgb_color assert initial_frame._bg_color_code is None def test_errorcorrect_dynamic_unswapped_with_preexisting_bg_uses_input_bg_from_start() -> None: """Verify unswapped characters use parsed background color immediately in dynamic mode.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 0 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = iterator.terminal.get_characters()[0] assert character.animation.active_scene is not None initial_frame = character.animation.active_scene.frames[-1].character_visual assert initial_frame.symbol == "A" assert initial_frame.colors == effect_errorcorrect.ColorPair(bg=Color(106)) assert initial_frame._fg_color_code is None assert initial_frame._bg_color_code == Color(106).rgb_color def test_errorcorrect_dynamic_unswapped_without_preexisting_colors_has_no_color_from_start() -> None: """Verify unswapped characters with no parsed colors start uncolored in dynamic mode.""" effect = effect_errorcorrect.ErrorCorrect("A") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 0 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = iterator.terminal.get_characters()[0] assert character.animation.active_scene is not None initial_frame = character.animation.active_scene.frames[-1].character_visual assert initial_frame.symbol == "A" assert initial_frame.colors == effect_errorcorrect.ColorPair() assert initial_frame._fg_color_code is None assert initial_frame._bg_color_code is None def test_errorcorrect_dynamic_swapped_error_scene_stays_error_colored() -> None: """Verify swapped characters still use the error color during the error scene.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[38;5;196mA\x1b[0m\x1b[48;5;106mB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 1 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = next(character for pair in iterator.swapped for character in pair if character.input_symbol == "A") error_scene = character.animation.scenes["error"] frame = error_scene.frames[0].character_visual assert frame.colors == effect_errorcorrect.ColorPair(fg=effect.effect_config.error_color) assert frame._bg_color_code is None def test_errorcorrect_dynamic_swapped_first_block_wipe_stays_error_colored() -> None: """Verify the first block wipe remains effect-colored for swapped characters.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[38;5;196mA\x1b[0m\x1b[48;5;106mB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 1 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = next(character for pair in iterator.swapped for character in pair if character.input_symbol == "A") scene_sequence = list(character.animation.scenes.values()) first_block_wipe = scene_sequence[1] frame = first_block_wipe.frames[0].character_visual assert frame.colors == effect_errorcorrect.ColorPair(fg=effect.effect_config.error_color) assert frame._bg_color_code is None def test_errorcorrect_dynamic_swapped_last_block_wipe_ends_on_input_colors() -> None: """Verify the last block wipe leaves swapped characters on their parsed input colors.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[38;5;196mA\x1b[0m\x1b[48;5;106mB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 1 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = next(character for pair in iterator.swapped for character in pair if character.input_symbol == "A") scene_sequence = list(character.animation.scenes.values()) last_block_wipe = scene_sequence[2] final_frame = last_block_wipe.frames[-1].character_visual assert final_frame.colors == effect_errorcorrect.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_errorcorrect_dynamic_swapped_last_block_wipe_ends_uncolored_without_input_colors() -> None: """Verify the last block wipe ends uncolored when swapped characters have no parsed colors.""" effect = effect_errorcorrect.ErrorCorrect("AB") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 1 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = iterator.swapped[0][0] scene_sequence = list(character.animation.scenes.values()) last_block_wipe = scene_sequence[2] final_frame = last_block_wipe.frames[-1].character_visual assert final_frame.colors == effect_errorcorrect.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_errorcorrect_dynamic_swapped_final_scene_uses_input_colors() -> None: """Verify swapped characters settle back to parsed fg/bg colors in the final scene.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[38;5;196mA\x1b[0m\x1b[48;5;106mB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 1 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = next(character for pair in iterator.swapped for character in pair if character.input_symbol == "B") scene_sequence = list(character.animation.scenes.values()) final_scene = scene_sequence[6] final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == effect_errorcorrect.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_errorcorrect_dynamic_swapped_final_scene_is_uncolored_without_input_colors() -> None: """Verify swapped characters with no parsed colors settle uncolored in the final scene.""" effect = effect_errorcorrect.ErrorCorrect("AB") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.error_pairs = 1 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = iterator.swapped[0][0] scene_sequence = list(character.animation.scenes.values()) final_scene = scene_sequence[6] final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == effect_errorcorrect.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_errorcorrect_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned gradient behavior.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") effect.effect_config.error_pairs = 0 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = iterator.terminal.get_characters()[0] assert character.animation.active_scene is not None initial_frame = character.animation.active_scene.frames[-1].character_visual final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None assert initial_frame.colors == effect_errorcorrect.ColorPair(fg=final_color) assert initial_frame._fg_color_code == final_color.rgb_color assert initial_frame._bg_color_code is None def test_errorcorrect_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to parsed input colors.""" effect = effect_errorcorrect.ErrorCorrect("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") effect.effect_config.error_pairs = 0 iterator = cast("effect_errorcorrect.ErrorCorrectIterator", iter(effect)) character = iterator.terminal.get_characters()[0] assert character.animation.active_scene is not None initial_frame = character.animation.active_scene.frames[-1].character_visual assert initial_frame.colors == effect_errorcorrect.ColorPair(fg=Color(196), bg=Color(106)) assert initial_frame._fg_color_code == Color(196).rgb_color assert initial_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_expand.py000066400000000000000000000205501517776150200262030ustar00rootroot00000000000000"""Tests for the Expand effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_expand from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_expand_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Expand effect against a variety of representative inputs.""" effect = effect_expand.Expand(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_expand_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Expand output when terminal color toggles change.""" effect = effect_expand.Expand(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_expand_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_expand.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Expand effect respects final gradient settings.""" effect = effect_expand.Expand(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("movement_speed", [0.01, 4]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_expand_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, movement_speed: float, easing_function_1: effect_expand.easing.EasingFunction, ) -> None: """Ensure Expand accepts and renders with various configuration arguments.""" effect = effect_expand.Expand(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.movement_speed = movement_speed effect.effect_config.expand_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_expand_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored in the final gradient scene.""" effect = effect_expand.Expand("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_expand.ExpandIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_expand.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_expand_gradient_scene_syncs_to_path_distance() -> None: """Verify the final gradient scene syncs to motion distance.""" effect = effect_expand.Expand("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_expand.ExpandIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None assert final_scene.sync == effect_expand.Scene.SyncMetric.DISTANCE def test_expand_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the final gradient scene.""" effect = effect_expand.Expand("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_expand.ExpandIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_expand.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_expand_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_expand.Expand("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_expand.ExpandIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_expand.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_expand_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_expand.Expand("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_expand.ExpandIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_expand.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_expand_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color.""" effect = effect_expand.Expand("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_expand.ExpandIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert final_scene is not None assert final_color is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_expand.ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_expand_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the final visible frame to the parsed input colors.""" effect = effect_expand.Expand("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_expand.ExpandIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_expand.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_fireworks.py000066400000000000000000000210021517776150200267300ustar00rootroot00000000000000"""Tests for the Fireworks effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_fireworks from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_fireworks_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Fireworks effect against a variety of representative inputs.""" effect = effect_fireworks.Fireworks(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_fireworks_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Fireworks output when terminal color toggles change.""" effect = effect_fireworks.Fireworks(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_fireworks_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_fireworks.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Fireworks effect respects final gradient settings.""" effect = effect_fireworks.Fireworks(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("explode_anywhere", [True, False]) @pytest.mark.parametrize("firework_colors", [(Color("#ff00ff"),), (Color("#0ffff0"), Color("#0000ff"))]) @pytest.mark.parametrize("firework_symbol", ["+", "x"]) @pytest.mark.parametrize("firework_volume", [0.001, 0.2, 1]) @pytest.mark.parametrize("launch_delay", [0, 10]) @pytest.mark.parametrize("explode_distance", [0.001, 0.5, 1]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_fireworks_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, explode_anywhere: Literal[True, False], firework_colors: tuple[Color, ...], firework_symbol: str, firework_volume: float, launch_delay: int, explode_distance: float, ) -> None: """Ensure Fireworks accepts and renders with various configuration arguments.""" effect = effect_fireworks.Fireworks(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.explode_anywhere = explode_anywhere effect.effect_config.firework_colors = firework_colors effect.effect_config.firework_symbol = firework_symbol effect.effect_config.firework_volume = firework_volume effect.effect_config.launch_delay = launch_delay effect.effect_config.explode_distance = explode_distance with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_fireworks_dynamic_without_preexisting_colors_has_uncolored_fall_scene_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored in the fall scene.""" effect = effect_fireworks.Fireworks("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_fireworks.FireworksIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fall_scene = character.animation.scenes["fall_scn"] final_frame = fall_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_fireworks.ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_fireworks_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the fall scene.""" effect = effect_fireworks.Fireworks("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_fireworks.FireworksIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fall_scene = character.animation.scenes["fall_scn"] final_frame = fall_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_fireworks.ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_fireworks_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_fireworks.Fireworks("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_fireworks.FireworksIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fall_scene = character.animation.scenes["fall_scn"] final_frame = fall_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_fireworks.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_fireworks_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_fireworks.Fireworks("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_fireworks.FireworksIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fall_scene = character.animation.scenes["fall_scn"] final_frame = fall_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_fireworks.ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_fireworks_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color in the fall scene.""" effect = effect_fireworks.Fireworks("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_fireworks.FireworksIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fall_scene = character.animation.scenes["fall_scn"] final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None final_frame = fall_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_fireworks.ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_fireworks_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the fall scene final frame to parsed input colors.""" effect = effect_fireworks.Fireworks("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_fireworks.FireworksIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fall_scene = character.animation.scenes["fall_scn"] final_frame = fall_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == effect_fireworks.ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_highlight.py000066400000000000000000000225331517776150200266760ustar00rootroot00000000000000"""Tests for the highlight effect.""" from __future__ import annotations from typing import TYPE_CHECKING, Literal, cast import pytest from terminaltexteffects.effects import effect_highlight from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import ColorPair if TYPE_CHECKING: from terminaltexteffects import Color from terminaltexteffects.utils.argutils import CharacterGroup from terminaltexteffects.utils.graphics import Gradient def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_highlight_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Render the highlight effect across various inputs using the default terminal configuration.""" effect = effect_highlight.Highlight(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_highlight_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Ensure the highlight effect works when terminal color options are toggled.""" effect = effect_highlight.Highlight(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_highlight_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Validate custom final gradient settings render without errors.""" effect = effect_highlight.Highlight(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("highlight_width", [1, 20]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) @pytest.mark.parametrize("highlight_brightness", [0.5, 2]) def test_highlight_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, character_group: CharacterGroup, highlight_brightness: float, highlight_width: int, ) -> None: """Check highlight configuration options such as direction, brightness, and width.""" effect = effect_highlight.Highlight(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.highlight_direction = character_group effect.effect_config.highlight_brightness = highlight_brightness effect.effect_config.highlight_width = highlight_width with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_highlight_dynamic_with_preexisting_fg_uses_input_fg_for_base_and_returns_to_it() -> None: """Verify dynamic mode derives the base color and highlight return color from input fg.""" effect = effect_highlight.Highlight("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_highlight.HighlightIterator", iter(effect)) character = iterator.terminal.get_characters()[0] highlight_scene = character.animation.scenes["highlight"] final_frame = highlight_scene.frames[-1].character_visual base_visual = character.animation.current_character_visual assert base_visual.colors == ColorPair(fg=effect_highlight.Color(196)) assert base_visual._fg_color_code == effect_highlight.Color(196).rgb_color assert base_visual._bg_color_code is None assert final_frame.colors == ColorPair(fg=effect_highlight.Color(196)) assert final_frame._fg_color_code == effect_highlight.Color(196).rgb_color assert final_frame._bg_color_code is None def test_highlight_dynamic_with_preexisting_bg_space_preserves_input_bg() -> None: """Verify dynamic mode preserves parsed background color on input spaces.""" effect = effect_highlight.Highlight("\x1b[48;5;21m \x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_highlight.HighlightIterator", iter(effect)) character = iterator.terminal.get_characters()[0] highlight_scene = character.animation.scenes["highlight"] base_visual = character.animation.current_character_visual final_frame = highlight_scene.frames[-1].character_visual assert base_visual.colors == ColorPair(bg=effect_highlight.Color(21)) assert base_visual._fg_color_code is None assert base_visual._bg_color_code == effect_highlight.Color(21).rgb_color assert final_frame.colors == ColorPair(bg=effect_highlight.Color(21)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == effect_highlight.Color(21).rgb_color def test_highlight_dynamic_without_preexisting_fg_has_no_visible_highlight_effect() -> None: """Verify dynamic mode leaves no-fg characters uncolored throughout the highlight scene.""" effect = effect_highlight.Highlight("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_highlight.HighlightIterator", iter(effect)) character = iterator.terminal.get_characters()[0] highlight_scene = character.animation.scenes["highlight"] base_visual = character.animation.current_character_visual final_frame = highlight_scene.frames[-1].character_visual assert base_visual.colors == ColorPair() assert base_visual._fg_color_code is None assert base_visual._bg_color_code is None assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_highlight_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final-gradient base and highlight colors.""" effect = effect_highlight.Highlight("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_highlight.HighlightIterator", iter(effect)) character = iterator.terminal.get_characters()[0] highlight_scene = character.animation.scenes["highlight"] base_visual = character.animation.current_character_visual final_frame = highlight_scene.frames[-1].character_visual final_color = iterator.character_final_color_map[character] assert final_color is not None assert base_visual.colors == ColorPair(fg=final_color) assert base_visual._fg_color_code == final_color.rgb_color assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_highlight_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible highlight frames to the parsed input colors.""" effect = effect_highlight.Highlight("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_highlight.HighlightIterator", iter(effect)) character = iterator.terminal.get_characters()[0] highlight_scene = character.animation.scenes["highlight"] base_visual = character.animation.current_character_visual final_frame = highlight_scene.frames[-1].character_visual assert base_visual.colors == ColorPair(fg=effect_highlight.Color(196)) assert base_visual._fg_color_code == effect_highlight.Color(196).rgb_color assert final_frame.colors == ColorPair(fg=effect_highlight.Color(196)) assert final_frame._fg_color_code == effect_highlight.Color(196).rgb_color assert final_frame._bg_color_code is None def test_highlight_always_with_preexisting_bg_space_uses_input_bg() -> None: """Verify always mode resolves visible frames for input spaces to the parsed background color.""" effect = effect_highlight.Highlight("\x1b[48;5;21m \x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_highlight.HighlightIterator", iter(effect)) character = iterator.terminal.get_characters()[0] highlight_scene = character.animation.scenes["highlight"] base_visual = character.animation.current_character_visual final_frame = highlight_scene.frames[-1].character_visual assert base_visual.colors == ColorPair(bg=effect_highlight.Color(21)) assert base_visual._fg_color_code is None assert base_visual._bg_color_code == effect_highlight.Color(21).rgb_color assert final_frame.colors == ColorPair(bg=effect_highlight.Color(21)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == effect_highlight.Color(21).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_laseretch.py000066400000000000000000000244451517776150200267050ustar00rootroot00000000000000"""Tests for the LaserEtch effect and its dynamic color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_laseretch from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_laseretch_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the LaserEtch effect against a variety of representative inputs.""" effect = effect_laseretch.LaserEtch(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_laseretch_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test LaserEtch output when terminal color toggles change.""" effect = effect_laseretch.LaserEtch(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_laseretch_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_laseretch.tte.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], gradient_frames: int, ) -> None: """Verify the LaserEtch effect respects final gradient settings.""" effect = effect_laseretch.LaserEtch(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.effect_config.final_gradient_frames = gradient_frames effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize( "etch_direction", [ "column_left_to_right", "row_top_to_bottom", "row_bottom_to_top", "diagonal_top_left_to_bottom_right", "diagonal_bottom_left_to_top_right", "diagonal_top_right_to_bottom_left", "diagonal_bottom_right_to_top_left", "outside_to_center", "center_to_outside", ], ) @pytest.mark.parametrize("etch_speed", [1, 20]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) @pytest.mark.parametrize("etch_delay", [0, 5]) def test_laseretch_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, etch_speed: int, etch_delay: int, etch_direction: str, ) -> None: """Ensure LaserEtch accepts and renders with various configuration arguments.""" effect = effect_laseretch.LaserEtch(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.etch_pattern = cast("effect_laseretch.argutils.CharacterGroup", etch_direction) effect.effect_config.etch_speed = etch_speed effect.effect_config.etch_delay = etch_delay with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_laseretch_dynamic_without_preexisting_colors_cools_to_white_then_clears() -> None: """Verify dynamic mode cools to white and then clears uncolored input back to terminal default.""" effect = effect_laseretch.LaserEtch("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] white_frame = spawn_scene.frames[-2].character_visual final_frame = spawn_scene.frames[-1].character_visual assert white_frame.symbol == "A" assert white_frame.colors == ColorPair(fg=Color("#ffffff")) assert white_frame._fg_color_code == Color("#ffffff").rgb_color assert white_frame._bg_color_code is None assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_laseretch_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color after the white cooldown.""" effect = effect_laseretch.LaserEtch("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] penultimate_frame = spawn_scene.frames[-2].character_visual final_frame = spawn_scene.frames[-1].character_visual assert penultimate_frame._fg_color_code != Color("#ffffff").rgb_color assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_laseretch_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_laseretch.LaserEtch("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] final_frame = spawn_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_laseretch_dynamic_with_preexisting_bg_space_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color on input spaces.""" effect = effect_laseretch.LaserEtch("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] final_frame = spawn_scene.frames[-1].character_visual assert final_frame.symbol == " " assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color assert iterator._has_input_colors(character) def test_laseretch_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_laseretch.LaserEtch("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] final_frame = spawn_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_laseretch_always_with_preexisting_bg_space_uses_input_bg_color() -> None: """Verify always mode restores a parsed background color on input spaces.""" effect = effect_laseretch.LaserEtch("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] final_frame = spawn_scene.frames[-1].character_visual assert final_frame.symbol == " " assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color assert iterator._has_input_colors(character) def test_laseretch_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color in the spawn scene.""" effect = effect_laseretch.LaserEtch("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None final_frame = spawn_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_laseretch_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the final visible spawn frame to parsed input colors.""" effect = effect_laseretch.LaserEtch("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_laseretch.LaserEtchIterator", iter(effect)) character = iterator.terminal.get_characters()[0] spawn_scene = character.animation.scenes["spawn"] final_frame = spawn_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_matrix.py000066400000000000000000000247331517776150200262370ustar00rootroot00000000000000"""Tests for the Matrix effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_matrix from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_matrix_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Matrix effect against a variety of representative inputs.""" effect = effect_matrix.Matrix(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.rain_time = 1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_matrix_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Matrix output when terminal color toggles change.""" effect = effect_matrix.Matrix(input_data) effect.terminal_config = terminal_config_with_color_options effect.effect_config.rain_time = 1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_matrix_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_matrix.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Matrix effect respects final gradient settings.""" effect = effect_matrix.Matrix(input_data) effect.effect_config.rain_time = 1 effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("highlight_color", [Color("#ff00ff"), Color("#0ffff0")]) @pytest.mark.parametrize("rain_color_gradient", [(Color("#ff0fff"),), (Color("#ff0fff"), Color("#ff0fff"))]) @pytest.mark.parametrize("rain_symbols", [("a",), ("a", "b")]) @pytest.mark.parametrize("rain_fall_delay_range", [(1, 2), (2, 3)]) @pytest.mark.parametrize("rain_column_delay_range", [(1, 2), (2, 3)]) @pytest.mark.parametrize("rain_time", [1, 2]) @pytest.mark.parametrize("resolve_delay", [1, 5]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_matrix_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, highlight_color: Color, rain_color_gradient: tuple[Color, ...], rain_symbols: tuple[str, ...], rain_fall_delay_range: tuple[int, int], rain_column_delay_range: tuple[int, int], rain_time: int, resolve_delay: int, ) -> None: """Ensure Matrix accepts and renders with various configuration arguments.""" effect = effect_matrix.Matrix(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.highlight_color = highlight_color effect.effect_config.rain_color_gradient = rain_color_gradient effect.effect_config.rain_symbols = rain_symbols effect.effect_config.rain_fall_delay_range = rain_fall_delay_range effect.effect_config.rain_column_delay_range = rain_column_delay_range effect.effect_config.rain_time = rain_time effect.effect_config.resolve_delay = resolve_delay with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_matrix_dynamic_without_preexisting_colors_has_uncolored_resolve_scene_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored in the resolve scene.""" effect = effect_matrix.Matrix("A") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_matrix_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the resolve scene.""" effect = effect_matrix.Matrix("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_matrix_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_matrix.Matrix("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_matrix_dynamic_with_preexisting_bg_space_uses_input_bg_color() -> None: """Verify dynamic mode resolves parsed background colors on input spaces.""" effect = effect_matrix.Matrix("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == " " assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color assert iterator._has_input_colors(character) def test_matrix_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_matrix.Matrix("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_matrix_always_with_preexisting_bg_space_uses_input_bg_color() -> None: """Verify always mode resolves parsed background colors on input spaces.""" effect = effect_matrix.Matrix("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("always") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == " " assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color assert iterator._has_input_colors(character) def test_matrix_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color in the resolve scene.""" effect = effect_matrix.Matrix("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_matrix_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the resolve scene final frame to parsed input colors.""" effect = effect_matrix.Matrix("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") effect.effect_config.rain_time = 1 iterator = cast("effect_matrix.MatrixIterator", iter(effect)) character = iterator.terminal.get_characters()[0] resolve_scene = character.animation.scenes["resolve"] final_frame = resolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_middleout.py000066400000000000000000000216011517776150200267100ustar00rootroot00000000000000"""Tests for the MiddleOut effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_middleout from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_middleout_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the MiddleOut effect against a variety of representative inputs.""" effect = effect_middleout.MiddleOut(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_middleout_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test MiddleOut output when terminal color toggles change.""" effect = effect_middleout.MiddleOut(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_middleout_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_middleout.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the MiddleOut effect respects final gradient settings.""" effect = effect_middleout.MiddleOut(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("starting_color", [Color("#000000"), Color("#ff00ff")]) @pytest.mark.parametrize("expand_direction", ["horizontal", "vertical"]) @pytest.mark.parametrize("center_movement_speed", [0.001, 2.0]) @pytest.mark.parametrize("full_movement_speed", [0.001, 2.0]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_middleout_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, starting_color: Color, expand_direction: Literal["horizontal", "vertical"], center_movement_speed: float, full_movement_speed: float, ) -> None: """Ensure MiddleOut accepts and renders with various configuration arguments.""" effect = effect_middleout.MiddleOut(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.starting_color = starting_color effect.effect_config.expand_direction = expand_direction effect.effect_config.center_movement_speed = center_movement_speed effect.effect_config.full_movement_speed = full_movement_speed with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_middleout_easing( terminal_config_default_no_framerate: TerminalConfig, input_data: str, easing_function_1: effect_middleout.easing.EasingFunction, easing_function_2: effect_middleout.easing.EasingFunction, ) -> None: """Ensure MiddleOut accepts and renders with various easing functions.""" effect = effect_middleout.MiddleOut(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.center_easing = easing_function_1 effect.effect_config.full_easing = easing_function_2 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_middleout_dynamic_without_preexisting_colors_has_uncolored_full_scene_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored in the full scene.""" effect = effect_middleout.MiddleOut("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_middleout.MiddleOutIterator", iter(effect)) character = iterator.terminal.get_characters()[0] full_scene = character.animation.scenes["full"] final_frame = full_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_middleout_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the full scene.""" effect = effect_middleout.MiddleOut("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_middleout.MiddleOutIterator", iter(effect)) character = iterator.terminal.get_characters()[0] full_scene = character.animation.scenes["full"] final_frame = full_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_middleout_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_middleout.MiddleOut("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_middleout.MiddleOutIterator", iter(effect)) character = iterator.terminal.get_characters()[0] full_scene = character.animation.scenes["full"] final_frame = full_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_middleout_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_middleout.MiddleOut("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_middleout.MiddleOutIterator", iter(effect)) character = iterator.terminal.get_characters()[0] full_scene = character.animation.scenes["full"] final_frame = full_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_middleout_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color in the full scene.""" effect = effect_middleout.MiddleOut("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_middleout.MiddleOutIterator", iter(effect)) character = iterator.terminal.get_characters()[0] full_scene = character.animation.scenes["full"] final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None final_frame = full_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_middleout_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the full scene final frame to parsed input colors.""" effect = effect_middleout.MiddleOut("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_middleout.MiddleOutIterator", iter(effect)) character = iterator.terminal.get_characters()[0] full_scene = character.animation.scenes["full"] final_frame = full_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_orbittingvolley.py000066400000000000000000000253621517776150200301660ustar00rootroot00000000000000"""Tests for the OrbittingVolley effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_orbittingvolley from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_orbittingvolley_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the OrbittingVolley effect against a variety of representative inputs.""" effect = effect_orbittingvolley.OrbittingVolley(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_orbittingvolley_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test OrbittingVolley output when terminal color toggles change.""" effect = effect_orbittingvolley.OrbittingVolley(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_orbittingvolley_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_orbittingvolley.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the OrbittingVolley effect respects final gradient settings.""" effect = effect_orbittingvolley.OrbittingVolley(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("top_launcher_symbol", ["a", "b"]) @pytest.mark.parametrize("right_launcher_symbol", ["a", "b"]) @pytest.mark.parametrize("bottom_launcher_symbol", ["a", "b"]) @pytest.mark.parametrize("left_launcher_symbol", ["a", "b"]) @pytest.mark.parametrize("launcher_movement_speed", [0.1, 2.0]) @pytest.mark.parametrize("character_movement_speed", [0.1, 2.0]) @pytest.mark.parametrize("volley_size", [0.0001, 0.5, 1.0]) @pytest.mark.parametrize("launch_delay", [1, 5]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_orbittingvolley_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, top_launcher_symbol: str, right_launcher_symbol: str, bottom_launcher_symbol: str, left_launcher_symbol: str, launcher_movement_speed: float, character_movement_speed: float, volley_size: float, launch_delay: int, ) -> None: """Ensure OrbittingVolley accepts and renders with various configuration arguments.""" effect = effect_orbittingvolley.OrbittingVolley(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.top_launcher_symbol = top_launcher_symbol effect.effect_config.right_launcher_symbol = right_launcher_symbol effect.effect_config.bottom_launcher_symbol = bottom_launcher_symbol effect.effect_config.left_launcher_symbol = left_launcher_symbol effect.effect_config.launcher_movement_speed = launcher_movement_speed effect.effect_config.character_movement_speed = character_movement_speed effect.effect_config.volley_size = volley_size effect.effect_config.launch_delay = launch_delay with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("launcher_movement_speed", [0.1, 2.0]) @pytest.mark.parametrize("character_movement_speed", [0.1, 2.0]) @pytest.mark.parametrize("volley_size", [0.0001, 0.5, 1.0]) @pytest.mark.parametrize("launch_delay", [1, 5]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_orbittingvolley_easing( terminal_config_default_no_framerate: TerminalConfig, input_data: str, launcher_movement_speed: float, character_movement_speed: float, volley_size: float, launch_delay: int, easing_function_1: effect_orbittingvolley.easing.EasingFunction, ) -> None: """Ensure OrbittingVolley accepts and renders with various easing functions.""" effect = effect_orbittingvolley.OrbittingVolley(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.launcher_movement_speed = launcher_movement_speed effect.effect_config.character_movement_speed = character_movement_speed effect.effect_config.volley_size = volley_size effect.effect_config.launch_delay = launch_delay effect.effect_config.character_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_orbittingvolley_dynamic_without_preexisting_colors_uses_uncolored_character_visual() -> None: """Verify dynamic mode leaves uncolored input uncolored from initial character appearance.""" effect = effect_orbittingvolley.OrbittingVolley("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_orbittingvolley.OrbittingVolleyIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair() assert current_visual._fg_color_code is None assert current_visual._bg_color_code is None def test_orbittingvolley_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color from initial character appearance.""" effect = effect_orbittingvolley.OrbittingVolley("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_orbittingvolley.OrbittingVolleyIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code is None def test_orbittingvolley_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_orbittingvolley.OrbittingVolley("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_orbittingvolley.OrbittingVolleyIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(bg=Color(106)) assert current_visual._fg_color_code is None assert current_visual._bg_color_code == Color(106).rgb_color def test_orbittingvolley_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_orbittingvolley.OrbittingVolley("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_orbittingvolley.OrbittingVolleyIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_orbittingvolley_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color in the direct character appearance.""" effect = effect_orbittingvolley.OrbittingVolley("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_orbittingvolley.OrbittingVolleyIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_color = iterator.character_final_color_map[character].fg_color current_visual = character.animation.current_character_visual assert final_color is not None assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=final_color) assert current_visual._fg_color_code == final_color.rgb_color assert current_visual._bg_color_code is None def test_orbittingvolley_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the direct character appearance to parsed input colors.""" effect = effect_orbittingvolley.OrbittingVolley("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_orbittingvolley.OrbittingVolleyIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_orbittingvolley_always_keeps_helper_launcher_effect_colored() -> None: """Verify helper launcher characters remain effect-colored under always mode.""" effect = effect_orbittingvolley.OrbittingVolley("A") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_orbittingvolley.OrbittingVolleyIterator", iter(effect)) launcher_visual = iterator._main_launcher.character.animation.current_character_visual assert launcher_visual.colors == ColorPair(fg=iterator.final_gradient.spectrum[-1]) assert launcher_visual._fg_color_code == iterator.final_gradient.spectrum[-1].rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_overflow.py000066400000000000000000000212261517776150200265700ustar00rootroot00000000000000"""Tests for the Overflow effect and its final-row dynamic color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_overflow from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_overflow_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Overflow effect against a variety of representative inputs.""" effect = effect_overflow.Overflow(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_overflow_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Overflow output when terminal color toggles change.""" effect = effect_overflow.Overflow(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_overflow_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_overflow.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Overflow effect respects final gradient settings.""" effect = effect_overflow.Overflow(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("overflow_gradient_stops", [(Color("#000000"),), (Color("#ff00ff"), Color("#0ffff0"))]) @pytest.mark.parametrize("overflow_cycles_range", [(1, 5), (5, 10)]) @pytest.mark.parametrize("overflow_speed", [1, 5]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_overflow_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, overflow_gradient_stops: tuple[Color, ...], overflow_cycles_range: tuple[int, int], overflow_speed: int, ) -> None: """Ensure Overflow accepts and renders with various configuration arguments.""" effect = effect_overflow.Overflow(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.overflow_gradient_stops = overflow_gradient_stops effect.effect_config.overflow_cycles_range = overflow_cycles_range effect.effect_config.overflow_speed = overflow_speed with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_overflow_dynamic_without_preexisting_colors_has_uncolored_final_row_visual() -> None: """Verify dynamic mode leaves final-row characters without explicit color when no input colors exist.""" effect = effect_overflow.Overflow("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_overflow.OverflowIterator", iter(effect)) final_row = iterator.pending_rows[-1] character = final_row.characters[0] current_visual = character.animation.current_character_visual assert final_row.final is True assert current_visual.symbol == "A" assert current_visual.colors == ColorPair() assert current_visual._fg_color_code is None assert current_visual._bg_color_code is None def test_overflow_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in final rows.""" effect = effect_overflow.Overflow("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_overflow.OverflowIterator", iter(effect)) final_row = iterator.pending_rows[-1] character = final_row.characters[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code is None def test_overflow_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_overflow.Overflow("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_overflow.OverflowIterator", iter(effect)) final_row = iterator.pending_rows[-1] character = final_row.characters[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(bg=Color(106)) assert current_visual._fg_color_code is None assert current_visual._bg_color_code == Color(106).rgb_color def test_overflow_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together in final rows.""" effect = effect_overflow.Overflow("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_overflow.OverflowIterator", iter(effect)) final_row = iterator.pending_rows[-1] character = final_row.characters[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_overflow_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color in final rows.""" effect = effect_overflow.Overflow("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_overflow.OverflowIterator", iter(effect)) final_row = iterator.pending_rows[-1] character = final_row.characters[0] final_color = iterator.character_final_color_map[character] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=final_color) assert current_visual._fg_color_code == final_color.rgb_color assert current_visual._bg_color_code is None def test_overflow_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves final rows to parsed input colors.""" effect = effect_overflow.Overflow("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_overflow.OverflowIterator", iter(effect)) final_row = iterator.pending_rows[-1] character = final_row.characters[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_overflow_always_without_preexisting_colors_has_uncolored_final_row_visual() -> None: """Verify always mode leaves final-row characters uncolored when no input colors exist.""" effect = effect_overflow.Overflow("A") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_overflow.OverflowIterator", iter(effect)) final_row = iterator.pending_rows[-1] character = final_row.characters[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair() assert current_visual._fg_color_code is None assert current_visual._bg_color_code is None terminaltexteffects-release-0.15.0/tests/effects_tests/test_pour.py000066400000000000000000000202331517776150200257070ustar00rootroot00000000000000"""Tests for the Pour effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_pour from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_pour_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Pour effect against a variety of representative inputs.""" effect = effect_pour.Pour(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_pour_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Pour output when terminal color toggles change.""" effect = effect_pour.Pour(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_pour_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_pour.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Pour effect respects final gradient settings.""" effect = effect_pour.Pour(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("pour_direction", ["up", "down", "left", "right"]) @pytest.mark.parametrize("pour_speed", [1, 5]) @pytest.mark.parametrize("movement_speed_range", [(0.1, 0.2), (2.0, 4.0)]) @pytest.mark.parametrize("gap", [0, 10]) @pytest.mark.parametrize("starting_color", [Color("#ffffff"), Color("#000000")]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_pour_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, pour_direction: Literal["up", "down", "left", "right"], pour_speed: int, movement_speed_range: tuple[float, float], gap: int, starting_color: Color, ) -> None: """Ensure Pour accepts and renders with various configuration arguments.""" effect = effect_pour.Pour(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.pour_direction = pour_direction effect.effect_config.pour_speed = pour_speed effect.effect_config.movement_speed_range = movement_speed_range effect.effect_config.gap = gap effect.effect_config.starting_color = starting_color with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_pour_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored in the pour scene.""" effect = effect_pour.Pour("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_pour.PourIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_pour_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the pour scene.""" effect = effect_pour.Pour("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_pour.PourIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_pour_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_pour.Pour("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_pour.PourIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_pour_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_pour.Pour("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_pour.PourIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_pour_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color.""" effect = effect_pour.Pour("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_pour.PourIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert final_scene is not None assert final_color is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_pour_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the final visible frame to the parsed input colors.""" effect = effect_pour.Pour("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_pour.PourIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_print.py000066400000000000000000000175761517776150200260760ustar00rootroot00000000000000"""Tests for the Print effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_print from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_print_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Print effect against a variety of representative inputs.""" effect = effect_print.Print(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_print_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Print output when terminal color toggles change.""" effect = effect_print.Print(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_print_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_print.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Print effect respects final gradient settings.""" effect = effect_print.Print(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("print_head_return_speed", [0.1, 2]) @pytest.mark.parametrize("print_speed", [1, 5]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_print_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, print_head_return_speed: float, print_speed: int, easing_function_1: effect_print.easing.EasingFunction, ) -> None: """Ensure Print accepts and renders with various configuration arguments.""" effect = effect_print.Print(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.print_head_return_speed = print_head_return_speed effect.effect_config.print_speed = print_speed effect.effect_config.print_head_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_print_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored at the end of typing.""" effect = effect_print.Print("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_print.PrintIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_print_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color at the end of typing.""" effect = effect_print.Print("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_print.PrintIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_print_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_print.Print("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_print.PrintIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_print_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_print.Print("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_print.PrintIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_print_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color.""" effect = effect_print.Print("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_print.PrintIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert final_scene is not None assert final_color is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_print_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the final visible frame to the parsed input colors.""" effect = effect_print.Print("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_print.PrintIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_rain.py000066400000000000000000000175741517776150200256710ustar00rootroot00000000000000"""Tests for the Rain effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_rain from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_rain_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Rain effect against a variety of representative inputs.""" effect = effect_rain.Rain(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_rain_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Rain output when terminal color toggles change.""" effect = effect_rain.Rain(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_rain_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_rain.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Rain effect respects final gradient settings.""" effect = effect_rain.Rain(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("rain_colors", [(Color("#000000"),), (Color("#ff00ff"), Color("#0ffff0"))]) @pytest.mark.parametrize("movement_speed", [(0.1, 1), (2, 4)]) @pytest.mark.parametrize("rain_symbols", [("a",), ("b", "c")]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_rain_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, rain_colors: tuple[Color, ...], movement_speed: tuple[float, float], rain_symbols: tuple[str, ...], easing_function_1: effect_rain.easing.EasingFunction, ) -> None: """Ensure Rain accepts and renders with various configuration arguments.""" effect = effect_rain.Rain(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.rain_colors = rain_colors effect.effect_config.movement_speed = movement_speed effect.effect_config.rain_symbols = rain_symbols effect.effect_config.movement_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_rain_dynamic_without_preexisting_colors_has_uncolored_final_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored in the fade scene.""" effect = effect_rain.Rain("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rain.RainIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fade_scene = list(character.animation.scenes.values())[1] final_frame = fade_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_rain_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the fade scene.""" effect = effect_rain.Rain("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rain.RainIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fade_scene = list(character.animation.scenes.values())[1] final_frame = fade_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_rain_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_rain.Rain("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rain.RainIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fade_scene = list(character.animation.scenes.values())[1] final_frame = fade_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_rain_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_rain.Rain("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rain.RainIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fade_scene = list(character.animation.scenes.values())[1] final_frame = fade_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_rain_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color.""" effect = effect_rain.Rain("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_rain.RainIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fade_scene = list(character.animation.scenes.values())[1] final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None final_frame = fade_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_rain_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the final visible frame to the parsed input colors.""" effect = effect_rain.Rain("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_rain.RainIterator", iter(effect)) character = iterator.terminal.get_characters()[0] fade_scene = list(character.animation.scenes.values())[1] final_frame = fade_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_randomsequence.py000066400000000000000000000222711517776150200277370ustar00rootroot00000000000000"""Tests for the RandomSequence effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_random_sequence from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_randomsequence_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the RandomSequence effect against a variety of representative inputs.""" effect = effect_random_sequence.RandomSequence(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_randomsequence_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test RandomSequence output when terminal color toggles change.""" effect = effect_random_sequence.RandomSequence(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_randomsequence_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_random_sequence.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the RandomSequence effect respects final gradient settings.""" effect = effect_random_sequence.RandomSequence(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("speed", [0.0001, 0.5, 1]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_randomsequence_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, speed: float, ) -> None: """Ensure RandomSequence accepts and renders with various configuration arguments.""" effect = effect_random_sequence.RandomSequence(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.speed = speed with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_randomsequence_reveal_starts_from_terminal_background_color() -> None: """Verify the reveal gradient starts from the configured terminal background color.""" background_color = Color("#123456") effect = effect_random_sequence.RandomSequence("A") effect.terminal_config = _make_terminal_config("ignore") effect.terminal_config.terminal_background_color = background_color iterator = cast("effect_random_sequence.RandomSequenceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None first_frame = final_scene.frames[0].character_visual assert first_frame.symbol == "A" assert first_frame.colors == ColorPair(fg=background_color) assert first_frame._fg_color_code == background_color.rgb_color assert first_frame._bg_color_code is None def test_randomsequence_dynamic_without_preexisting_colors_has_gray_fade_and_uncolored_final_frame() -> None: """Verify dynamic mode fades uncolored input through gray before clearing the color.""" effect = effect_random_sequence.RandomSequence("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_random_sequence.RandomSequenceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None gray_frame = final_scene.frames[-2].character_visual final_frame = final_scene.frames[-1].character_visual assert gray_frame.symbol == "A" assert gray_frame.colors == ColorPair(fg=Color("#808080")) assert gray_frame._fg_color_code == Color("#808080").rgb_color assert gray_frame._bg_color_code is None assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_randomsequence_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode restores a parsed foreground color in the reveal scene.""" effect = effect_random_sequence.RandomSequence("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_random_sequence.RandomSequenceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_randomsequence_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_random_sequence.RandomSequence("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_random_sequence.RandomSequenceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_randomsequence_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_random_sequence.RandomSequence("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_random_sequence.RandomSequenceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_randomsequence_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final gradient color.""" effect = effect_random_sequence.RandomSequence("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_random_sequence.RandomSequenceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert final_scene is not None assert final_color is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_randomsequence_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the final visible frame to the parsed input colors.""" effect = effect_random_sequence.RandomSequence("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_random_sequence.RandomSequenceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.active_scene assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_rings.py000066400000000000000000000241071517776150200260500ustar00rootroot00000000000000"""Tests for the Rings effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_rings from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config def _get_ring_character(iterator: effect_rings.RingsIterator) -> effect_rings.EffectCharacter: for character in iterator.terminal.get_characters(): if character.animation.query_scene("gradient", None) is not None: return character msg = "Expected at least one character with ring scenes" raise AssertionError(msg) @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_rings_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Rings effect against a variety of representative inputs.""" effect = effect_rings.Rings(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_rings_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Rings output when terminal color toggles change.""" effect = effect_rings.Rings(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_rings_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_rings.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Rings effect respects final gradient settings.""" effect = effect_rings.Rings(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("ring_colors", [(Color("#ffffff"),), (Color("#f0f0f0"), Color("#00ff00"))]) @pytest.mark.parametrize("ring_gap", [0.0001, 0.5, 2]) @pytest.mark.parametrize("spin_duration", [0, 10]) @pytest.mark.parametrize("spin_speed", [(0.01, 2.0), (1.0, 3.0)]) @pytest.mark.parametrize("disperse_duration", [1, 10]) @pytest.mark.parametrize("spin_disperse_cycles", [1, 3]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_rings_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, ring_colors: tuple[Color, ...], ring_gap: float, spin_duration: int, spin_speed: tuple[float, float], disperse_duration: int, spin_disperse_cycles: int, ) -> None: """Ensure Rings accepts and renders with various configuration arguments.""" effect = effect_rings.Rings(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.ring_colors = ring_colors effect.effect_config.ring_gap = ring_gap effect.effect_config.spin_duration = spin_duration effect.effect_config.spin_speed = spin_speed effect.effect_config.disperse_duration = disperse_duration effect.effect_config.spin_disperse_cycles = spin_disperse_cycles with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_rings_dynamic_without_preexisting_colors_uses_no_color_in_all_scenes() -> None: """Verify dynamic mode keeps uncolored input uncolored in start, gradient, and disperse scenes.""" effect = effect_rings.Rings("ABCD\nEFGH\nIJKL\nMNOP") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rings.RingsIterator", iter(effect)) character = _get_ring_character(iterator) start_scene = character.animation.active_scene gradient_scene = character.animation.query_scene("gradient") disperse_scene = character.animation.query_scene("disperse") assert start_scene is not None assert gradient_scene is not None assert disperse_scene is not None for scene in (start_scene, gradient_scene, disperse_scene): final_frame = scene.frames[-1].character_visual assert final_frame.symbol == character.input_symbol assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_rings_dynamic_with_preexisting_fg_uses_input_fg_color_in_all_scenes() -> None: """Verify dynamic mode uses parsed foreground color in every visible scene.""" effect = effect_rings.Rings("\x1b[38;5;196mABCD\nEFGH\nIJKL\nMNOP\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rings.RingsIterator", iter(effect)) character = _get_ring_character(iterator) start_scene = character.animation.active_scene gradient_scene = character.animation.query_scene("gradient") disperse_scene = character.animation.query_scene("disperse") assert start_scene is not None assert gradient_scene is not None assert disperse_scene is not None for scene in (start_scene, gradient_scene, disperse_scene): final_frame = scene.frames[-1].character_visual assert final_frame.symbol == character.input_symbol assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_rings_dynamic_with_preexisting_bg_only_uses_input_bg_color_in_all_scenes() -> None: """Verify dynamic mode uses parsed background color without inventing a foreground.""" effect = effect_rings.Rings("\x1b[48;5;106mABCD\nEFGH\nIJKL\nMNOP\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rings.RingsIterator", iter(effect)) character = _get_ring_character(iterator) start_scene = character.animation.active_scene gradient_scene = character.animation.query_scene("gradient") disperse_scene = character.animation.query_scene("disperse") assert start_scene is not None assert gradient_scene is not None assert disperse_scene is not None for scene in (start_scene, gradient_scene, disperse_scene): final_frame = scene.frames[-1].character_visual assert final_frame.symbol == character.input_symbol assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_rings_dynamic_with_preexisting_fg_and_bg_uses_input_colors_in_all_scenes() -> None: """Verify dynamic mode uses parsed foreground and background colors together in every visible scene.""" effect = effect_rings.Rings("\x1b[38;5;196m\x1b[48;5;106mABCD\nEFGH\nIJKL\nMNOP\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_rings.RingsIterator", iter(effect)) character = _get_ring_character(iterator) start_scene = character.animation.active_scene gradient_scene = character.animation.query_scene("gradient") disperse_scene = character.animation.query_scene("disperse") assert start_scene is not None assert gradient_scene is not None assert disperse_scene is not None for scene in (start_scene, gradient_scene, disperse_scene): final_frame = scene.frames[-1].character_visual assert final_frame.symbol == character.input_symbol assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_rings_ignore_with_preexisting_colors_uses_effect_colors() -> None: """Verify ignore mode keeps the effect-owned gradient and ring colors.""" effect = effect_rings.Rings("\x1b[38;5;196mABCD\nEFGH\nIJKL\nMNOP\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_rings.RingsIterator", iter(effect)) character = _get_ring_character(iterator) start_scene = character.animation.active_scene gradient_scene = character.animation.query_scene("gradient") final_color = iterator.character_final_color_map[character].fg_color assert start_scene is not None assert gradient_scene is not None assert final_color is not None start_frame = start_scene.frames[-1].character_visual gradient_frame = gradient_scene.frames[-1].character_visual assert start_frame.colors == ColorPair(fg=final_color) assert gradient_frame.colors != ColorPair(fg=Color(196)) def test_rings_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to the parsed input colors.""" effect = effect_rings.Rings("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_rings.RingsIterator", iter(effect)) character = iterator.terminal.get_characters()[0] start_scene = character.animation.active_scene assert start_scene is not None final_frame = start_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_scattered.py000066400000000000000000000200241517776150200266760ustar00rootroot00000000000000"""Tests for the Scattered effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_scattered from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_scattered_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Scattered effect against a variety of representative inputs.""" effect = effect_scattered.Scattered(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_scattered_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Scattered output when terminal color toggles change.""" effect = effect_scattered.Scattered(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_scattered_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_scattered.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Scattered effect respects final gradient settings.""" effect = effect_scattered.Scattered(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("movement_speed", [0.01, 1]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_scattered_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, movement_speed: float, easing_function_1: effect_scattered.easing.EasingFunction, ) -> None: """Ensure Scattered accepts and renders with various configuration arguments.""" effect = effect_scattered.Scattered(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.movement_speed = movement_speed effect.effect_config.movement_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_scattered_dynamic_without_preexisting_colors_uses_no_color_throughout() -> None: """Verify dynamic mode keeps uncolored input uncolored for the entire synced scene.""" effect = effect_scattered.Scattered("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_scattered.ScatteredIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair() assert visual._fg_color_code is None assert visual._bg_color_code is None def test_scattered_dynamic_with_preexisting_fg_uses_input_fg_throughout() -> None: """Verify dynamic mode uses the parsed foreground color for the entire synced scene.""" effect = effect_scattered.Scattered("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_scattered.ScatteredIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(fg=Color(196)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code is None def test_scattered_dynamic_with_preexisting_bg_only_uses_input_bg_throughout() -> None: """Verify dynamic mode uses the parsed background color without inventing a foreground.""" effect = effect_scattered.Scattered("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_scattered.ScatteredIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(bg=Color(106)) assert visual._fg_color_code is None assert visual._bg_color_code == Color(106).rgb_color def test_scattered_dynamic_with_preexisting_fg_and_bg_uses_input_colors_throughout() -> None: """Verify dynamic mode uses parsed foreground and background colors for the entire synced scene.""" effect = effect_scattered.Scattered("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_scattered.ScatteredIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code == Color(106).rgb_color def test_scattered_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned synced gradient.""" effect = effect_scattered.Scattered("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_scattered.ScatteredIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert active_scene is not None assert final_color is not None final_frame = active_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_scattered_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to the parsed input colors.""" effect = effect_scattered.Scattered("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_scattered.ScatteredIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None final_frame = active_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_slice.py000066400000000000000000000170171517776150200260270ustar00rootroot00000000000000"""Tests for the Slice effect and its configuration surface.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_slice from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_slice_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Slice effect against a variety of representative inputs.""" effect = effect_slice.Slice(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_slice_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Slice output when terminal color toggles change.""" effect = effect_slice.Slice(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_slice_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_slice.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Slice effect respects final gradient settings.""" effect = effect_slice.Slice(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("slice_direction", ["vertical", "horizontal", "diagonal"]) @pytest.mark.parametrize("movement_speed", [0.01, 2.0]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_slice_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, slice_direction: Literal["vertical", "horizontal", "diagonal"], movement_speed: float, easing_function_1: effect_slice.easing.EasingFunction, ) -> None: """Ensure Slice accepts and renders with various configuration arguments.""" effect = effect_slice.Slice(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.slice_direction = slice_direction effect.effect_config.movement_speed = movement_speed effect.effect_config.movement_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_slice_dynamic_without_preexisting_colors_uses_no_color() -> None: """Verify dynamic mode leaves uncolored input uncolored for the whole effect.""" effect = effect_slice.Slice("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slice.SliceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair() assert current_visual._fg_color_code is None assert current_visual._bg_color_code is None def test_slice_dynamic_with_preexisting_fg_uses_input_fg_color() -> None: """Verify dynamic mode uses the parsed foreground color from the start.""" effect = effect_slice.Slice("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slice.SliceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code is None def test_slice_dynamic_with_preexisting_bg_only_uses_input_bg_color() -> None: """Verify dynamic mode uses the parsed background color without inventing a foreground.""" effect = effect_slice.Slice("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slice.SliceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(bg=Color(106)) assert current_visual._fg_color_code is None assert current_visual._bg_color_code == Color(106).rgb_color def test_slice_dynamic_with_preexisting_fg_and_bg_uses_input_colors() -> None: """Verify dynamic mode uses parsed foreground and background colors together.""" effect = effect_slice.Slice("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slice.SliceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_slice_ignore_with_preexisting_colors_uses_effect_gradient_color() -> None: """Verify ignore mode keeps the effect-owned final gradient color.""" effect = effect_slice.Slice("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_slice.SliceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=final_color) assert current_visual._fg_color_code == final_color.rgb_color assert current_visual._bg_color_code is None def test_slice_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the visible frame to the parsed input colors.""" effect = effect_slice.Slice("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_slice.SliceIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_slide.py000066400000000000000000000206241517776150200260260ustar00rootroot00000000000000"""Tests for the Slide effect and its configuration surface.""" from __future__ import annotations from typing import Any, Literal, cast import pytest from terminaltexteffects.effects import effect_slide from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_slide_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Slide effect against a variety of representative inputs.""" effect = effect_slide.Slide(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_slide_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Slide output when terminal color toggles change.""" effect = effect_slide.Slide(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_slide_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_slide.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], gradient_frames: int, ) -> None: """Verify the Slide effect respects final gradient settings.""" effect = effect_slide.Slide(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.effect_config.final_gradient_frames = gradient_frames effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("movement_speed", [0.01, 1]) @pytest.mark.parametrize("grouping", ["row", "column", "diagonal"]) @pytest.mark.parametrize("gap", [0, 3]) @pytest.mark.parametrize("reverse_direction", [True, False]) @pytest.mark.parametrize("merge", [True, False]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_slide_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, movement_speed: float, grouping: Literal["row", "column", "diagonal"], gap: int, reverse_direction: Any, merge: Any, easing_function_1: effect_slide.easing.EasingFunction, ) -> None: """Ensure Slide accepts and renders with various configuration arguments.""" effect = effect_slide.Slide(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.movement_speed = movement_speed effect.effect_config.grouping = grouping effect.effect_config.gap = gap effect.effect_config.reverse_direction = reverse_direction effect.effect_config.merge = merge effect.effect_config.movement_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_slide_dynamic_without_preexisting_colors_uses_no_color_throughout() -> None: """Verify dynamic mode keeps uncolored input uncolored for the entire scene.""" effect = effect_slide.Slide("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slide.SlideIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair() assert visual._fg_color_code is None assert visual._bg_color_code is None def test_slide_dynamic_with_preexisting_fg_uses_input_fg_throughout() -> None: """Verify dynamic mode uses the parsed foreground color for the entire scene.""" effect = effect_slide.Slide("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slide.SlideIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(fg=Color(196)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code is None def test_slide_dynamic_with_preexisting_bg_only_uses_input_bg_throughout() -> None: """Verify dynamic mode uses the parsed background color without inventing a foreground.""" effect = effect_slide.Slide("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slide.SlideIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(bg=Color(106)) assert visual._fg_color_code is None assert visual._bg_color_code == Color(106).rgb_color def test_slide_dynamic_with_preexisting_fg_and_bg_uses_input_colors_throughout() -> None: """Verify dynamic mode uses parsed foreground and background colors for the entire scene.""" effect = effect_slide.Slide("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_slide.SlideIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code == Color(106).rgb_color def test_slide_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned gradient scene.""" effect = effect_slide.Slide("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_slide.SlideIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert active_scene is not None assert final_color is not None final_frame = active_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_slide_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to the parsed input colors.""" effect = effect_slide.Slide("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_slide.SlideIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None final_frame = active_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_smoke.py000066400000000000000000000262721517776150200260510ustar00rootroot00000000000000"""Tests for the Smoke effect and its configuration surface.""" from __future__ import annotations from typing import Any, Literal, cast import pytest from terminaltexteffects.effects import effect_smoke from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config def _get_inactive_character(iterator: effect_smoke.SmokeIterator) -> effect_smoke.tte.EffectCharacter: for character in iterator.terminal.get_characters(): if character not in iterator.active_characters: return character msg = "Expected at least one inactive character" raise AssertionError(msg) @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_smoke_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Smoke effect against a variety of representative inputs.""" effect = effect_smoke.Smoke(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_smoke_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Smoke output when terminal color toggles change.""" effect = effect_smoke.Smoke(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_smoke_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_smoke.tte.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Smoke effect respects final gradient settings.""" effect = effect_smoke.Smoke(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("starting_color", [Color("#000000"), Color("#ff00ff")]) @pytest.mark.parametrize("smoke_symbols", [("a", "b"), ("a",)]) @pytest.mark.parametrize("smoke_gradient_stops", [(Color("#000000"),), (Color("#000000"), Color("#ff00ff"))]) @pytest.mark.parametrize("use_whole_canvas", [True, False]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_smoke_args( starting_color: Color, smoke_symbols: tuple[str, ...], smoke_gradient_stops: tuple[Color, ...], use_whole_canvas: Any, input_data: str, terminal_config_default_no_framerate: TerminalConfig, ) -> None: """Ensure Smoke accepts and renders with various configuration arguments.""" effect = effect_smoke.Smoke(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.starting_color = starting_color effect.effect_config.smoke_symbols = smoke_symbols effect.effect_config.smoke_gradient_stops = smoke_gradient_stops effect.effect_config.use_whole_canvas = use_whole_canvas with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_smoke_dynamic_without_preexisting_colors_uses_no_color_throughout() -> None: """Verify dynamic mode starts black, then keeps uncolored input uncolored when revealed.""" effect = effect_smoke.Smoke("AB") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_smoke.SmokeIterator", iter(effect)) character = _get_inactive_character(iterator) current_visual = character.animation.current_character_visual smoke_scene = character.animation.query_scene("smoke") paint_scene = character.animation.query_scene("paint") assert current_visual.symbol == character.input_symbol assert current_visual.colors == ColorPair(fg=Color("#000000")) assert current_visual._fg_color_code == Color("#000000").rgb_color assert current_visual._bg_color_code is None for frame in smoke_scene.frames: visual = frame.character_visual assert visual.colors == ColorPair() assert visual._fg_color_code is None assert visual._bg_color_code is None for frame in paint_scene.frames: visual = frame.character_visual assert visual.symbol == character.input_symbol assert visual.colors == ColorPair() assert visual._fg_color_code is None assert visual._bg_color_code is None def test_smoke_dynamic_with_preexisting_fg_uses_input_fg_throughout() -> None: """Verify dynamic mode starts black, then uses parsed foreground color for smoke and paint.""" effect = effect_smoke.Smoke("\x1b[38;5;196mAB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_smoke.SmokeIterator", iter(effect)) character = _get_inactive_character(iterator) current_visual = character.animation.current_character_visual smoke_scene = character.animation.query_scene("smoke") paint_scene = character.animation.query_scene("paint") assert current_visual.symbol == character.input_symbol assert current_visual.colors == ColorPair(fg=Color("#000000")) assert current_visual._fg_color_code == Color("#000000").rgb_color assert current_visual._bg_color_code is None for frame in smoke_scene.frames: visual = frame.character_visual assert visual.colors == ColorPair(fg=Color(196)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code is None for frame in paint_scene.frames: visual = frame.character_visual assert visual.symbol == character.input_symbol assert visual.colors == ColorPair(fg=Color(196)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code is None def test_smoke_dynamic_with_preexisting_bg_only_uses_input_bg_throughout() -> None: """Verify dynamic mode starts black, then uses parsed background color without inventing a foreground.""" effect = effect_smoke.Smoke("\x1b[48;5;106mAB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_smoke.SmokeIterator", iter(effect)) character = _get_inactive_character(iterator) current_visual = character.animation.current_character_visual smoke_scene = character.animation.query_scene("smoke") paint_scene = character.animation.query_scene("paint") assert current_visual.symbol == character.input_symbol assert current_visual.colors == ColorPair(fg=Color("#000000")) assert current_visual._fg_color_code == Color("#000000").rgb_color assert current_visual._bg_color_code is None for frame in smoke_scene.frames: visual = frame.character_visual assert visual.colors == ColorPair(bg=Color(106)) assert visual._fg_color_code is None assert visual._bg_color_code == Color(106).rgb_color for frame in paint_scene.frames: visual = frame.character_visual assert visual.symbol == character.input_symbol assert visual.colors == ColorPair(bg=Color(106)) assert visual._fg_color_code is None assert visual._bg_color_code == Color(106).rgb_color def test_smoke_dynamic_with_preexisting_fg_and_bg_uses_input_colors_throughout() -> None: """Verify dynamic mode starts black, then uses parsed foreground and background colors.""" effect = effect_smoke.Smoke("\x1b[38;5;196m\x1b[48;5;106mAB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_smoke.SmokeIterator", iter(effect)) character = _get_inactive_character(iterator) current_visual = character.animation.current_character_visual smoke_scene = character.animation.query_scene("smoke") paint_scene = character.animation.query_scene("paint") assert current_visual.symbol == character.input_symbol assert current_visual.colors == ColorPair(fg=Color("#000000")) assert current_visual._fg_color_code == Color("#000000").rgb_color assert current_visual._bg_color_code is None for frame in smoke_scene.frames: visual = frame.character_visual assert visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code == Color(106).rgb_color for frame in paint_scene.frames: visual = frame.character_visual assert visual.symbol == character.input_symbol assert visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code == Color(106).rgb_color def test_smoke_ignore_with_preexisting_colors_uses_effect_colors() -> None: """Verify ignore mode keeps the effect-owned starting, smoke, and paint colors.""" effect = effect_smoke.Smoke("\x1b[38;5;196mAB\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_smoke.SmokeIterator", iter(effect)) character = _get_inactive_character(iterator) current_visual = character.animation.current_character_visual smoke_scene = character.animation.query_scene("smoke") paint_scene = character.animation.query_scene("paint") assert current_visual.colors == ColorPair(fg=effect.effect_config.starting_color) assert smoke_scene.frames[-1].character_visual.colors != ColorPair(fg=Color(196)) assert paint_scene.frames[-1].character_visual.colors != ColorPair(fg=Color(196)) def test_smoke_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to the parsed input colors.""" effect = effect_smoke.Smoke("\x1b[38;5;196m\x1b[48;5;106mAB\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_smoke.SmokeIterator", iter(effect)) character = _get_inactive_character(iterator) current_visual = character.animation.current_character_visual paint_scene = character.animation.query_scene("paint") assert current_visual.symbol == character.input_symbol assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color final_frame = paint_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_spotlights.py000066400000000000000000000402271517776150200271270ustar00rootroot00000000000000"""Tests for the Spotlights effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_spotlights from terminaltexteffects.engine import animation from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config def _get_input_character(iterator: effect_spotlights.SpotlightsIterator) -> effect_spotlights.EffectCharacter: return next(character for character in iterator.terminal.get_characters() if character.input_symbol != " ") def _get_space_character(iterator: effect_spotlights.SpotlightsIterator) -> effect_spotlights.EffectCharacter: return next(character for character in iterator.terminal.get_characters() if character.input_symbol == " ") def _place_spotlight_on_character( iterator: effect_spotlights.SpotlightsIterator, character: effect_spotlights.EffectCharacter, ) -> None: iterator.spotlights = [iterator.spotlights[0]] iterator.spotlights[0].motion.set_coordinate(character.input_coord) @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_spotlights_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Spotlights effect against a variety of representative inputs.""" effect = effect_spotlights.Spotlights(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_spotlights_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Spotlights output when terminal color toggles change.""" effect = effect_spotlights.Spotlights(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_spotlights_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_spotlights.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Spotlights effect respects final gradient settings.""" effect = effect_spotlights.Spotlights(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("beam_width_ratio", [0.01, 3]) @pytest.mark.parametrize("beam_falloff", [0, 3.0]) @pytest.mark.parametrize("search_duration", [1, 5]) @pytest.mark.parametrize("search_speed_range", [(0.01, 1), (2, 4)]) @pytest.mark.parametrize("spotlight_count", [1, 10]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_spotlights_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, beam_width_ratio: float, beam_falloff: float, search_duration: int, search_speed_range: tuple[float, float], spotlight_count: int, ) -> None: """Ensure Spotlights accepts and renders with various configuration arguments.""" effect = effect_spotlights.Spotlights(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.beam_width_ratio = beam_width_ratio effect.effect_config.beam_falloff = beam_falloff effect.effect_config.search_duration = search_duration effect.effect_config.search_speed_range = search_speed_range effect.effect_config.spotlight_count = spotlight_count with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_spotlights_dynamic_without_preexisting_colors_starts_faded_gray() -> None: """Verify dynamic mode starts uncolored input in a dim neutral gray.""" effect = effect_spotlights.Spotlights("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) current_visual = character.animation.current_character_visual expected_gray = animation.Animation.adjust_color_brightness( effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY, 0.2, ) assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=expected_gray) assert current_visual._fg_color_code == expected_gray.rgb_color assert current_visual._bg_color_code is None def test_spotlights_dynamic_with_preexisting_fg_starts_faded_input_fg() -> None: """Verify dynamic mode dims a parsed foreground color for the initial visible state.""" effect = effect_spotlights.Spotlights("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) current_visual = character.animation.current_character_visual expected_fg = animation.Animation.adjust_color_brightness(Color(196), 0.2) assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=expected_fg) assert current_visual._fg_color_code == expected_fg.rgb_color assert current_visual._bg_color_code is None def test_spotlights_dynamic_with_preexisting_bg_only_starts_with_gray_fg_and_faded_input_bg() -> None: """Verify dynamic mode keeps bg-only characters visible with gray fg and faded input bg.""" effect = effect_spotlights.Spotlights("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) current_visual = character.animation.current_character_visual expected_fg = animation.Animation.adjust_color_brightness( effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY, 0.2, ) expected_bg = animation.Animation.adjust_color_brightness(Color(106), 0.2) assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=expected_fg, bg=expected_bg) assert current_visual._fg_color_code == expected_fg.rgb_color assert current_visual._bg_color_code == expected_bg.rgb_color def test_spotlights_dynamic_with_preexisting_fg_and_bg_starts_faded_input_colors() -> None: """Verify dynamic mode dims both parsed foreground and background colors initially.""" effect = effect_spotlights.Spotlights("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) current_visual = character.animation.current_character_visual expected_fg = animation.Animation.adjust_color_brightness(Color(196), 0.2) expected_bg = animation.Animation.adjust_color_brightness(Color(106), 0.2) assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=expected_fg, bg=expected_bg) assert current_visual._fg_color_code == expected_fg.rgb_color assert current_visual._bg_color_code == expected_bg.rgb_color def test_spotlights_ignore_with_preexisting_colors_starts_with_effect_dim_color() -> None: """Verify ignore mode keeps the effect-owned dim spotlight color.""" effect = effect_spotlights.Spotlights("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) dim_pair = iterator.character_color_map[character][1] current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == dim_pair assert current_visual._fg_color_code == (dim_pair.fg_color.rgb_color if dim_pair.fg_color else None) assert current_visual._bg_color_code is None def test_spotlights_dynamic_illumination_uses_bright_input_fg() -> None: """Verify spotlight illumination restores the parsed foreground color at full brightness.""" effect = effect_spotlights.Spotlights("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) _place_spotlight_on_character(iterator, character) iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.colors == ColorPair(fg=Color(196)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code is None def test_spotlights_dynamic_illumination_uses_gray_fg_and_bright_input_bg() -> None: """Verify spotlight illumination keeps bg-only characters visible with gray fg and input bg.""" effect = effect_spotlights.Spotlights("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) _place_spotlight_on_character(iterator, character) iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.colors == ColorPair( fg=effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY, bg=Color(106), ) assert current_visual._fg_color_code == effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY.rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_spotlights_dynamic_illumination_uses_gray_fg_and_bright_input_bg_for_spaces() -> None: """Verify spotlight illumination includes bg-colored input spaces.""" effect = effect_spotlights.Spotlights("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_space_character(iterator) _place_spotlight_on_character(iterator, character) iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.symbol == " " assert current_visual.colors == ColorPair( fg=effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY, bg=Color(106), ) assert current_visual._fg_color_code == effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY.rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_spotlights_dynamic_illumination_uses_bright_input_fg_and_bg() -> None: """Verify spotlight illumination restores both parsed input color channels together.""" effect = effect_spotlights.Spotlights("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) _place_spotlight_on_character(iterator, character) iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color def test_spotlights_dynamic_illumination_uses_gray_for_uncolored_input() -> None: """Verify spotlight illumination uses the neutral gray target for uncolored input before final expand.""" effect = effect_spotlights.Spotlights("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) _place_spotlight_on_character(iterator, character) iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.colors == ColorPair(fg=effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY) assert current_visual._fg_color_code == effect_spotlights.SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY.rgb_color assert current_visual._bg_color_code is None def test_spotlights_dynamic_expand_clears_uncolored_characters() -> None: """Verify the final spotlight expand clears gray fallback color for uncolored input.""" effect = effect_spotlights.Spotlights("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) _place_spotlight_on_character(iterator, character) iterator.expanding = True iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair() assert current_visual._fg_color_code is None assert current_visual._bg_color_code is None def test_spotlights_dynamic_expand_clears_gray_fg_for_bg_only_characters() -> None: """Verify the final spotlight expand removes temporary gray fg for bg-only input.""" effect = effect_spotlights.Spotlights("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) _place_spotlight_on_character(iterator, character) iterator.expanding = True iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(bg=Color(106)) assert current_visual._fg_color_code is None assert current_visual._bg_color_code == Color(106).rgb_color def test_spotlights_dynamic_expand_clears_gray_fg_for_bg_only_spaces() -> None: """Verify final spotlight expand preserves bg-colored spaces without temporary gray fg.""" effect = effect_spotlights.Spotlights("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_space_character(iterator) _place_spotlight_on_character(iterator, character) iterator.expanding = True iterator.illuminate_chars(iterator.illuminate_range) current_visual = character.animation.current_character_visual assert current_visual.symbol == " " assert current_visual.colors == ColorPair(bg=Color(106)) assert current_visual._fg_color_code is None assert current_visual._bg_color_code == Color(106).rgb_color def test_spotlights_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to the parsed input colors.""" effect = effect_spotlights.Spotlights("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_spotlights.SpotlightsIterator", iter(effect)) character = _get_input_character(iterator) current_visual = character.animation.current_character_visual assert current_visual.symbol == "A" assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert current_visual._fg_color_code == Color(196).rgb_color assert current_visual._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_spray.py000066400000000000000000000203641517776150200260650ustar00rootroot00000000000000"""Tests for the Spray effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_spray from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_spray_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Spray effect against a variety of representative inputs.""" effect = effect_spray.Spray(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_spray_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Spray output when terminal color toggles change.""" effect = effect_spray.Spray(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_spray_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_spray.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Spray effect respects final gradient settings.""" effect = effect_spray.Spray(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("spray_position", ["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"]) @pytest.mark.parametrize("spray_volume", [0.0001, 1]) @pytest.mark.parametrize("movement_speed", [(0.01, 1), (2, 4)]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_spray_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, spray_position: Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"], spray_volume: float, movement_speed: tuple[float, float], easing_function_1: effect_spray.easing.EasingFunction, ) -> None: """Ensure Spray accepts and renders with various configuration arguments.""" effect = effect_spray.Spray(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.spray_position = spray_position effect.effect_config.spray_volume = spray_volume effect.effect_config.movement_speed_range = movement_speed effect.effect_config.movement_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_spray_dynamic_without_preexisting_colors_uses_no_color_in_every_frame() -> None: """Verify dynamic mode leaves uncolored input uncolored for the whole spray scene.""" effect = effect_spray.Spray("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spray.SprayIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair() assert visual._fg_color_code is None assert visual._bg_color_code is None def test_spray_dynamic_with_preexisting_fg_uses_input_fg_in_every_frame() -> None: """Verify dynamic mode uses the parsed foreground color for the whole spray scene.""" effect = effect_spray.Spray("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spray.SprayIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(fg=Color(196)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code is None def test_spray_dynamic_with_preexisting_bg_only_uses_input_bg_in_every_frame() -> None: """Verify dynamic mode uses the parsed background color without inventing a foreground.""" effect = effect_spray.Spray("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spray.SprayIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(bg=Color(106)) assert visual._fg_color_code is None assert visual._bg_color_code == Color(106).rgb_color def test_spray_dynamic_with_preexisting_fg_and_bg_uses_input_colors_in_every_frame() -> None: """Verify dynamic mode uses parsed foreground and background colors for the whole spray scene.""" effect = effect_spray.Spray("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_spray.SprayIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None for frame in active_scene.frames: visual = frame.character_visual assert visual.symbol == "A" assert visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert visual._fg_color_code == Color(196).rgb_color assert visual._bg_color_code == Color(106).rgb_color def test_spray_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned spray gradient.""" effect = effect_spray.Spray("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_spray.SprayIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert active_scene is not None assert final_color is not None final_frame = active_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_spray_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to the parsed input colors.""" effect = effect_spray.Spray("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_spray.SprayIterator", iter(effect)) character = iterator.terminal.get_characters()[0] active_scene = character.animation.active_scene assert active_scene is not None final_frame = active_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_swarm.py000066400000000000000000000204551517776150200260610ustar00rootroot00000000000000"""Tests for the Swarm effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_swarm from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_swarm_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Swarm effect against a variety of representative inputs.""" effect = effect_swarm.Swarm(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_swarm_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Swarm output when terminal color toggles change.""" effect = effect_swarm.Swarm(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_swarm_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_swarm.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Swarm effect respects final gradient settings.""" effect = effect_swarm.Swarm(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("base_color", [(Color("#ffffff"),), (Color("#f0f0f0"), Color("#00ff00"))]) @pytest.mark.parametrize("flash_color", [Color("#ff0000"), Color("#0000ff")]) @pytest.mark.parametrize("swarm_size", [0.0001, 1]) @pytest.mark.parametrize("swarm_coordination", [0.0001, 1]) @pytest.mark.parametrize("swarm_area_count_range", [(1, 2), (3, 4)]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_swarm_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, base_color: tuple[Color, ...], flash_color: Color, swarm_size: float, swarm_coordination: float, swarm_area_count_range: tuple[int, int], ) -> None: """Ensure Swarm accepts and renders with various configuration arguments.""" effect = effect_swarm.Swarm(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.base_color = base_color effect.effect_config.flash_color = flash_color effect.effect_config.swarm_size = swarm_size effect.effect_config.swarm_coordination = swarm_coordination effect.effect_config.swarm_area_count_range = swarm_area_count_range with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_swarm_dynamic_without_preexisting_colors_ends_input_scene_uncolored() -> None: """Verify dynamic mode clears uncolored input at the end of the landing scene.""" effect = effect_swarm.Swarm("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_swarm.SwarmIterator", iter(effect)) character = iterator.terminal.get_characters()[0] input_scene = character.animation.query_scene("1") assert input_scene is not None final_frame = input_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_swarm_dynamic_with_preexisting_fg_restores_input_fg() -> None: """Verify dynamic mode restores a parsed foreground color in the landing scene.""" effect = effect_swarm.Swarm("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_swarm.SwarmIterator", iter(effect)) character = iterator.terminal.get_characters()[0] input_scene = character.animation.query_scene("1") assert input_scene is not None final_frame = input_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_swarm_dynamic_with_preexisting_bg_only_restores_input_bg() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_swarm.Swarm("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_swarm.SwarmIterator", iter(effect)) character = iterator.terminal.get_characters()[0] input_scene = character.animation.query_scene("1") assert input_scene is not None final_frame = input_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_swarm_dynamic_with_preexisting_fg_and_bg_restores_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors in the landing scene.""" effect = effect_swarm.Swarm("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_swarm.SwarmIterator", iter(effect)) character = iterator.terminal.get_characters()[0] input_scene = character.animation.query_scene("1") assert input_scene is not None final_frame = input_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_swarm_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned final landing gradient.""" effect = effect_swarm.Swarm("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_swarm.SwarmIterator", iter(effect)) character = iterator.terminal.get_characters()[0] input_scene = character.animation.query_scene("1") final_color = iterator.character_final_color_map[character].fg_color assert input_scene is not None assert final_color is not None final_frame = input_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_swarm_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the landing scene to parsed input colors.""" effect = effect_swarm.Swarm("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_swarm.SwarmIterator", iter(effect)) character = iterator.terminal.get_characters()[0] input_scene = character.animation.query_scene("1") assert input_scene is not None final_frame = input_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_sweep.py000066400000000000000000000223001517776150200260420ustar00rootroot00000000000000"""Tests for the sweep effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_sweep from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_sweep_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the sweep effect with default terminal configuration.""" effect = effect_sweep.Sweep(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_sweep_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test the sweep effect with terminal color options.""" effect = effect_sweep.Sweep(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_sweep_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_sweep.tte.Gradient.Direction, gradient_steps: tuple[int, ...] | int, gradient_stops: tuple[effect_sweep.tte.Color, ...], ) -> None: """Test the sweep effect with final gradient configuration.""" effect = effect_sweep.Sweep(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) @pytest.mark.parametrize("sweep_symbols", [("0", "1"), (" ",), ("a", "b", "c")]) def test_sweep_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, sweep_symbols: tuple[str, ...], ) -> None: """Test the sweep effect with different sweep symbols.""" effect = effect_sweep.Sweep(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.sweep_symbols = sweep_symbols with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_sweep_dynamic_without_preexisting_colors_uses_gradient_palette_fallback_and_no_final_color() -> None: """Verify dynamic mode falls back to final-gradient shimmer colors and ends uncolored.""" effect = effect_sweep.Sweep("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_sweep.SweepIterator", iter(effect)) character = next(char for char in iterator.terminal.get_characters() if not char.is_fill_character) second_sweep_scene = character.animation.query_scene("second_sweep") assert second_sweep_scene is not None palette = {color.rgb_color for color in iterator.dynamic_second_sweep_palette} for frame in second_sweep_scene.frames[:-1]: visual = frame.character_visual assert visual._fg_color_code in palette assert visual._bg_color_code is None final_frame = second_sweep_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_sweep_dynamic_with_preexisting_fg_uses_input_palette_and_restores_input_fg() -> None: """Verify dynamic mode shimmers from parsed input colors and restores fg-only input.""" effect = effect_sweep.Sweep("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_sweep.SweepIterator", iter(effect)) character = next(char for char in iterator.terminal.get_characters() if not char.is_fill_character) second_sweep_scene = character.animation.query_scene("second_sweep") assert second_sweep_scene is not None palette = {color.rgb_color for color in iterator.dynamic_second_sweep_palette} for frame in second_sweep_scene.frames[:-1]: assert frame.character_visual._fg_color_code in palette final_frame = second_sweep_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_sweep_dynamic_with_preexisting_bg_only_restores_input_bg() -> None: """Verify dynamic mode restores bg-only input without inventing a foreground.""" effect = effect_sweep.Sweep("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_sweep.SweepIterator", iter(effect)) character = next(char for char in iterator.terminal.get_characters() if not char.is_fill_character) second_sweep_scene = character.animation.query_scene("second_sweep") assert second_sweep_scene is not None final_frame = second_sweep_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_sweep_dynamic_with_preexisting_fg_and_bg_restores_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors together.""" effect = effect_sweep.Sweep("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_sweep.SweepIterator", iter(effect)) character = next(char for char in iterator.terminal.get_characters() if not char.is_fill_character) second_sweep_scene = character.animation.query_scene("second_sweep") assert second_sweep_scene is not None final_frame = second_sweep_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_sweep_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned second-sweep final color.""" effect = effect_sweep.Sweep("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_sweep.SweepIterator", iter(effect)) character = next(char for char in iterator.terminal.get_characters() if not char.is_fill_character) second_sweep_scene = character.animation.query_scene("second_sweep") final_color = iterator.character_final_color_map[character].fg_color assert second_sweep_scene is not None assert final_color is not None final_frame = second_sweep_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_sweep_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the visible second-sweep final frame to parsed input colors.""" effect = effect_sweep.Sweep("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_sweep.SweepIterator", iter(effect)) character = next(char for char in iterator.terminal.get_characters() if not char.is_fill_character) second_sweep_scene = character.animation.query_scene("second_sweep") assert second_sweep_scene is not None final_frame = second_sweep_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_sweep_dynamic_second_sweep_palette_uses_only_input_text_colors() -> None: """Verify the dynamic shimmer palette is derived only from colors parsed from input text.""" effect = effect_sweep.Sweep("\x1b[38;5;196mA\x1b[0m\x1b[48;5;106mB\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_sweep.SweepIterator", iter(effect)) assert {color.rgb_color for color in iterator.dynamic_second_sweep_palette} == { Color(196).rgb_color, Color(106).rgb_color, } terminaltexteffects-release-0.15.0/tests/effects_tests/test_synthgrid.py000066400000000000000000000257631517776150200267520ustar00rootroot00000000000000"""Tests for the SynthGrid effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_synthgrid from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config def _get_first_nonspace_character( iterator: effect_synthgrid.SynthGridIterator, ) -> effect_synthgrid.EffectCharacter: return next(character for character in iterator.terminal.get_characters() if character.input_symbol != " ") def _get_first_space_character( iterator: effect_synthgrid.SynthGridIterator, ) -> effect_synthgrid.EffectCharacter: return next(character for character in iterator.terminal.get_characters() if character.input_symbol == " ") @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_synthgrid_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the SynthGrid effect against a variety of representative inputs.""" effect = effect_synthgrid.SynthGrid(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_synthgrid_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test SynthGrid output when terminal color toggles change.""" effect = effect_synthgrid.SynthGrid(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize( "grid_gradient_stops", [(Color("#000000"), Color("#ff00ff"), Color("#0ffff0")), (Color("#ff0fff"),)], ) @pytest.mark.parametrize("grid_gradient_steps", [1, 4, (1, 3)]) @pytest.mark.parametrize( "grid_gradient_direction", [ Gradient.Direction.DIAGONAL, Gradient.Direction.HORIZONTAL, Gradient.Direction.VERTICAL, Gradient.Direction.RADIAL, ], ) @pytest.mark.parametrize( "text_gradient_stops", [(Color("#000000"), Color("#ff00ff"), Color("#0ffff0")), (Color("#ff0fff"),)], ) @pytest.mark.parametrize("text_gradient_steps", [1, 4, (1, 3)]) @pytest.mark.parametrize( "text_gradient_direction", [ Gradient.Direction.DIAGONAL, Gradient.Direction.HORIZONTAL, Gradient.Direction.VERTICAL, Gradient.Direction.RADIAL, ], ) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_synthgrid_gradients( terminal_config_default_no_framerate: TerminalConfig, input_data: str, grid_gradient_stops: tuple[Color, ...], grid_gradient_steps: tuple[int, ...] | int, grid_gradient_direction: Gradient.Direction, text_gradient_stops: tuple[Color, ...], text_gradient_steps: tuple[int, ...] | int, text_gradient_direction: Gradient.Direction, ) -> None: """Verify the SynthGrid effect respects grid and text gradient settings.""" effect = effect_synthgrid.SynthGrid(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.grid_gradient_stops = grid_gradient_stops effect.effect_config.grid_gradient_steps = ( (grid_gradient_steps,) if isinstance(grid_gradient_steps, int) else grid_gradient_steps ) effect.effect_config.grid_gradient_direction = grid_gradient_direction effect.effect_config.text_gradient_stops = text_gradient_stops effect.effect_config.text_gradient_steps = ( (text_gradient_steps,) if isinstance(text_gradient_steps, int) else text_gradient_steps ) effect.effect_config.text_gradient_direction = text_gradient_direction with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("grid_row_symbol", ["a", "b"]) @pytest.mark.parametrize("grid_column_symbol", ["c", "d"]) @pytest.mark.parametrize("text_generation_symbols", [("e",), ("f", "g"), ("h",)]) @pytest.mark.parametrize("max_active_blocks", [0.001, 1]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_synthgrid_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, grid_row_symbol: str, grid_column_symbol: str, text_generation_symbols: tuple[str, ...], max_active_blocks: float, ) -> None: """Ensure SynthGrid accepts and renders with various configuration arguments.""" effect = effect_synthgrid.SynthGrid(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.grid_row_symbol = grid_row_symbol effect.effect_config.grid_column_symbol = grid_column_symbol effect.effect_config.text_generation_symbols = text_generation_symbols effect.effect_config.max_active_blocks = max_active_blocks with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_synthgrid_dynamic_without_preexisting_colors_ends_dissolve_uncolored() -> None: """Verify dynamic mode ends the dissolve scene uncolored when no input colors exist.""" effect = effect_synthgrid.SynthGrid("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_synthgrid.SynthGridIterator", iter(effect)) character = _get_first_nonspace_character(iterator) dissolve_scene = character.animation.active_scene assert dissolve_scene is not None final_frame = dissolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_synthgrid_dynamic_with_preexisting_fg_restores_input_fg() -> None: """Verify dynamic mode restores a parsed foreground color at the end of dissolve.""" effect = effect_synthgrid.SynthGrid("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_synthgrid.SynthGridIterator", iter(effect)) character = _get_first_nonspace_character(iterator) dissolve_scene = character.animation.active_scene assert dissolve_scene is not None final_frame = dissolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_synthgrid_dynamic_with_preexisting_bg_only_restores_input_bg() -> None: """Verify dynamic mode restores a parsed background color without inventing a foreground.""" effect = effect_synthgrid.SynthGrid("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_synthgrid.SynthGridIterator", iter(effect)) character = _get_first_nonspace_character(iterator) dissolve_scene = character.animation.active_scene assert dissolve_scene is not None final_frame = dissolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_synthgrid_dynamic_with_preexisting_bg_space_restores_input_bg() -> None: """Verify dynamic mode restores a parsed background color on input spaces.""" effect = effect_synthgrid.SynthGrid("\x1b[48;5;106m \x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_synthgrid.SynthGridIterator", iter(effect)) character = _get_first_space_character(iterator) dissolve_scene = character.animation.active_scene assert dissolve_scene is not None final_frame = dissolve_scene.frames[-1].character_visual assert final_frame.symbol == " " assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_synthgrid_dynamic_with_preexisting_fg_and_bg_restores_input_colors() -> None: """Verify dynamic mode restores parsed foreground and background colors at dissolve end.""" effect = effect_synthgrid.SynthGrid("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_synthgrid.SynthGridIterator", iter(effect)) character = _get_first_nonspace_character(iterator) dissolve_scene = character.animation.active_scene assert dissolve_scene is not None final_frame = dissolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_synthgrid_ignore_with_preexisting_colors_uses_effect_gradient() -> None: """Verify ignore mode keeps the effect-owned text gradient as the dissolve endpoint.""" effect = effect_synthgrid.SynthGrid("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_synthgrid.SynthGridIterator", iter(effect)) character = _get_first_nonspace_character(iterator) dissolve_scene = character.animation.active_scene final_color = iterator.character_final_color_map[character].fg_color assert dissolve_scene is not None assert final_color is not None final_frame = dissolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=final_color) assert final_frame._fg_color_code == final_color.rgb_color assert final_frame._bg_color_code is None def test_synthgrid_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the dissolve endpoint to parsed input colors.""" effect = effect_synthgrid.SynthGrid("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_synthgrid.SynthGridIterator", iter(effect)) character = _get_first_nonspace_character(iterator) dissolve_scene = character.animation.active_scene assert dissolve_scene is not None final_frame = dissolve_scene.frames[-1].character_visual assert final_frame.symbol == "A" assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_thunderstorm.py000066400000000000000000000275441517776150200274740ustar00rootroot00000000000000"""Tests for the Thunderstorm effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_thunderstorm from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config def _get_first_nonspace_character( iterator: effect_thunderstorm.ThunderstormIterator, ) -> effect_thunderstorm.EffectCharacter: return next(character for character in iterator.terminal.get_characters() if character.input_symbol != " ") @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_thunderstorm_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Thunderstorm effect against a variety of representative inputs.""" effect = effect_thunderstorm.Thunderstorm(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.storm_time = 0.1 # type: ignore[assignment] with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_thunderstorm_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Thunderstorm output when terminal color toggles change.""" effect = effect_thunderstorm.Thunderstorm(input_data) effect.effect_config.storm_time = 0.01 # type: ignore[assignment] effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_thunderstorm_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_thunderstorm.tte.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Thunderstorm effect respects final gradient settings.""" effect = effect_thunderstorm.Thunderstorm(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.effect_config.storm_time = 0.01 # type: ignore[assignment] effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("lightning_color", [Color("#000000"), Color("#ff00ff")]) @pytest.mark.parametrize("glowing_text_color", [Color("#000000"), Color("#ff00ff")]) @pytest.mark.parametrize("text_glow_time", [1, 4]) @pytest.mark.parametrize("raindrop_symbols", [("a", "b"), ("a",)]) @pytest.mark.parametrize("spark_symbols", [(".", ","), ("a",)]) @pytest.mark.parametrize("spark_glow_color", [Color("#000000"), Color("#ff00ff")]) @pytest.mark.parametrize("spark_glow_time", [1, 4]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_thunderstorm_args( lightning_color: Color, glowing_text_color: Color, text_glow_time: int, raindrop_symbols: tuple[str, ...], spark_symbols: tuple[str, ...], spark_glow_time: int, spark_glow_color: Color, input_data: str, terminal_config_default_no_framerate: TerminalConfig, ) -> None: """Ensure Thunderstorm accepts and renders with various configuration arguments.""" effect = effect_thunderstorm.Thunderstorm(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.storm_time = 0.1 # type: ignore[assignment] effect.effect_config.lightning_color = lightning_color effect.effect_config.glowing_text_color = glowing_text_color effect.effect_config.text_glow_time = text_glow_time effect.effect_config.raindrop_symbols = raindrop_symbols effect.effect_config.spark_glow_time = spark_glow_time effect.effect_config.spark_symbols = spark_symbols effect.effect_config.spark_glow_color = spark_glow_color with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_thunderstorm_dynamic_without_preexisting_colors_uses_gray_then_clears() -> None: """Verify dynamic mode uses gray during the storm and clears to no color after it ends.""" effect = effect_thunderstorm.Thunderstorm("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_thunderstorm.ThunderstormIterator", iter(effect)) character = _get_first_nonspace_character(iterator) fade_scene = character.animation.query_scene("fade") glow_scene = character.animation.query_scene("glow") unfade_scene = character.animation.query_scene("unfade") storm_colors = iterator.character_storm_color_map[character] assert fade_scene is not None assert glow_scene is not None assert unfade_scene is not None assert ( fade_scene.frames[0].character_visual._fg_color_code == effect_thunderstorm.ThunderstormIterator.DYNAMIC_NEUTRAL_GRAY.rgb_color ) assert fade_scene.frames[-1].character_visual.colors == storm_colors assert glow_scene.frames[-1].character_visual.colors == storm_colors final_frame = unfade_scene.frames[-1].character_visual assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_thunderstorm_dynamic_with_preexisting_fg_uses_input_fg_from_start() -> None: """Verify dynamic mode starts from input fg, fades it for the storm, and restores it after.""" effect = effect_thunderstorm.Thunderstorm("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_thunderstorm.ThunderstormIterator", iter(effect)) character = _get_first_nonspace_character(iterator) fade_scene = character.animation.query_scene("fade") unfade_scene = character.animation.query_scene("unfade") storm_colors = iterator.character_storm_color_map[character] assert fade_scene is not None assert unfade_scene is not None assert fade_scene.frames[0].character_visual._fg_color_code == Color(196).rgb_color assert fade_scene.frames[-1].character_visual.colors == storm_colors final_frame = unfade_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_thunderstorm_dynamic_with_preexisting_bg_only_uses_gray_fg_during_storm_then_restores_bg() -> None: """Verify bg-only input uses gray fg fallback during storm but ends with bg only.""" effect = effect_thunderstorm.Thunderstorm("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_thunderstorm.ThunderstormIterator", iter(effect)) character = _get_first_nonspace_character(iterator) fade_scene = character.animation.query_scene("fade") unfade_scene = character.animation.query_scene("unfade") assert fade_scene is not None assert unfade_scene is not None assert ( fade_scene.frames[0].character_visual._fg_color_code == effect_thunderstorm.ThunderstormIterator.DYNAMIC_NEUTRAL_GRAY.rgb_color ) assert fade_scene.frames[0].character_visual._bg_color_code == Color(106).rgb_color final_frame = unfade_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_thunderstorm_dynamic_with_preexisting_fg_and_bg_uses_input_colors_from_start() -> None: """Verify dynamic mode uses full input fg/bg colors for fade and restore.""" effect = effect_thunderstorm.Thunderstorm("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_thunderstorm.ThunderstormIterator", iter(effect)) character = _get_first_nonspace_character(iterator) fade_scene = character.animation.query_scene("fade") unfade_scene = character.animation.query_scene("unfade") storm_colors = iterator.character_storm_color_map[character] assert fade_scene is not None assert unfade_scene is not None assert fade_scene.frames[0].character_visual._fg_color_code == Color(196).rgb_color assert fade_scene.frames[0].character_visual._bg_color_code == Color(106).rgb_color assert fade_scene.frames[-1].character_visual.colors == storm_colors final_frame = unfade_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_thunderstorm_ignore_with_preexisting_colors_uses_effect_gradient_behavior() -> None: """Verify ignore mode keeps the effect-owned text gradient as the fade base.""" effect = effect_thunderstorm.Thunderstorm("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_thunderstorm.ThunderstormIterator", iter(effect)) character = _get_first_nonspace_character(iterator) fade_scene = character.animation.query_scene("fade") unfade_scene = character.animation.query_scene("unfade") assert fade_scene is not None assert unfade_scene is not None assert fade_scene.frames[0].character_visual.colors != ColorPair(fg=Color(196)) assert unfade_scene.frames[-1].character_visual.colors != ColorPair(fg=Color(196)) def test_thunderstorm_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the visible text scenes to parsed input colors.""" effect = effect_thunderstorm.Thunderstorm("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_thunderstorm.ThunderstormIterator", iter(effect)) character = _get_first_nonspace_character(iterator) unfade_scene = character.animation.query_scene("unfade") assert unfade_scene is not None final_frame = unfade_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_thunderstorm_dynamic_flash_and_glow_remain_effect_driven_while_cooling_to_storm_state() -> None: """Verify flash and glow scenes still exist and cool back to the dynamic storm color.""" effect = effect_thunderstorm.Thunderstorm("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_thunderstorm.ThunderstormIterator", iter(effect)) character = _get_first_nonspace_character(iterator) glow_scene = character.animation.query_scene("glow") flash_scene = character.animation.query_scene("flash") assert glow_scene is not None assert flash_scene is not None assert ( glow_scene.frames[0].character_visual._fg_color_code == effect.effect_config.glowing_text_color.rgb_color.lower() ) assert glow_scene.frames[-1].character_visual.colors == iterator.character_storm_color_map[character] assert flash_scene.frames[0].character_visual.colors == iterator.character_storm_color_map[character] terminaltexteffects-release-0.15.0/tests/effects_tests/test_unstable.py000066400000000000000000000255041517776150200265450ustar00rootroot00000000000000"""Tests for the Unstable effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_unstable from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_unstable_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Unstable effect against a variety of representative inputs.""" effect = effect_unstable.Unstable(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_unstable_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Unstable output when terminal color toggles change.""" effect = effect_unstable.Unstable(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_unstable_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_unstable.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify the Unstable effect respects final gradient settings.""" effect = effect_unstable.Unstable(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("unstable_color", [Color("#ff00ff"), Color("#0ffff0")]) @pytest.mark.parametrize("explosion_speed", [0.001, 2]) @pytest.mark.parametrize("reassembly_speed", [0.001, 2]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_unstable_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, unstable_color: Color, explosion_speed: float, reassembly_speed: float, ) -> None: """Ensure Unstable accepts and renders with various configuration arguments.""" effect = effect_unstable.Unstable(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.unstable_color = unstable_color effect.effect_config.explosion_speed = explosion_speed effect.effect_config.reassembly_speed = reassembly_speed with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_unstable_explosion_ease( terminal_config_default_no_framerate: TerminalConfig, input_data: str, easing_function_1: effect_unstable.easing.EasingFunction, ) -> None: """Ensure Unstable accepts and renders with various explosion easing functions.""" effect = effect_unstable.Unstable(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.explosion_ease = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_unstable_reassembly_ease( terminal_config_default_no_framerate: TerminalConfig, input_data: str, easing_function_1: effect_unstable.easing.EasingFunction, ) -> None: """Ensure Unstable accepts and renders with various reassembly easing functions.""" effect = effect_unstable.Unstable(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.reassembly_ease = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_unstable_dynamic_without_preexisting_colors_starts_gray_and_ends_uncolored() -> None: """Verify dynamic mode starts uncolored text in neutral gray and clears it after coalescing.""" effect = effect_unstable.Unstable("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_unstable.UnstableIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual rumble_scene = character.animation.query_scene("rumble") final_scene = character.animation.query_scene("final") assert current_visual.colors == ColorPair(fg=effect_unstable.UnstableIterator.DYNAMIC_NEUTRAL_GRAY) assert rumble_scene is not None assert final_scene is not None assert rumble_scene.frames[-1].character_visual._fg_color_code == effect.effect_config.unstable_color.rgb_color final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_unstable_dynamic_with_preexisting_fg_starts_and_ends_in_input_fg() -> None: """Verify dynamic mode starts and finishes with the parsed foreground color.""" effect = effect_unstable.Unstable("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_unstable.UnstableIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_scene = character.animation.query_scene("final") assert current_visual.colors == ColorPair(fg=Color(196)) assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_unstable_dynamic_with_preexisting_bg_only_starts_gray_and_bg_then_ends_bg_only() -> None: """Verify bg-only input uses gray fg fallback until coalescing removes it.""" effect = effect_unstable.Unstable("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_unstable.UnstableIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_scene = character.animation.query_scene("final") assert current_visual.colors == ColorPair( fg=effect_unstable.UnstableIterator.DYNAMIC_NEUTRAL_GRAY, bg=Color(106), ) assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_unstable_dynamic_with_preexisting_fg_and_bg_starts_and_ends_in_input_colors() -> None: """Verify dynamic mode starts and finishes with the parsed fg/bg colors.""" effect = effect_unstable.Unstable("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_unstable.UnstableIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_scene = character.animation.query_scene("final") assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_unstable_ignore_with_preexisting_colors_uses_effect_gradient_behavior() -> None: """Verify ignore mode keeps the effect-owned gradient behavior.""" effect = effect_unstable.Unstable("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_unstable.UnstableIterator", iter(effect)) character = iterator.terminal.get_characters()[0] rumble_scene = character.animation.query_scene("rumble") final_scene = character.animation.query_scene("final") final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None assert rumble_scene is not None assert rumble_scene.frames[-1].character_visual._fg_color_code == effect.effect_config.unstable_color.rgb_color assert final_scene is not None assert final_scene.frames[-1].character_visual.colors == ColorPair(fg=final_color) def test_unstable_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves the final visible frame to parsed input colors.""" effect = effect_unstable.Unstable("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_unstable.UnstableIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.query_scene("final") assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_unstable_dynamic_rumble_still_transitions_toward_unstable_color() -> None: """Verify rumble still heads toward the unstable effect color under dynamic handling.""" effect = effect_unstable.Unstable("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_unstable.UnstableIterator", iter(effect)) character = iterator.terminal.get_characters()[0] rumble_scene = character.animation.query_scene("rumble") assert rumble_scene is not None assert rumble_scene.frames[0].character_visual._fg_color_code == Color(196).rgb_color assert rumble_scene.frames[-1].character_visual._fg_color_code == effect.effect_config.unstable_color.rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_vhstape.py000066400000000000000000000273271517776150200264070ustar00rootroot00000000000000"""Tests for the VHSTape effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_vhstape from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_vhstape_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the VHSTape effect against representative input shapes.""" effect = effect_vhstape.VHSTape(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_vhstape_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test VHSTape output when terminal color toggles change.""" effect = effect_vhstape.VHSTape(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_vhstape_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_vhstape.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify VHSTape respects final gradient settings.""" effect = effect_vhstape.VHSTape(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("glitch_line_colors", [(Color("#ff00ff"), Color("#0ffff0")), (Color("#ff0fff"),)]) @pytest.mark.parametrize("glitch_wave_colors", [(Color("#ff00ff"), Color("#0ffff0")), (Color("#ff0fff"),)]) @pytest.mark.parametrize("noise_colors", [(Color("#ff00ff"), Color("#0ffff0")), (Color("#ff0fff"),)]) @pytest.mark.parametrize("glitch_line_chance", [0, 0.5, 1]) @pytest.mark.parametrize("noise_chance", [0, 0.5, 1]) @pytest.mark.parametrize("total_glitch_time", [1, 20]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_vhstape_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, glitch_line_colors: tuple[Color, ...], glitch_wave_colors: tuple[Color, ...], noise_colors: tuple[Color, ...], glitch_line_chance: float, noise_chance: float, total_glitch_time: int, ) -> None: """Ensure VHSTape renders with varied configuration arguments.""" effect = effect_vhstape.VHSTape(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.glitch_line_colors = glitch_line_colors effect.effect_config.glitch_wave_colors = glitch_wave_colors effect.effect_config.noise_colors = noise_colors effect.effect_config.glitch_line_chance = glitch_line_chance effect.effect_config.noise_chance = noise_chance effect.effect_config.total_glitch_time = total_glitch_time with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_vhstape_dynamic_without_preexisting_colors_starts_gray_and_resolves_uncolored() -> None: """Verify uncolored dynamic text starts gray and resolves to no explicit color.""" effect = effect_vhstape.VHSTape("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_vhstape.VHSTapeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual base_scene = character.animation.query_scene("base") snow_scene = character.animation.query_scene("snow") final_redraw_scene = character.animation.query_scene("final_redraw") assert current_visual.colors == ColorPair(fg=effect_vhstape.VHSTapeIterator.DYNAMIC_NEUTRAL_GRAY) assert base_scene is not None assert base_scene.frames[-1].character_visual.colors == ColorPair( fg=effect_vhstape.VHSTapeIterator.DYNAMIC_NEUTRAL_GRAY, ) assert snow_scene is not None assert snow_scene.frames[-1].character_visual.colors == ColorPair( fg=effect_vhstape.VHSTapeIterator.DYNAMIC_NEUTRAL_GRAY, ) assert final_redraw_scene is not None final_frame = final_redraw_scene.frames[-1].character_visual assert final_frame.colors == ColorPair() assert final_frame._fg_color_code is None assert final_frame._bg_color_code is None def test_vhstape_dynamic_with_preexisting_fg_starts_and_resolves_in_input_fg() -> None: """Verify dynamic mode preserves parsed foreground color in stable and final scenes.""" effect = effect_vhstape.VHSTape("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_vhstape.VHSTapeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual base_scene = character.animation.query_scene("base") snow_scene = character.animation.query_scene("snow") final_redraw_scene = character.animation.query_scene("final_redraw") assert current_visual.colors == ColorPair(fg=Color(196)) assert base_scene is not None assert base_scene.frames[-1].character_visual.colors == ColorPair(fg=Color(196)) assert snow_scene is not None assert snow_scene.frames[-1].character_visual.colors == ColorPair(fg=Color(196)) assert final_redraw_scene is not None final_frame = final_redraw_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_vhstape_dynamic_with_preexisting_bg_only_starts_gray_and_resolves_bg_only() -> None: """Verify bg-only input uses gray fg fallback until the final redraw removes it.""" effect = effect_vhstape.VHSTape("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_vhstape.VHSTapeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_redraw_scene = character.animation.query_scene("final_redraw") assert current_visual.colors == ColorPair( fg=effect_vhstape.VHSTapeIterator.DYNAMIC_NEUTRAL_GRAY, bg=Color(106), ) assert final_redraw_scene is not None final_frame = final_redraw_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_vhstape_dynamic_with_preexisting_fg_and_bg_starts_and_resolves_in_input_colors() -> None: """Verify dynamic mode preserves parsed fg/bg colors in stable and final scenes.""" effect = effect_vhstape.VHSTape("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_vhstape.VHSTapeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_redraw_scene = character.animation.query_scene("final_redraw") assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_redraw_scene is not None final_frame = final_redraw_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_vhstape_ignore_with_preexisting_colors_uses_effect_gradient_behavior() -> None: """Verify ignore mode keeps the effect-owned final gradient behavior.""" effect = effect_vhstape.VHSTape("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_vhstape.VHSTapeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] base_scene = character.animation.query_scene("base") final_redraw_scene = character.animation.query_scene("final_redraw") final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None assert base_scene is not None assert base_scene.frames[-1].character_visual.colors == ColorPair(fg=final_color) assert final_redraw_scene is not None assert final_redraw_scene.frames[-1].character_visual.colors == ColorPair(fg=final_color) def test_vhstape_always_with_preexisting_colors_resolves_in_input_colors() -> None: """Verify always mode still resolves the final visible frame to parsed input colors.""" effect = effect_vhstape.VHSTape("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_vhstape.VHSTapeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_redraw_scene = character.animation.query_scene("final_redraw") assert final_redraw_scene is not None final_frame = final_redraw_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_vhstape_dynamic_keeps_glitch_noise_and_white_redraw_effect_colored() -> None: """Verify dynamic mode preserves the effect-owned glitch, noise, and redraw colors.""" effect = effect_vhstape.VHSTape("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.glitch_line_colors = (Color("#ff00ff"), Color("#00ff00")) effect.effect_config.noise_colors = (Color("#111111"), Color("#222222")) iterator = cast("effect_vhstape.VHSTapeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] glitch_forward_scene = character.animation.query_scene("rgb_glitch_fwd") glitch_backward_scene = character.animation.query_scene("rgb_glitch_bwd") snow_scene = character.animation.query_scene("snow") final_redraw_scene = character.animation.query_scene("final_redraw") assert glitch_forward_scene is not None assert [frame.character_visual._fg_color_code for frame in glitch_forward_scene.frames] == [ Color("#ff00ff").rgb_color, Color("#00ff00").rgb_color, ] assert glitch_backward_scene is not None assert [frame.character_visual._fg_color_code for frame in glitch_backward_scene.frames] == [ Color("#00ff00").rgb_color, Color("#ff00ff").rgb_color, ] assert snow_scene is not None assert { frame.character_visual._fg_color_code for frame in snow_scene.frames[:-1] } <= {Color("#111111").rgb_color, Color("#222222").rgb_color} assert final_redraw_scene is not None assert final_redraw_scene.frames[0].character_visual._fg_color_code == Color("#ffffff").rgb_color terminaltexteffects-release-0.15.0/tests/effects_tests/test_waves.py000066400000000000000000000247511517776150200260600ustar00rootroot00000000000000"""Tests for the Waves effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import Literal, cast import pytest from terminaltexteffects.effects import effect_waves from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair WaveDirection = Literal[ "column_left_to_right", "column_right_to_left", "row_top_to_bottom", "row_bottom_to_top", "center_to_outside", "outside_to_center", ] def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_waves_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Test the Waves effect against representative input shapes.""" effect = effect_waves.Waves(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_waves_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Test Waves output when terminal color toggles change.""" effect = effect_waves.Waves(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_waves_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: effect_waves.Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], ) -> None: """Verify Waves respects final gradient settings.""" effect = effect_waves.Waves(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("wave_symbols", [("a", "b"), ("c",)]) @pytest.mark.parametrize( "wave_gradient_stops", [(Color("#000000"), Color("#ff00ff"), Color("#0ffff0")), (Color("#ff0fff"),)], ) @pytest.mark.parametrize("wave_gradient_steps", [(1,), (4,), (1, 3)]) @pytest.mark.parametrize("wave_count", [1, 4]) @pytest.mark.parametrize("wave_length", [1, 3]) @pytest.mark.parametrize( "wave_direction", [ "column_left_to_right", "column_right_to_left", "row_top_to_bottom", "row_bottom_to_top", "center_to_outside", "outside_to_center", ], ) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_waves_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, wave_symbols: tuple[str, ...], wave_gradient_stops: tuple[Color, ...], wave_gradient_steps: tuple[int, ...], wave_count: int, wave_length: int, wave_direction: WaveDirection, ) -> None: """Ensure Waves renders with varied configuration arguments.""" effect = effect_waves.Waves(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.wave_symbols = wave_symbols effect.effect_config.wave_gradient_stops = wave_gradient_stops effect.effect_config.wave_gradient_steps = wave_gradient_steps effect.effect_config.wave_count = wave_count effect.effect_config.wave_length = wave_length effect.effect_config.wave_direction = wave_direction with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_waves_effect_easing( input_data: str, terminal_config_default_no_framerate: TerminalConfig, easing_function_1: effect_waves.easing.EasingFunction, ) -> None: """Ensure Waves renders with varied easing functions.""" effect = effect_waves.Waves(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.wave_easing = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_waves_dynamic_without_preexisting_colors_starts_and_ends_uncolored() -> None: """Verify uncolored dynamic text starts and settles with no explicit color.""" effect = effect_waves.Waves("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_waves.WavesIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_scene = character.animation.query_scene("1") assert current_visual.colors == ColorPair() assert final_scene is not None assert final_scene.frames[-1].character_visual.colors == ColorPair() assert final_scene.frames[-1].character_visual._fg_color_code is None assert final_scene.frames[-1].character_visual._bg_color_code is None def test_waves_dynamic_with_preexisting_fg_starts_and_ends_in_input_fg() -> None: """Verify dynamic mode preserves parsed foreground color before and after the wave.""" effect = effect_waves.Waves("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_waves.WavesIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_scene = character.animation.query_scene("1") assert current_visual.colors == ColorPair(fg=Color(196)) assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code is None def test_waves_dynamic_with_preexisting_bg_only_starts_and_ends_in_input_bg() -> None: """Verify bg-only input remains background-only before and after the wave.""" effect = effect_waves.Waves("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_waves.WavesIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_scene = character.animation.query_scene("1") assert current_visual.colors == ColorPair(bg=Color(106)) assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(bg=Color(106)) assert final_frame._fg_color_code is None assert final_frame._bg_color_code == Color(106).rgb_color def test_waves_dynamic_with_preexisting_fg_and_bg_starts_and_ends_in_input_colors() -> None: """Verify dynamic mode preserves parsed fg/bg colors before and after the wave.""" effect = effect_waves.Waves("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_waves.WavesIterator", iter(effect)) character = iterator.terminal.get_characters()[0] current_visual = character.animation.current_character_visual final_scene = character.animation.query_scene("1") assert current_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_waves_ignore_with_preexisting_colors_uses_effect_gradient_behavior() -> None: """Verify ignore mode keeps the effect-owned final gradient behavior.""" effect = effect_waves.Waves("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_waves.WavesIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.query_scene("1") final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None assert final_scene is not None assert final_scene.frames[-1].character_visual.colors == ColorPair(fg=final_color) def test_waves_always_with_preexisting_colors_resolves_in_input_colors() -> None: """Verify always mode still resolves the final visible frame to parsed input colors.""" effect = effect_waves.Waves("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_waves.WavesIterator", iter(effect)) character = iterator.terminal.get_characters()[0] final_scene = character.animation.query_scene("1") assert final_scene is not None final_frame = final_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color def test_waves_dynamic_keeps_wave_scene_effect_colored() -> None: """Verify the animated wave remains effect-colored in dynamic mode.""" effect = effect_waves.Waves("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") effect.effect_config.wave_symbols = ("a", "b") effect.effect_config.wave_gradient_stops = (Color("#111111"), Color("#222222")) effect.effect_config.wave_gradient_steps = (1,) effect.effect_config.wave_count = 1 iterator = cast("effect_waves.WavesIterator", iter(effect)) character = iterator.terminal.get_characters()[0] wave_scene = character.animation.query_scene("0") assert wave_scene is not None assert [frame.character_visual.symbol for frame in wave_scene.frames] == ["a", "b"] assert {frame.character_visual._fg_color_code for frame in wave_scene.frames} <= { Color("#111111").rgb_color, Color("#222222").rgb_color, } terminaltexteffects-release-0.15.0/tests/effects_tests/test_wipe.py000066400000000000000000000211271517776150200256710ustar00rootroot00000000000000"""Tests for the Wipe effect and its dynamic preexisting-color handling.""" from __future__ import annotations from typing import TYPE_CHECKING, Literal, cast import pytest from terminaltexteffects.effects import effect_wipe from terminaltexteffects.engine.terminal import TerminalConfig from terminaltexteffects.utils.graphics import Color, ColorPair if TYPE_CHECKING: from terminaltexteffects.utils.argutils import CharacterGroup from terminaltexteffects.utils.easing import EasingFunction from terminaltexteffects.utils.graphics import Gradient def _make_terminal_config( existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> TerminalConfig: terminal_config = TerminalConfig._build_config() terminal_config.frame_rate = 0 terminal_config.existing_color_handling = existing_color_handling return terminal_config @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs"], indirect=True, ) def test_wipe_effect(input_data: str, terminal_config_default_no_framerate: TerminalConfig) -> None: """Ensure the wipe effect renders without errors for various inputs.""" effect = effect_wipe.Wipe(input_data) effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_wipe_effect_terminal_color_options( input_data: str, terminal_config_with_color_options: TerminalConfig, ) -> None: """Ensure the effect works when terminal color options are configured.""" effect = effect_wipe.Wipe(input_data) effect.terminal_config = terminal_config_with_color_options with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["medium"], indirect=True) def test_wipe_final_gradient( terminal_config_default_no_framerate: TerminalConfig, input_data: str, gradient_direction: Gradient.Direction, gradient_steps: tuple[int, ...], gradient_stops: tuple[Color, ...], gradient_frames: int, ) -> None: """Validate that final gradient customization options render as expected.""" effect = effect_wipe.Wipe(input_data) effect.effect_config.final_gradient_stops = gradient_stops effect.effect_config.final_gradient_steps = gradient_steps effect.effect_config.final_gradient_direction = gradient_direction effect.effect_config.final_gradient_frames = gradient_frames effect.terminal_config = terminal_config_default_no_framerate with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("wipe_direction", effect_wipe.argutils.CharacterGroup) @pytest.mark.parametrize("wipe_delay", [0, 5]) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_wipe_args( terminal_config_default_no_framerate: TerminalConfig, input_data: str, wipe_direction: CharacterGroup, wipe_delay: int, ) -> None: """Check that all wipe direction and delay combinations complete successfully.""" effect = effect_wipe.Wipe(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.wipe_direction = wipe_direction effect.effect_config.wipe_delay = wipe_delay with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) @pytest.mark.parametrize("input_data", ["single_char", "medium"], indirect=True) def test_wipe_ease( terminal_config_default_no_framerate: TerminalConfig, input_data: str, easing_function_1: EasingFunction, ) -> None: """Verify easing function changes run without issues.""" effect = effect_wipe.Wipe(input_data) effect.terminal_config = terminal_config_default_no_framerate effect.effect_config.wipe_ease = easing_function_1 with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) def test_wipe_dynamic_without_preexisting_colors_uses_no_color_for_entire_scene() -> None: """Verify uncolored dynamic text remains uncolored for every wipe frame.""" effect = effect_wipe.Wipe("A") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_wipe.WipeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] wipe_scene = character.animation.query_scene("wipe") assert wipe_scene is not None assert all(frame.character_visual.colors == ColorPair() for frame in wipe_scene.frames) assert all(frame.character_visual._fg_color_code is None for frame in wipe_scene.frames) assert all(frame.character_visual._bg_color_code is None for frame in wipe_scene.frames) def test_wipe_dynamic_with_preexisting_fg_uses_input_fg_for_entire_scene() -> None: """Verify dynamic mode uses parsed foreground color for all wipe frames.""" effect = effect_wipe.Wipe("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_wipe.WipeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] wipe_scene = character.animation.query_scene("wipe") assert wipe_scene is not None assert all(frame.character_visual.colors == ColorPair(fg=Color(196)) for frame in wipe_scene.frames) assert all(frame.character_visual._fg_color_code == Color(196).rgb_color for frame in wipe_scene.frames) assert all(frame.character_visual._bg_color_code is None for frame in wipe_scene.frames) def test_wipe_dynamic_with_preexisting_bg_only_uses_input_bg_for_entire_scene() -> None: """Verify bg-only input remains background-only for all wipe frames.""" effect = effect_wipe.Wipe("\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_wipe.WipeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] wipe_scene = character.animation.query_scene("wipe") assert wipe_scene is not None assert all(frame.character_visual.colors == ColorPair(bg=Color(106)) for frame in wipe_scene.frames) assert all(frame.character_visual._fg_color_code is None for frame in wipe_scene.frames) assert all(frame.character_visual._bg_color_code == Color(106).rgb_color for frame in wipe_scene.frames) def test_wipe_dynamic_with_preexisting_fg_and_bg_uses_input_colors_for_entire_scene() -> None: """Verify dynamic mode uses parsed fg/bg colors for all wipe frames.""" effect = effect_wipe.Wipe("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("dynamic") iterator = cast("effect_wipe.WipeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] wipe_scene = character.animation.query_scene("wipe") assert wipe_scene is not None assert all(frame.character_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) for frame in wipe_scene.frames) assert all(frame.character_visual._fg_color_code == Color(196).rgb_color for frame in wipe_scene.frames) assert all(frame.character_visual._bg_color_code == Color(106).rgb_color for frame in wipe_scene.frames) def test_wipe_ignore_with_preexisting_colors_uses_effect_gradient_behavior() -> None: """Verify ignore mode keeps the effect-owned wipe gradient.""" effect = effect_wipe.Wipe("\x1b[38;5;196mA\x1b[0m") effect.terminal_config = _make_terminal_config("ignore") iterator = cast("effect_wipe.WipeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] wipe_scene = character.animation.query_scene("wipe") final_color = iterator.character_final_color_map[character].fg_color assert final_color is not None assert wipe_scene is not None assert wipe_scene.frames[-1].character_visual.colors == ColorPair(fg=final_color) def test_wipe_always_with_preexisting_colors_uses_input_colors() -> None: """Verify always mode still resolves visible frames to parsed input colors.""" effect = effect_wipe.Wipe("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") effect.terminal_config = _make_terminal_config("always") iterator = cast("effect_wipe.WipeIterator", iter(effect)) character = iterator.terminal.get_characters()[0] wipe_scene = character.animation.query_scene("wipe") assert wipe_scene is not None final_frame = wipe_scene.frames[-1].character_visual assert final_frame.colors == ColorPair(fg=Color(196), bg=Color(106)) assert final_frame._fg_color_code == Color(196).rgb_color assert final_frame._bg_color_code == Color(106).rgb_color terminaltexteffects-release-0.15.0/tests/engine_tests/000077500000000000000000000000001517776150200231375ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/tests/engine_tests/__init__.py000066400000000000000000000001171517776150200252470ustar00rootroot00000000000000"""Marks this directory as a Python package for test discovery and imports.""" terminaltexteffects-release-0.15.0/tests/engine_tests/test_animation.py000066400000000000000000000740601517776150200265360ustar00rootroot00000000000000"""Unit tests for the animation functionality within the terminaltexteffects package.""" import pytest from terminaltexteffects.engine.animation import CharacterVisual, Frame, Scene from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.utils import easing from terminaltexteffects.utils.exceptions import ( ActivateEmptySceneError, AnimationSceneError, FrameDurationError, SceneNotFoundError, ) from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient pytestmark = [pytest.mark.engine, pytest.mark.animation, pytest.mark.smoke] @pytest.fixture def character_visual_default() -> CharacterVisual: """Return a default CharacterVisual instance with the symbol set to "a". Returns: CharacterVisual: A new instance of CharacterVisual with the default symbol "a". """ return CharacterVisual( symbol="a", ) @pytest.fixture def character_visual_all_modes_enabled() -> CharacterVisual: """Return a CharacterVisual instance with all modes enabled. Returns: CharacterVisual: A new instance of CharacterVisual with all attributes set. """ return CharacterVisual( symbol="a", bold=True, dim=True, italic=True, underline=True, blink=True, reverse=True, hidden=True, strike=True, colors=ColorPair("#ffffff", "#ffffff"), _fg_color_code="ffffff", _bg_color_code="ffffff", ) @pytest.fixture def character() -> EffectCharacter: """Return a default EffectCharacter instance.""" return EffectCharacter(0, "a", 0, 0) def test_character_visual_init(character_visual_all_modes_enabled: CharacterVisual) -> None: """Test that the formatted_symbol of character_visual_all_modes_enabled is correctly initialized.""" assert ( character_visual_all_modes_enabled.formatted_symbol == "\x1b[1m\x1b[3m\x1b[4m\x1b[5m\x1b[7m\x1b[8m\x1b[9m\x1b[38;2;255;255;255m\x1b[48;2;255;255;255ma\x1b[0m" ) def test_character_visual_init_default(character_visual_default: CharacterVisual) -> None: """Test that the default formatted symbol is 'a'.""" assert character_visual_default.formatted_symbol == "a" def test_frame_init(character_visual_default: CharacterVisual) -> None: """Test that the Frame instance is correctly initialized.""" frame = Frame(character_visual=character_visual_default, duration=5) assert frame.character_visual == character_visual_default assert frame.duration == 5 assert frame.ticks_elapsed == 0 def test_scene_init() -> None: """Test that the Scene instance is correctly initialized.""" scene = Scene(scene_id="test_scene", is_looping=True, sync=Scene.SyncMetric.STEP, ease=easing.in_sine) assert scene.scene_id == "test_scene" assert scene.is_looping is True assert scene.sync == Scene.SyncMetric.STEP assert scene.ease == easing.in_sine def test_scene_add_frame() -> None: """Test that a frame can be added to the Scene instance.""" scene = Scene(scene_id="test_scene") scene.add_frame( symbol="a", duration=5, colors=ColorPair("#ffffff", "#ffffff"), bold=True, italic=True, blink=True, hidden=True, ) assert len(scene.frames) == 1 frame = scene.frames[0] assert ( frame.character_visual.formatted_symbol == "\x1b[1m\x1b[3m\x1b[5m\x1b[8m\x1b[38;2;255;255;255m\x1b[48;2;255;255;255ma\x1b[0m" ) assert frame.duration == 5 assert frame.character_visual.colors == ColorPair("#ffffff", "#ffffff") assert frame.character_visual.bold is True def test_scene_add_frame_invalid_duration() -> None: """Test that a FrameDurationError is raised when a frame with a duration of 0 is added to the scene.""" scene = Scene(scene_id="test_scene") with pytest.raises(FrameDurationError): scene.add_frame(symbol="a", duration=0, colors=ColorPair("#ffffff", "#ffffff")) def test_scene_apply_gradient_to_symbols_equal_colors_and_symbols() -> None: """Test symbols are correctly assigned colors from a gradient when the colors and symbols are equal in length.""" scene = Scene(scene_id="test_scene") gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=2) symbols = ["a", "b", "c"] scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient) assert len(scene.frames) == 3 for i, frame in enumerate(scene.frames): assert frame.duration == 1 assert frame.character_visual._fg_color_code == gradient.spectrum[i].rgb_color def test_scene_apply_gradient_to_symbols_unequal_colors_and_symbols() -> None: """Test that all colors and symbols are represented when the gradient and symbols length are unequal. Verify the gradient is represented in the scene frames and the symbols are progressed such that the first and final symbols align to the first and final colors. """ scene = Scene(scene_id="test_scene") gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=4) symbols = ["q", "z"] scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient) assert len(scene.frames) == 5 assert scene.frames[0].character_visual._fg_color_code == gradient.spectrum[0].rgb_color assert "q" in scene.frames[0].character_visual.symbol assert scene.frames[-1].character_visual._fg_color_code == gradient.spectrum[-1].rgb_color assert "z" in scene.frames[-1].character_visual.symbol def test_animation_init(character: EffectCharacter) -> None: """Test that the EffectCharacter instance is correctly initialized.""" assert character.animation.character == character assert character.animation.scenes == {} assert character.animation.active_scene is None assert character.animation.use_xterm_colors is False assert character.animation.no_color is False assert character.animation.xterm_color_map == {} assert character.animation.active_scene_current_step == 0 def test_animation_new_scene(character: EffectCharacter) -> None: """Test that a new scene can be created.""" animation = character.animation scene = animation.new_scene(scene_id="test_scene", is_looping=True) assert isinstance(scene, Scene) assert scene.scene_id == "test_scene" assert scene.is_looping is True assert "test_scene" in animation.scenes def test_animation_new_scene_without_id(character: EffectCharacter) -> None: """Test that a new scene can be created without a specified ID.""" animation = character.animation scene = animation.new_scene() assert isinstance(scene, Scene) assert scene.scene_id == "0" assert "0" in animation.scenes 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): character.animation.new_scene() character.animation.scenes.pop("2") character.animation.new_scene() def test_animation_query_scene(character: EffectCharacter) -> None: """Test that a scene can be queried from the animation.""" animation = character.animation scene = animation.new_scene(scene_id="test_scene", is_looping=True) assert animation.query_scene("test_scene") is scene def test_animation_query_nonexistent_scene(character: EffectCharacter) -> None: """Test that querying a non-existent scene on the EffectCharacter's animation raises a SceneNotFoundError.""" animation = character.animation with pytest.raises(SceneNotFoundError): animation.query_scene("nonexistent_scene") def test_animation_looping_active_scene_is_complete(character: EffectCharacter) -> None: """Test that the looping active scene is complete after all frames have been processed.""" animation = character.animation scene = animation.new_scene(scene_id="test_scene", is_looping=True) scene.add_frame(symbol="a", duration=2) animation.activate_scene(scene) assert animation.active_scene_is_complete() is True 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 scene = animation.new_scene(scene_id="test_scene") scene.add_frame(symbol="a", duration=1) animation.activate_scene(scene) assert animation.active_scene_is_complete() is False animation.step_animation() assert animation.active_scene_is_complete() is True def test_animation_get_color_code_no_color(character: EffectCharacter) -> None: """Test that the color code is None when no_color is enabled.""" character.animation.no_color = True assert character.animation._get_color_code(Color("#ffffff")) is None def test_animation_get_color_code_use_xterm_colors(character: EffectCharacter) -> None: """Ensure xterm color mapping is used when the flag is enabled.""" character.animation.use_xterm_colors = True assert character.animation._get_color_code(Color("#ffffff")) == 15 assert character.animation._get_color_code(Color(0)) == 0 assert character.animation._get_color_code(Color("#ffffff")) == 15 def test_animation_get_color_code_rgb_color(character: EffectCharacter) -> None: """Ensure standard RGB color codes are returned when xterm colors are disabled.""" assert character.animation._get_color_code(Color("#ffffff")) == "ffffff" def test_animation_get_color_code_color_is_none(character: EffectCharacter) -> None: """Verify None is safely handled when requesting a color code.""" assert character.animation._get_color_code(None) is None def test_animation_set_appearance_existing_colors(character: EffectCharacter) -> None: """Ensure existing colors take precedence when the handling mode is 'always'.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.input_fg_color = Color("#ffffff") character.animation.input_bg_color = Color("#000000") character.animation.set_appearance("a", colors=ColorPair("#f0f0f0", "#0f0f0f")) assert character.animation.current_character_visual.colors == ColorPair( "#ffffff", "#000000", ) def test_animation_set_appearance_existing_bold(character: EffectCharacter) -> None: """Ensure always mode applies parsed input bold styling.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.input_fg_color = Color(10) character.animation.input_bold = True character.animation.set_appearance("a") current_visual = character.animation.current_character_visual assert current_visual.bold is True assert current_visual.colors == ColorPair(fg=Color(10)) assert current_visual.formatted_symbol.startswith("\x1b[1m") def test_animation_set_appearance_without_existing_bold(character: EffectCharacter) -> None: """Ensure parsed input bold is opt-in and does not affect non-bold input.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.input_fg_color = Color(2) character.animation.input_bold = False character.animation.set_appearance("a") assert character.animation.current_character_visual.bold is False def test_animation_set_appearance_existing_colors_without_input_colors_clears_effect_colors( character: EffectCharacter, ) -> None: """Ensure always mode clears effect-provided colors when the input character has no parsed colors.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.set_appearance("a", colors=ColorPair("#f0f0f0", "#0f0f0f")) assert character.animation.current_character_visual.colors == ColorPair() def test_animation_adjust_color_brightness_half(character: EffectCharacter) -> None: """Confirm halving brightness scales the color toward black.""" red = Color("#ff0000") new_color = character.animation.adjust_color_brightness(red, 0.5) assert new_color == Color("#7f0000") def test_animation_adjust_color_brightness_double(character: EffectCharacter) -> None: """Verify doubling brightness clamps the value to white.""" red = Color("#ff0000") new_color = character.animation.adjust_color_brightness(red, 2) assert new_color == Color("#ffffff") def test_animation_adjust_color_brightness_quarter(character: EffectCharacter) -> None: """Ensure quarter brightness darkens the color proportionally.""" red = Color("#ff0000") new_color = character.animation.adjust_color_brightness(red, 0.25) assert new_color == Color("#3f0000") def test_animation_adjust_color_brightness_zero(character: EffectCharacter) -> None: """Validate zero brightness results in pure black.""" red = Color("#ff0000") new_color = character.animation.adjust_color_brightness(red, 0) assert new_color == Color("#000000") def test_animation_adjust_color_brightness_negative(character: EffectCharacter) -> None: """Ensure negative brightness factors are clamped to black.""" red = Color("#ff0000") new_color = character.animation.adjust_color_brightness(red, -0.5) assert new_color == Color("#000000") def test_animation_adjust_color_brightness_black(character: EffectCharacter) -> None: """Confirm adjusting brightness of black always returns black.""" black = Color("#000000") new_color = character.animation.adjust_color_brightness(black, 0.5) assert new_color == Color("#000000") def test_animation_ease_animation_no_active_scene(character: EffectCharacter) -> None: """Ensure the easing helper defaults to zero with no active scene.""" assert character.animation._ease_animation(easing.in_sine) == 0 def test_animation_ease_animation_active_scene(character: EffectCharacter) -> None: """Verify easing value is calculated based on the active scene's progress.""" scene = character.animation.new_scene(scene_id="test_scene", ease=easing.in_sine) scene.add_frame(symbol="a", duration=10) scene.add_frame(symbol="b", duration=10) character.animation.activate_scene(scene) for _ in range(10): character.animation.step_animation() n = character.animation._ease_animation(easing.in_sine) assert n == 0.2928932188134524 def test_animation_step_animation_sync_step(character: EffectCharacter) -> None: """Ensure animations synchronized to steps advance correctly.""" p = character.motion.new_path() p.new_waypoint(Coord(10, 10)) character.motion.activate_path(p) s = character.animation.new_scene(sync=Scene.SyncMetric.STEP) s.add_frame(symbol="a", duration=10) s.add_frame(symbol="b", duration=10) character.animation.activate_scene(s) for _ in range(5): character.animation.step_animation() def test_animation_step_animation_sync_distance(character: EffectCharacter) -> None: """Ensure animations synchronized to distance progress when traveling.""" p = character.motion.new_path() p.new_waypoint(Coord(10, 10)) character.motion.activate_path(p) s = character.animation.new_scene(sync=Scene.SyncMetric.DISTANCE) s.add_frame(symbol="a", duration=10) s.add_frame(symbol="b", duration=10) character.animation.activate_scene(s) for _ in range(5): character.animation.step_animation() def test_animation_step_animation_sync_waypoint_deactivated(character: EffectCharacter) -> None: """Confirm animation stepping behaves when the associated path deactivates.""" p = character.motion.new_path() p.new_waypoint(Coord(10, 10)) character.motion.activate_path(p) s = character.animation.new_scene(sync=Scene.SyncMetric.DISTANCE) s.add_frame(symbol="a", duration=10) s.add_frame(symbol="b", duration=10) character.animation.activate_scene(s) for _ in range(5): character.animation.step_animation() character.motion.deactivate_path(p) character.animation.step_animation() def test_animation_step_animation_eased_scene(character: EffectCharacter) -> None: """Ensure eased scenes progress until completion.""" scene = character.animation.new_scene(scene_id="test_scene", ease=easing.in_sine) scene.add_frame(symbol="a", duration=10) scene.add_frame(symbol="b", duration=10) character.animation.activate_scene(scene) while character.animation.active_scene: character.animation.step_animation() def test_animation_step_animation_eased_scene_looping(character: EffectCharacter) -> None: """Ensure eased looping scenes continue cycling without errors.""" scene = character.animation.new_scene(scene_id="test_scene", ease=easing.in_sine, is_looping=True) scene.add_frame(symbol="a", duration=10) scene.add_frame(symbol="b", duration=10) character.animation.activate_scene(scene) for _ in range(100): character.animation.step_animation() 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") scene.add_frame(symbol="a", duration=10) character.animation.activate_scene(scene) character.animation.deactivate_scene() assert character.animation.active_scene is None def test_animation_deactivate_scene_by_object(character: EffectCharacter) -> None: """Verify that deactivating a scene by object clears the active scene reference.""" scene = character.animation.new_scene(scene_id="test_scene") scene.add_frame(symbol="a", duration=10) character.animation.activate_scene(scene) character.animation.deactivate_scene(scene) assert character.animation.active_scene is None def test_animation_deactivate_scene_by_id(character: EffectCharacter) -> None: """Verify that deactivating a scene by ID clears the active scene reference.""" scene = character.animation.new_scene(scene_id="test_scene") scene.add_frame(symbol="a", duration=10) character.animation.activate_scene(scene) character.animation.deactivate_scene("test_scene") assert character.animation.active_scene is None def test_scene_get_color_code_no_color(character: EffectCharacter) -> None: """Ensure Scene mirrors Animation color handling when color is disabled.""" character.animation.no_color = True new_scene = character.animation.new_scene() assert new_scene._get_color_code(Color("#ffffff")) is None def test_scene_get_color_code_use_xterm_colors(character: EffectCharacter) -> None: """Validate Scene resolves xterm codes when that option is enabled.""" character.animation.use_xterm_colors = True new_scene = character.animation.new_scene() assert new_scene._get_color_code(Color("#ffffff")) == 15 assert new_scene._get_color_code(Color(0)) == 0 assert new_scene._get_color_code(Color("#ffffff")) == 15 def test_scene_input_color_from_existing(character: EffectCharacter) -> None: """Ensure Scenes capture preexisting input colors from the animation.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.input_fg_color = Color("#ffffff") character.animation.input_bg_color = Color("#000000") new_scene = character.animation.new_scene() assert new_scene.preexisting_colors == ColorPair("#ffffff", "#000000") def test_scene_input_bold_from_existing(character: EffectCharacter) -> None: """Ensure Scenes capture preexisting input bold from the animation.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.input_bold = True new_scene = character.animation.new_scene() assert new_scene.preexisting_bold is True def test_scene_add_frame_existing_colors(character: EffectCharacter) -> None: """Confirm scene-level preexisting colors override per-frame colors.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.input_fg_color = Color("#ffffff") character.animation.input_bg_color = Color("#000000") new_scene = character.animation.new_scene() new_scene.add_frame(symbol="a", duration=1, colors=ColorPair("#f0f0f0", "#0f0f0f")) # the frame colors should be overridden by the scene colors derived from the input assert new_scene.frames[0].character_visual.colors == ColorPair("#ffffff", "#000000") def test_scene_add_frame_existing_bold(character: EffectCharacter) -> None: """Confirm scene-level preexisting bold overrides per-frame bold styling.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = True character.animation.input_bold = True new_scene = character.animation.new_scene() new_scene.add_frame(symbol="a", duration=1, bold=False) assert new_scene.frames[0].character_visual.bold is True def test_scene_input_color_from_existing_skips_helper_characters(character: EffectCharacter) -> None: """Ensure helper characters do not receive scene preexisting colors in always mode.""" character.animation.existing_color_handling = "always" character.uses_input_preexisting_colors = False character.animation.input_fg_color = Color("#ffffff") character.animation.input_bg_color = Color("#000000") new_scene = character.animation.new_scene() assert new_scene.preexisting_colors is None assert new_scene.preexisting_bold is False def test_activate_scene_with_no_frames(character: EffectCharacter) -> None: """Ensure activating an empty scene raises an error.""" new_scene = character.animation.new_scene(scene_id="test_scene") with pytest.raises(ActivateEmptySceneError): character.animation.activate_scene(new_scene) def test_animation_activate_scene_by_id(character: EffectCharacter) -> None: """Verify that activating a scene by ID sets it as the active scene.""" scene = character.animation.new_scene(scene_id="test_scene") scene.add_frame(symbol="a", duration=1) character.animation.activate_scene("test_scene") assert character.animation.active_scene is scene def test_scene_get_next_visual_looping(character: EffectCharacter) -> None: """Verify looping scenes wrap around when fetching visuals.""" new_scene = character.animation.new_scene(scene_id="test_scene", is_looping=True) new_scene.add_frame(symbol="a", duration=1) new_scene.add_frame(symbol="b", duration=1) character.animation.activate_scene(new_scene) visual = new_scene.get_next_visual() assert visual.symbol == "a" visual = new_scene.get_next_visual() assert visual.symbol == "b" visual = new_scene.get_next_visual() assert visual.symbol == "a" def test_scene_apply_gradient_to_symbols_empty_gradient(character: EffectCharacter) -> None: """Ensure empty gradient spectra trigger an error.""" new_scene = character.animation.new_scene(scene_id="test_scene") gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=2) gradient.spectrum.clear() symbols = ["a", "b", "c"] with pytest.raises(AnimationSceneError): new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient) def test_scene_apply_gradient_to_symbols_both_gradients_empty(character: EffectCharacter) -> None: """Ensure both empty gradients raise the same error path.""" new_scene = character.animation.new_scene(scene_id="test_scene") gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=2) gradient.spectrum.clear() symbols = ["a", "b", "c"] with pytest.raises(AnimationSceneError): new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient, bg_gradient=gradient) def test_scene_apply_gradient_to_symbols_invalid_symbols(character: EffectCharacter) -> None: """Test that an ApplyGradientToSymbolsInvalidSymbolError is raised when a symbol with length > 1 is passed.""" new_scene = character.animation.new_scene(scene_id="test_scene") gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=2) symbols = ["aa", "b", "c"] with pytest.raises(AnimationSceneError): new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient) 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") gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=1) symbols = ["a"] new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient, bg_gradient=gradient) assert len(new_scene.frames) == 2 for i, frame in enumerate(new_scene.frames): assert frame.character_visual._fg_color_code == gradient.spectrum[i].rgb_color assert symbols[0] in frame.character_visual.symbol def test_scene_apply_gradient_to_symbols_fg_bg_spectrums_not_equal(character: EffectCharacter) -> None: """Ensure frames expand to cover both spectrum lengths when unequal.""" new_scene = character.animation.new_scene(scene_id="test_scene") fg_gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=8) bg_gradient = Gradient(Color("#ffffff"), Color("#000000"), steps=6) symbols = ["a", "b", "c"] new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=fg_gradient, bg_gradient=bg_gradient) assert len(new_scene.frames) == 9 for i, frame in enumerate(new_scene.frames): assert frame.character_visual._fg_color_code == fg_gradient.spectrum[i].rgb_color def test_scene_apply_gradient_to_symbols_empty_spectrums(character: EffectCharacter) -> None: """Ensure clearing both spectrums raises an AnimationSceneError.""" new_scene = character.animation.new_scene(scene_id="test_scene") fg_gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=1) bg_gradient = Gradient(Color("#ffffff"), Color("#000000"), steps=1) fg_gradient.spectrum.clear() bg_gradient.spectrum.clear() symbols = ["a", "b", "c"] with pytest.raises(AnimationSceneError): new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=fg_gradient, bg_gradient=bg_gradient) def test_scene_apply_gradient_to_symbols_no_gradients(character: EffectCharacter) -> None: """Verify omitting both gradients is considered invalid.""" new_scene = character.animation.new_scene(scene_id="test_scene") symbols = ["a", "b", "c"] with pytest.raises(AnimationSceneError): new_scene.apply_gradient_to_symbols(symbols, duration=1) def test_scene_apply_gradient_to_symbols_larger_bg_spectrum(character: EffectCharacter) -> None: """Ensure larger background spectrums determine the frame count when longer.""" new_scene = character.animation.new_scene(scene_id="test_scene") fg_gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=3) bg_gradient = Gradient(Color("#ffffff"), Color("#000000"), steps=6) symbols = ["a", "b", "c"] new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=fg_gradient, bg_gradient=bg_gradient) assert len(new_scene.frames) == 7 for i, frame in enumerate(new_scene.frames): assert frame.character_visual._bg_color_code == bg_gradient.spectrum[i].rgb_color def test_scene_apply_gradient_to_symbols_larger_fg_spectrum(character: EffectCharacter) -> None: """Ensure larger foreground spectrums determine the total frame count.""" new_scene = character.animation.new_scene(scene_id="test_scene") fg_gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=6) bg_gradient = Gradient(Color("#ffffff"), Color("#000000"), steps=3) symbols = ["a", "b", "c"] new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=fg_gradient, bg_gradient=bg_gradient) assert len(new_scene.frames) == 7 for i, frame in enumerate(new_scene.frames): assert frame.character_visual._fg_color_code == fg_gradient.spectrum[i].rgb_color def test_scene_apply_gradient_to_symbols_fg_gradient_only(character: EffectCharacter) -> None: """Ensure supplying only a foreground gradient still creates frames.""" new_scene = character.animation.new_scene(scene_id="test_scene") fg_gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=3) symbols = ["a", "b", "c"] new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=fg_gradient) assert len(new_scene.frames) == 4 for i, frame in enumerate(new_scene.frames): assert frame.character_visual._fg_color_code == fg_gradient.spectrum[i].rgb_color def test_scene_apply_gradient_to_symbols_bg_gradient_only(character: EffectCharacter) -> None: """Ensure supplying only a background gradient still creates frames.""" new_scene = character.animation.new_scene(scene_id="test_scene") bg_gradient = Gradient(Color("#ffffff"), Color("#000000"), steps=3) symbols = ["a", "b", "c"] new_scene.apply_gradient_to_symbols(symbols, duration=1, bg_gradient=bg_gradient) assert len(new_scene.frames) == 4 for i, frame in enumerate(new_scene.frames): assert frame.character_visual._bg_color_code == bg_gradient.spectrum[i].rgb_color def test_scene_reset_scene(character: EffectCharacter) -> None: """Verify resetting a scene clears playback state and frame ticks.""" new_scene = character.animation.new_scene(scene_id="test_scene") new_scene.add_frame(symbol="a", duration=3) new_scene.add_frame(symbol="b", duration=3) for _ in range(4): new_scene.get_next_visual() new_scene.reset_scene() for sequence in new_scene.frames: assert sequence.ticks_elapsed == 0 assert not new_scene.played_frames 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") assert new_scene == new_scene2 def test_scene_equality_incorrect_type(character: EffectCharacter) -> None: """Ensure Scene equality checks guard against other object types.""" new_scene = character.animation.new_scene(scene_id="test_scene") assert new_scene != "test_scene" terminaltexteffects-release-0.15.0/tests/engine_tests/test_base_character.py000066400000000000000000000405231517776150200275020ustar00rootroot00000000000000"""Tests for the base_character module including the BaseCharacter class and the EventHandler class.""" from __future__ import annotations from typing import Any import pytest from terminaltexteffects.engine.base_character import EffectCharacter, EventHandler from terminaltexteffects.engine.animation import Scene from terminaltexteffects.engine.motion import Path from terminaltexteffects.utils.exceptions.base_character_exceptions import ( DuplicateEventRegistrationError, EventRegistrationCallerError, EventRegistrationTargetError, ) from terminaltexteffects.utils.geometry import Coord pytestmark = [pytest.mark.engine, pytest.mark.base_character, pytest.mark.smoke] @pytest.fixture def effectcharacter() -> EffectCharacter: """Fixture for creating an EffectCharacter instance.""" return EffectCharacter(0, "a", 1, 1) @pytest.fixture def eventhandler(effectcharacter: EffectCharacter) -> EventHandler: """Fixture for creating an EventHandler instance.""" return EventHandler(effectcharacter) def test_eventhandler_init(eventhandler: EventHandler, effectcharacter: EffectCharacter) -> None: """Test the initialization of EventHandler.""" assert eventhandler.character == effectcharacter assert eventhandler.registered_events == {} def test_eventhandler_callback_init(eventhandler: EventHandler) -> None: """Test the initialization of EventHandler.Callback.""" def func(*_: Any) -> None: pass cb = eventhandler.Callback(func, "a") assert cb.callback == func assert len(cb.args) == 1 @pytest.mark.parametrize( "event", [ EventHandler.Event.PATH_COMPLETE, EventHandler.Event.PATH_ACTIVATED, EventHandler.Event.PATH_HOLDING, EventHandler.Event.SCENE_ACTIVATED, EventHandler.Event.SCENE_COMPLETE, EventHandler.Event.SEGMENT_ENTERED, EventHandler.Event.SEGMENT_EXITED, ], ) def test_eventhandler_register_event_invalid_event_caller( event: EventHandler.Event, eventhandler: EventHandler, ) -> None: """Test registering an event with an invalid event caller.""" with pytest.raises(EventRegistrationCallerError): eventhandler.register_event(event, 1, EventHandler.Action.ACTIVATE_PATH, Path("a")) # type: ignore[call-overload] @pytest.mark.parametrize( "event_caller_action_target", [ (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.ACTIVATE_PATH, 1), (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.DEACTIVATE_PATH, 1), (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.ACTIVATE_SCENE, 1), (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.DEACTIVATE_SCENE, 1), (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.CALLBACK, 1), (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.SET_LAYER, ""), (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.SET_COORDINATE, 1), (EventHandler.Event.PATH_COMPLETE, Path("a"), EventHandler.Action.RESET_APPEARANCE, 1), ], ) def test_eventhandler_register_event_invalid_target( eventhandler: EventHandler, event_caller_action_target: tuple[EventHandler.Event, Path, EventHandler.Action, str], ) -> None: """Test registering an event with an invalid target.""" event, caller, action, target = event_caller_action_target with pytest.raises(EventRegistrationTargetError): eventhandler.register_event(event, caller, action, target) # type: ignore[call-overload] def test_eventhandler_register_event(eventhandler: EventHandler) -> None: """Test registering a valid event.""" p1 = Path("a") p2 = Path("b") eventhandler.register_event(EventHandler.Event.PATH_COMPLETE, p1, EventHandler.Action.ACTIVATE_PATH, p2) assert ( eventhandler.registered_events[(EventHandler.Event.PATH_COMPLETE, p1)][0][0] is EventHandler.Action.ACTIVATE_PATH ) assert eventhandler.registered_events[(EventHandler.Event.PATH_COMPLETE, p1)][0][1] is p2 def test_eventhandler_register_event_path_caller_with_id(eventhandler: EventHandler) -> None: """Test registering an event using a path ID caller.""" caller = eventhandler.character.motion.new_path(path_id="caller") target = eventhandler.character.motion.new_path(path_id="target") eventhandler.register_event( EventHandler.Event.PATH_COMPLETE, "caller", EventHandler.Action.ACTIVATE_PATH, target, ) assert eventhandler.registered_events[(EventHandler.Event.PATH_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_scene_caller_with_id(eventhandler: EventHandler) -> None: """Test registering an event using a scene ID caller.""" caller = eventhandler.character.animation.new_scene(scene_id="caller") target = eventhandler.character.animation.new_scene(scene_id="target") target.add_frame("a", duration=1) eventhandler.register_event( EventHandler.Event.SCENE_COMPLETE, "caller", EventHandler.Action.ACTIVATE_SCENE, target, ) assert eventhandler.registered_events[(EventHandler.Event.SCENE_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_activate_path_with_id(eventhandler: EventHandler) -> None: """Test registering ACTIVATE_PATH using a path ID target.""" caller = eventhandler.character.motion.new_path(path_id="caller") target = eventhandler.character.motion.new_path(path_id="target") eventhandler.register_event( EventHandler.Event.PATH_COMPLETE, caller, EventHandler.Action.ACTIVATE_PATH, "target", ) assert eventhandler.registered_events[(EventHandler.Event.PATH_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_activate_scene_with_id(eventhandler: EventHandler) -> None: """Test registering ACTIVATE_SCENE using a scene ID target.""" caller = eventhandler.character.animation.new_scene(scene_id="caller") target = eventhandler.character.animation.new_scene(scene_id="target") target.add_frame("a", duration=1) eventhandler.register_event( EventHandler.Event.SCENE_COMPLETE, caller, EventHandler.Action.ACTIVATE_SCENE, "target", ) assert eventhandler.registered_events[(EventHandler.Event.SCENE_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_deactivate_path_with_id(eventhandler: EventHandler) -> None: """Test registering DEACTIVATE_PATH using a path ID target.""" caller = eventhandler.character.motion.new_path(path_id="caller") target = eventhandler.character.motion.new_path(path_id="target") eventhandler.register_event( EventHandler.Event.PATH_COMPLETE, caller, EventHandler.Action.DEACTIVATE_PATH, "target", ) assert eventhandler.registered_events[(EventHandler.Event.PATH_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_deactivate_path_with_object(eventhandler: EventHandler) -> None: """Test registering DEACTIVATE_PATH using a path object target.""" caller = eventhandler.character.motion.new_path(path_id="caller") target = eventhandler.character.motion.new_path(path_id="target") eventhandler.register_event( EventHandler.Event.PATH_COMPLETE, caller, EventHandler.Action.DEACTIVATE_PATH, target, ) assert eventhandler.registered_events[(EventHandler.Event.PATH_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_deactivate_path_with_none_target(eventhandler: EventHandler) -> None: """Test registering DEACTIVATE_PATH without a target.""" caller = eventhandler.character.motion.new_path(path_id="caller") eventhandler.register_event( EventHandler.Event.PATH_COMPLETE, caller, EventHandler.Action.DEACTIVATE_PATH, ) assert eventhandler.registered_events[(EventHandler.Event.PATH_COMPLETE, caller)][0][1] is None def test_eventhandler_register_event_deactivate_scene_with_id(eventhandler: EventHandler) -> None: """Test registering DEACTIVATE_SCENE using a scene ID target.""" caller = eventhandler.character.animation.new_scene(scene_id="caller") target = eventhandler.character.animation.new_scene(scene_id="target") eventhandler.register_event( EventHandler.Event.SCENE_COMPLETE, caller, EventHandler.Action.DEACTIVATE_SCENE, "target", ) assert eventhandler.registered_events[(EventHandler.Event.SCENE_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_deactivate_scene_with_object(eventhandler: EventHandler) -> None: """Test registering DEACTIVATE_SCENE using a scene object target.""" caller = eventhandler.character.animation.new_scene(scene_id="caller") target = eventhandler.character.animation.new_scene(scene_id="target") eventhandler.register_event( EventHandler.Event.SCENE_COMPLETE, caller, EventHandler.Action.DEACTIVATE_SCENE, target, ) assert eventhandler.registered_events[(EventHandler.Event.SCENE_COMPLETE, caller)][0][1] is target def test_eventhandler_register_event_deactivate_scene_with_none_target(eventhandler: EventHandler) -> None: """Test registering DEACTIVATE_SCENE without a target.""" caller = eventhandler.character.animation.new_scene(scene_id="caller") eventhandler.register_event( EventHandler.Event.SCENE_COMPLETE, caller, EventHandler.Action.DEACTIVATE_SCENE, ) assert eventhandler.registered_events[(EventHandler.Event.SCENE_COMPLETE, caller)][0][1] is None def test_eventhandler_register_event_duplicate_raises_error(eventhandler: EventHandler) -> None: """Test that registering the same event-caller-action-target combination raises DuplicateEventRegistrationError.""" p1 = Path("a") p2 = Path("b") # Register the event once - should succeed eventhandler.register_event(EventHandler.Event.PATH_COMPLETE, p1, EventHandler.Action.ACTIVATE_PATH, p2) # Try to register the same combination again - should raise error with pytest.raises(DuplicateEventRegistrationError): eventhandler.register_event(EventHandler.Event.PATH_COMPLETE, p1, EventHandler.Action.ACTIVATE_PATH, p2) def test_eventhandler_handle_event(eventhandler: EventHandler) -> None: """Test handling an event.""" p1 = Path("a") p2 = Path("b") p2.new_waypoint(Coord(0, 0)) eventhandler.register_event(EventHandler.Event.PATH_COMPLETE, p1, EventHandler.Action.ACTIVATE_PATH, p2) eventhandler._handle_event(EventHandler.Event.PATH_COMPLETE, p1) assert eventhandler.character.motion.active_path == p2 def test_eventhandler_handle_event_deactivate_path_with_none_target(eventhandler: EventHandler) -> None: """Test handling DEACTIVATE_PATH with no target deactivates the active path.""" caller = eventhandler.character.motion.new_path(path_id="caller") active = eventhandler.character.motion.new_path(path_id="active") active.new_waypoint(Coord(0, 0)) eventhandler.character.motion.activate_path(active) eventhandler.register_event( EventHandler.Event.PATH_COMPLETE, caller, EventHandler.Action.DEACTIVATE_PATH, ) eventhandler._handle_event(EventHandler.Event.PATH_COMPLETE, caller) assert eventhandler.character.motion.active_path is None def test_eventhandler_handle_event_deactivate_path_with_id_target(eventhandler: EventHandler) -> None: """Test handling DEACTIVATE_PATH with a path ID target deactivates that path.""" caller = eventhandler.character.motion.new_path(path_id="caller") active = eventhandler.character.motion.new_path(path_id="active") active.new_waypoint(Coord(0, 0)) eventhandler.character.motion.activate_path(active) eventhandler.register_event( EventHandler.Event.PATH_COMPLETE, caller, EventHandler.Action.DEACTIVATE_PATH, "active", ) eventhandler._handle_event(EventHandler.Event.PATH_COMPLETE, caller) assert eventhandler.character.motion.active_path is None def test_eventhandler_handle_event_deactivate_scene_with_none_target(eventhandler: EventHandler) -> None: """Test handling DEACTIVATE_SCENE with no target deactivates the active scene.""" caller = eventhandler.character.animation.new_scene(scene_id="caller") active = eventhandler.character.animation.new_scene(scene_id="active") active.add_frame("a", duration=1) eventhandler.character.animation.activate_scene(active) eventhandler.register_event( EventHandler.Event.SCENE_COMPLETE, caller, EventHandler.Action.DEACTIVATE_SCENE, ) eventhandler._handle_event(EventHandler.Event.SCENE_COMPLETE, caller) assert eventhandler.character.animation.active_scene is None def test_eventhandler_handle_event_deactivate_scene_with_id_target(eventhandler: EventHandler) -> None: """Test handling DEACTIVATE_SCENE with a scene ID target deactivates that scene.""" caller = eventhandler.character.animation.new_scene(scene_id="caller") active = eventhandler.character.animation.new_scene(scene_id="active") active.add_frame("a", duration=1) eventhandler.character.animation.activate_scene(active) eventhandler.register_event( EventHandler.Event.SCENE_COMPLETE, caller, EventHandler.Action.DEACTIVATE_SCENE, "active", ) eventhandler._handle_event(EventHandler.Event.SCENE_COMPLETE, caller) assert eventhandler.character.animation.active_scene is None def test_effectcharacter_init(effectcharacter: EffectCharacter) -> None: """Test the initialization of EffectCharacter.""" assert effectcharacter.character_id == 0 assert effectcharacter._input_symbol == "a" assert effectcharacter._input_coord == Coord(1, 1) assert effectcharacter._input_ansi_sequences == {"fg_color": None, "bg_color": None} assert effectcharacter._is_visible is False assert effectcharacter.layer == 0 assert effectcharacter.is_fill_character is False def test_effectcharacter_repr(effectcharacter: EffectCharacter) -> None: """Test the __repr__ method of EffectCharacter.""" assert repr(effectcharacter) == "EffectCharacter(character_id=0, symbol='a', input_column=1, input_row=1)" def test_effectcharacter_hash_consistency(effectcharacter: EffectCharacter) -> None: """Test the consistency of the __hash__ method of EffectCharacter.""" assert hash(effectcharacter) == hash(effectcharacter) def test_effectcharacter_objects_have_same_hash(effectcharacter: EffectCharacter) -> None: """Test that two EffectCharacter objects with the same attributes have the same hash.""" effectcharacter2 = EffectCharacter(0, "a", 1, 1) assert hash(effectcharacter) == hash(effectcharacter2) def test_effectcharacter_properties(effectcharacter: EffectCharacter) -> None: """Test the properties of EffectCharacter.""" assert effectcharacter.input_symbol == "a" assert effectcharacter.input_coord == Coord(1, 1) assert effectcharacter.is_visible is False assert effectcharacter.character_id == 0 assert effectcharacter.is_active is False def test_effectcharacter_is_active(effectcharacter: EffectCharacter) -> None: """Test the is_active property of EffectCharacter.""" assert effectcharacter.is_active is False p = effectcharacter.motion.new_path() p.new_waypoint(Coord(0, 0)) effectcharacter.motion.activate_path(p) assert effectcharacter.is_active is True def test_effectcharacter_tick_no_paths_or_scenes(effectcharacter: EffectCharacter) -> None: """Test that tick does not fail when there are no paths or scenes.""" effectcharacter.tick() def test_effectcharacter_tick_scene_and_path(effectcharacter: EffectCharacter) -> None: """Test that tick updates both scene and path correctly.""" p = effectcharacter.motion.new_path() p.new_waypoint(Coord(3, 3)) effectcharacter.motion.activate_path(p) s = effectcharacter.animation.new_scene() s.add_frame("a", duration=2) effectcharacter.animation.activate_scene(s) effectcharacter.tick() assert effectcharacter.animation.active_scene.frames[0].ticks_elapsed == 1 # type: ignore[union-attr] assert effectcharacter.motion.active_path.current_step == 1 # type: ignore[union-attr] def test_effectcharacter_equal_invalid_type(effectcharacter: EffectCharacter) -> None: """Test that __eq__ returns NotImplemented when comparing with an invalid type.""" assert effectcharacter.__eq__("a") is NotImplemented terminaltexteffects-release-0.15.0/tests/engine_tests/test_base_config.py000066400000000000000000000057511517776150200270170ustar00rootroot00000000000000"""Unit tests for BaseConfig config-construction behavior.""" from __future__ import annotations import argparse from dataclasses import dataclass import pytest from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.utils import argutils pytestmark = [pytest.mark.engine, pytest.mark.smoke] @dataclass class ExampleConfig(BaseConfig): """Config model using only ArgSpec-backed fields.""" parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="example", help="example help", description="example description", epilog="example epilog", ) alpha: int = argutils.ArgSpec(name="--alpha", default=1, type=int) # pyright: ignore[reportAssignmentType] beta: str = argutils.ArgSpec(name="--beta", default="b") # pyright: ignore[reportAssignmentType] @dataclass class ExampleStrictConfig(BaseConfig): """Config model including a non-ArgSpec field for strict missing-field checks.""" parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="strict", help="strict help", description="strict description", epilog="strict epilog", ) alpha: int = argutils.ArgSpec(name="--alpha", default=10, type=int) # pyright: ignore[reportAssignmentType] gamma: int = 42 def test_build_config_none_uses_argspec_defaults() -> None: """Build config from declared ArgSpec defaults when parsed args are not provided.""" config: ExampleConfig = ExampleConfig._build_config(None) assert config.alpha == 1 assert config.beta == "b" def test_build_config_full_namespace_uses_namespace_values() -> None: """Use all provided namespace values when every field is present.""" parsed_args: argparse.Namespace = argparse.Namespace(alpha=7, beta="z") config: ExampleConfig = ExampleConfig._build_config(parsed_args) assert config.alpha == 7 assert config.beta == "z" def test_build_config_partial_namespace_falls_back_to_argspec_default() -> None: """Fallback to ArgSpec defaults for fields missing from a partial namespace.""" parsed_args: argparse.Namespace = argparse.Namespace(alpha=7) config: ExampleConfig = ExampleConfig._build_config(parsed_args) assert config.alpha == 7 assert config.beta == "b" def test_build_config_ignores_parser_spec_attribute_on_namespace() -> None: """Ignore parser metadata during config construction from namespace values.""" parsed_args: argparse.Namespace = argparse.Namespace(alpha=3, beta="y") config: ExampleConfig = ExampleConfig._build_config(parsed_args) assert config.alpha == 3 assert config.beta == "y" def test_build_config_missing_non_argspec_field_raises_attribute_error() -> None: """Raise a clear error when a non-ArgSpec field is absent in parsed args.""" parsed_args: argparse.Namespace = argparse.Namespace(alpha=12) with pytest.raises(AttributeError, match="Missing required config field 'gamma' for ExampleStrictConfig"): ExampleStrictConfig._build_config(parsed_args) terminaltexteffects-release-0.15.0/tests/engine_tests/test_motion.py000066400000000000000000000466071517776150200260720ustar00rootroot00000000000000"""Tests for the Path, Segment, Waypoint and Motion classes.""" import pytest from terminaltexteffects.engine.base_character import EffectCharacter, EventHandler from terminaltexteffects.engine.motion import Path, Segment, Waypoint from terminaltexteffects.utils import easing from terminaltexteffects.utils.exceptions import ( ActivateEmptyPathError, DuplicatePathIDError, DuplicateWaypointIDError, PathInvalidSpeedError, PathNotFoundError, WaypointNotFoundError, ) from terminaltexteffects.utils.geometry import Coord, find_length_of_bezier_curve, find_length_of_line pytestmark = [pytest.mark.engine, pytest.mark.motion, pytest.mark.smoke] @pytest.fixture def character() -> EffectCharacter: """Fixture for creating an EffectCharacter instance.""" return EffectCharacter(0, "a", 0, 0) @pytest.fixture def waypoint() -> Waypoint: """Fixture for creating a Waypoint instance.""" return Waypoint(waypoint_id="waypoint_0", coord=Coord(0, 0), bezier_control=(Coord(0, 10),)) def test_waypoint_init(waypoint: Waypoint) -> None: """Test the initialization of a Waypoint.""" assert waypoint.waypoint_id == "waypoint_0" assert waypoint.coord == Coord(0, 0) assert waypoint.bezier_control == (Coord(0, 10),) def test_waypoint_equal_waypoint(waypoint: Waypoint) -> None: """Test equality of waypoints with the same ID and coordinates.""" assert waypoint == Waypoint(waypoint_id="waypoint_0", coord=Coord(0, 0), bezier_control=(Coord(0, 10),)) def test_waypoint_equal_unqual_waypoint(waypoint: Waypoint) -> None: """Test inequality of waypoints with different IDs and coordinates.""" assert waypoint != Waypoint(waypoint_id="waypoint_1", coord=Coord(1, 0), bezier_control=(Coord(0, 10),)) def test_waypoint_equal_different_type(waypoint: Waypoint) -> None: """Test inequality of waypoint with a different type.""" assert waypoint != "waypoint_0" def test_segment_length_no_bezier() -> None: """Test segment length calculation without bezier control points.""" waypoint_0 = Waypoint(waypoint_id="waypoint_0", coord=Coord(0, 0)) waypoint_1 = Waypoint(waypoint_id="waypoint_1", coord=Coord(10, 0)) segment = Segment(waypoint_0, waypoint_1, find_length_of_line(waypoint_0.coord, waypoint_1.coord)) line_length = 10 assert segment.distance == line_length def test_segment_length_bezier() -> None: """Test segment length calculation with bezier control points.""" waypoint_0 = Waypoint(waypoint_id="waypoint_0", coord=Coord(0, 0), bezier_control=(Coord(5, 5),)) waypoint_1 = Waypoint(waypoint_id="waypoint_1", coord=Coord(10, 0), bezier_control=(Coord(10, 10),)) segment = Segment( waypoint_0, waypoint_1, find_length_of_bezier_curve(waypoint_0.coord, waypoint_0.bezier_control, waypoint_1.coord), # type: ignore[arg-type] ) bezier_length = 12.70820393249937 assert segment.distance == bezier_length def test_segment_is_hashable() -> None: """Test that a Segment instance is hashable.""" waypoint_0 = Waypoint(waypoint_id="waypoint_0", coord=Coord(0, 0)) waypoint_1 = Waypoint(waypoint_id="waypoint_1", coord=Coord(10, 0)) segment = Segment(waypoint_0, waypoint_1, find_length_of_line(waypoint_0.coord, waypoint_1.coord)) assert hash(segment) == hash((waypoint_0, waypoint_1)) def test_segment_equal_segment() -> None: """Test equality of segments with the same waypoints and distance.""" waypoint_0 = Waypoint(waypoint_id="waypoint_0", coord=Coord(0, 0)) waypoint_1 = Waypoint(waypoint_id="waypoint_1", coord=Coord(10, 0)) segment = Segment(waypoint_0, waypoint_1, find_length_of_line(waypoint_0.coord, waypoint_1.coord)) assert segment == Segment(waypoint_0, waypoint_1, find_length_of_line(waypoint_0.coord, waypoint_1.coord)) def test_segment_equal_incorrect_type() -> None: """Test inequality of segment with a different type.""" waypoint_0 = Waypoint(waypoint_id="waypoint_0", coord=Coord(0, 0)) waypoint_1 = Waypoint(waypoint_id="waypoint_1", coord=Coord(10, 0)) segment = Segment(waypoint_0, waypoint_1, find_length_of_line(waypoint_0.coord, waypoint_1.coord)) assert segment != "segment" def test_path_init() -> None: """Test the initialization of a Path.""" p = Path("path_0") assert p.path_id == "path_0" assert p.speed == 1 assert p.ease is None assert p.layer is None assert p.hold_time == 0 assert p.loop is False assert p.segments == [] assert p.waypoints == [] assert p.waypoint_lookup == {} assert p.total_distance == 0 assert p.current_step == 0 assert p.max_steps == 0 assert p.hold_time_remaining == 0 assert p.last_distance_reached == 0 assert p.origin_segment is None def test_path_init_invalid_speed() -> None: """Test initialization of a Path with invalid speed.""" with pytest.raises(PathInvalidSpeedError): Path("path_0", speed=-1) def test_path_new_waypoint_auto_id_generation() -> None: """Test auto ID generation for new waypoints. ID's should start at 0 and increment by 1 for each new waypoint. """ p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) assert p.waypoints[0].waypoint_id == "0" assert p.waypoints[1].waypoint_id == "1" def test_path_new_waypoint_auto_id_deleted_waypoints() -> None: """Test auto ID generation for new waypoints after deletion. ID's should start at 0 and increment by 1 for each new waypoint, even if waypoints have been deleted. """ # waypoint auto ID's start at 0 p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) p.new_waypoint(Coord(20, 0)) p.waypoints.pop(-1) new_waypoint = p.new_waypoint(Coord(30, 0)) assert new_waypoint.waypoint_id == "3" def test_path_new_waypoint_duplicate_waypoint_id() -> None: """Test that creating a waypoint with a duplicate ID raises an error.""" p = Path("p") p.new_waypoint(Coord(0, 0), waypoint_id="0") with pytest.raises(DuplicateWaypointIDError): p.new_waypoint(Coord(10, 0), waypoint_id="0") def test_path_new_waypoint_bezier_as_single_coord() -> None: """Test that a single coordinate can be used as a bezier control point.""" p = Path("p") p.new_waypoint(Coord(0, 0), bezier_control=Coord(0, 10)) assert p.waypoints[0].bezier_control == (Coord(0, 10),) def test_path_new_waypoint_bezier_as_tuple() -> None: """Test that a tuple can be used as bezier control points.""" p = Path("p") p.new_waypoint(Coord(0, 0), bezier_control=(Coord(0, 10),)) assert p.waypoints[0].bezier_control == (Coord(0, 10),) def test_path_new_waypoint_multiple_waypoints_with_bezier_segment() -> None: """Test multiple waypoints with bezier segments.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0), bezier_control=Coord(10, 10)) assert p.segments[0].distance == find_length_of_bezier_curve(Coord(0, 0), Coord(10, 10), Coord(10, 0)) def test_path_query_waypoint_valid_waypoint() -> None: """Test querying an existing waypoint ID in a path.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) assert p.query_waypoint("0") == p.waypoints[0] def test_path_query_waypoint_invalid_waypoint() -> None: """Test querying a non-existing waypoint ID in a path.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) with pytest.raises(WaypointNotFoundError): p.query_waypoint("2") def test_path_step_zero_distance(character: EffectCharacter) -> None: """Test stepping through a path with zero distance.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(0, 0)) assert p.step(character.event_handler) == Coord(0, 0) def test_path_step_single_segment(character: EffectCharacter) -> None: """Test stepping through a single segment path.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) current_point = p.step(character.event_handler) while current_point != Coord(10, 0): current_point = p.step(character.event_handler) def test_path_step_single_segment_eased(character: EffectCharacter) -> None: """Test stepping through a single segment path with easing.""" p = Path("p", ease=easing.in_out_sine) p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) current_point = p.step(character.event_handler) while current_point != Coord(10, 0): current_point = p.step(character.event_handler) def test_path_step_multiple_segments(character: EffectCharacter) -> None: """Test stepping through a path with multiple segments.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) p.new_waypoint(Coord(10, 10)) p.new_waypoint(Coord(0, 10)) current_point = p.step(character.event_handler) while current_point != Coord(0, 10): current_point = p.step(character.event_handler) def test_path_step_multiple_segments_eased(character: EffectCharacter) -> None: """Test stepping through a path with multiple segments and easing.""" p = Path("p", ease=easing.in_out_elastic) p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) p.new_waypoint(Coord(10, 10)) p.new_waypoint(Coord(0, 10)) current_point = p.step(character.event_handler) while current_point != Coord(0, 10): current_point = p.step(character.event_handler) def test_path_step_multiple_segments_zero_distance(character: EffectCharacter) -> None: """Test stepping through a path with multiple segments and zero distance.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(0, 0)) current_point = p.step(character.event_handler) while current_point != Coord(0, 0): current_point = p.step(character.event_handler) def test_path_step_multiple_segments_mutiple_bezier(character: EffectCharacter) -> None: """Test stepping through a path with multiple segments and multiple bezier control points.""" p = Path("p") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0), bezier_control=Coord(10, 10)) p.new_waypoint(Coord(10, 10), bezier_control=Coord(0, 10)) p.new_waypoint(Coord(0, 10), bezier_control=Coord(0, 0)) current_point = p.step(character.event_handler) while current_point != Coord(0, 10): current_point = p.step(character.event_handler) def test_path_equality() -> None: """Test equality of paths with the same waypoints.""" p1 = Path("p") p1.new_waypoint(Coord(0, 0)) p1.new_waypoint(Coord(10, 0)) p1.new_waypoint(Coord(10, 10)) p1.new_waypoint(Coord(0, 10)) p2 = Path("p") p2.new_waypoint(Coord(0, 0)) p2.new_waypoint(Coord(10, 0)) p2.new_waypoint(Coord(10, 10)) p2.new_waypoint(Coord(0, 10)) assert p1 == p2 def test_path_equality_invalid_type() -> None: """Test inequality of path with a different type.""" p = Path("p") assert p != "p" def test_motion_set_coordinate(character: EffectCharacter) -> None: """Test setting the coordinate of a character.""" character.motion.set_coordinate(Coord(10, 10)) assert character.motion.current_coord == Coord(10, 10) def test_motion_new_path_duplicate_path_id(character: EffectCharacter) -> None: """Test creating a new path with a duplicate ID raises an error.""" character.motion.new_path(path_id="0") with pytest.raises(DuplicatePathIDError): character.motion.new_path(path_id="0") def test_motion_new_path_auto_id_avoid_duplicate(character: EffectCharacter) -> None: """Test auto ID generation for new paths avoiding duplicates.""" character.motion.new_path(path_id="1") character.motion.new_path(path_id="2") character.motion.new_path(path_id="3") new_path = character.motion.new_path() assert new_path.path_id == "4" def test_motion_query_path_valid_path(character: EffectCharacter) -> None: """Test querying an existing path ID.""" character.motion.new_path(path_id="0") character.motion.new_path(path_id="1") assert character.motion.query_path("0").path_id == "0" def test_motion_query_path_invalid_path(character: EffectCharacter) -> None: """Test querying a non-existing path ID raises an error.""" character.motion.new_path(path_id="0") character.motion.new_path(path_id="1") with pytest.raises(PathNotFoundError): character.motion.query_path("2") def test_motion_movement_is_complete_no_active_paths(character: EffectCharacter) -> None: """Test checking if movement is complete with no active paths.""" assert character.motion.movement_is_complete() is True def test_motion_movement_is_complete_active_path_complete(character: EffectCharacter) -> None: """Test checking if movement is complete with an active path that is complete.""" p = character.motion.new_path(path_id="0") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) character.motion.activate_path(p) while character.motion.active_path: character.motion.move() assert character.motion.movement_is_complete() is True def test_motion_movement_is_complete_active_path_incomplete(character: EffectCharacter) -> None: """Test checking if movement is complete with an active path that is incomplete.""" p = character.motion.new_path(path_id="0") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) character.motion.activate_path(p) assert character.motion.movement_is_complete() is False def test_motion_chain_paths_single_path(character: EffectCharacter) -> None: """Test chaining a single path.""" p = character.motion.new_path(path_id="0") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) character.motion.chain_paths( [ p, ], ) def test_motion_chain_paths_multiple_paths(character: EffectCharacter) -> None: """Test chaining multiple paths.""" p1 = character.motion.new_path(path_id="0") p1.new_waypoint(Coord(0, 0)) p1.new_waypoint(Coord(10, 0)) p2 = character.motion.new_path(path_id="1") p2.new_waypoint(Coord(10, 0)) p2.new_waypoint(Coord(10, 10)) character.motion.chain_paths([p1, p2]) assert (EventHandler.Event.PATH_COMPLETE, p1) in character.event_handler.registered_events assert character.event_handler.registered_events[(EventHandler.Event.PATH_COMPLETE, p1)] == [ (EventHandler.Action.ACTIVATE_PATH, p2), ] def test_motion_chain_paths_multiple_paths_looping(character: EffectCharacter) -> None: """Test chaining multiple paths with looping.""" p1 = character.motion.new_path(path_id="0") p1.new_waypoint(Coord(0, 0)) p1.new_waypoint(Coord(10, 0)) p2 = character.motion.new_path(path_id="1") p2.new_waypoint(Coord(10, 0)) p2.new_waypoint(Coord(10, 10)) character.motion.chain_paths([p1, p2], loop=True) assert (EventHandler.Event.PATH_COMPLETE, p1) in character.event_handler.registered_events assert character.event_handler.registered_events[(EventHandler.Event.PATH_COMPLETE, p1)] == [ (EventHandler.Action.ACTIVATE_PATH, p2), ] assert (EventHandler.Event.PATH_COMPLETE, p2) in character.event_handler.registered_events assert character.event_handler.registered_events[(EventHandler.Event.PATH_COMPLETE, p2)] == [ (EventHandler.Action.ACTIVATE_PATH, p1), ] def test_motion_activate_path_first_waypoint_bezier(character: EffectCharacter) -> None: """Test activating a path with the first waypoint having a bezier control point.""" p = character.motion.new_path(path_id="0") p.new_waypoint(Coord(0, 0), bezier_control=Coord(0, 10)) p.new_waypoint(Coord(10, 0)) character.motion.activate_path(p) assert character.motion.active_path == p def test_motion_activate_path_by_id(character: EffectCharacter) -> None: """Test activating a path by path ID.""" p = character.motion.new_path(path_id="path_0") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) character.motion.activate_path("path_0") assert character.motion.active_path == p def test_motion_activate_path_no_waypoints(character: EffectCharacter) -> None: """Test activating a path with no waypoints raises an error.""" p = character.motion.new_path(path_id="0") with pytest.raises(ActivateEmptyPathError): character.motion.activate_path(p) def test_motion_active_path_with_layer(character: EffectCharacter) -> None: """Test activating a path with a layer.""" p = character.motion.new_path(path_id="0", layer=1) p.new_waypoint(Coord(0, 0), bezier_control=Coord(0, 10)) p.new_waypoint(Coord(10, 0)) character.motion.activate_path(p) assert character.motion.active_path == p assert character.layer == 1 def test_motion_activate_path_previously_deactivated(character: EffectCharacter) -> None: """Test reactivating a path that was previously deactivated.""" character.motion.set_coordinate(Coord(5, 5)) p = character.motion.new_path(path_id="0") p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) character.motion.activate_path(p) first_origin_distance = p.origin_segment.distance if p.origin_segment else 0 character.motion.deactivate_path(p) character.motion.set_coordinate(Coord(2, 2)) character.motion.activate_path(p) second_origin_distance = p.origin_segment.distance if p.origin_segment else 0 assert character.motion.active_path == p assert second_origin_distance < first_origin_distance def test_motion_deactivate_path_without_arg(character: EffectCharacter) -> None: """Test deactivating the current active path without providing a target.""" p = character.motion.new_path(path_id="0") p.new_waypoint(Coord(0, 0)) character.motion.activate_path(p) character.motion.deactivate_path() assert character.motion.active_path is None def test_motion_deactivate_path_by_object(character: EffectCharacter) -> None: """Test deactivating the active path by path object.""" p = character.motion.new_path(path_id="path_0") p.new_waypoint(Coord(0, 0)) character.motion.activate_path(p) character.motion.deactivate_path(p) assert character.motion.active_path is None def test_motion_deactivate_path_by_id(character: EffectCharacter) -> None: """Test deactivating the active path by path ID.""" p = character.motion.new_path(path_id="path_0") p.new_waypoint(Coord(0, 0)) character.motion.activate_path(p) character.motion.deactivate_path("path_0") assert character.motion.active_path is None def test_motion_move_no_active_path(character: EffectCharacter) -> None: """Test moving a character with no active path.""" assert character.motion.active_path is None character.motion.move() def test_motion_move_path_hold_time(character: EffectCharacter) -> None: """Test moving a character along a path with hold time.""" p = character.motion.new_path(path_id="0", hold_time=5) p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) character.motion.activate_path(p) character.motion.move() while character.motion.active_path: character.motion.move() assert character.motion.active_path is None def test_motion_move_path_looping(character: EffectCharacter) -> None: """Test moving a character along a looping path.""" p = character.motion.new_path(path_id="0", loop=True) p.new_waypoint(Coord(0, 0)) p.new_waypoint(Coord(10, 0)) p.new_waypoint(Coord(10, 10)) character.motion.activate_path(p) for _ in range(100): character.motion.move() terminaltexteffects-release-0.15.0/tests/engine_tests/test_terminal.py000066400000000000000000000603161517776150200263710ustar00rootroot00000000000000import shutil from typing import NoReturn import pytest from terminaltexteffects.engine.base_character import EffectCharacter from terminaltexteffects.engine.terminal import Canvas, Terminal, TerminalConfig from terminaltexteffects.utils.argutils import CharacterGroup, CharacterSort, ColorSort from terminaltexteffects.utils.exceptions import ( InvalidCharacterGroupError, InvalidCharacterSortError, InvalidColorSortError, ) from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.graphics import Color pytestmark = [pytest.mark.engine, pytest.mark.terminal, pytest.mark.smoke] def test_canvas_init_even() -> None: canvas = Canvas(10, 10) assert canvas.width == 10 assert canvas.height == 10 assert canvas.center_row == 5 assert canvas.center_column == 5 assert canvas.center == Coord(5, 5) def test_canvas_init_odd() -> None: canvas = Canvas(11, 11) assert canvas.width == 11 assert canvas.height == 11 assert canvas.center_row == 6 assert canvas.center_column == 6 assert canvas.center == Coord(6, 6) def test_canvas_single_col_row() -> None: canvas = Canvas(1, 1) assert canvas.width == 1 assert canvas.height == 1 assert canvas.center_row == 1 assert canvas.center_column == 1 assert canvas.center == Coord(1, 1) @pytest.mark.parametrize("anchor", ["n", "ne", "e", "se", "s", "sw", "w", "nw", "c"]) def test_canvas_anchor_text(anchor) -> None: c0 = EffectCharacter(0, symbol="a", input_column=1, input_row=1) c1 = EffectCharacter(1, symbol="b", input_column=2, input_row=1) canvas = Canvas(10, 10) chars = canvas._anchor_text([c0, c1], anchor=anchor) if anchor == "sw": assert chars[0].motion.current_coord == Coord(1, 1) elif anchor == "s": assert chars[0].motion.current_coord == Coord(5, 1) elif anchor == "se": assert chars[1].motion.current_coord == Coord(10, 1) elif anchor == "e": assert chars[1].motion.current_coord == Coord(10, 6) elif anchor == "ne": assert chars[1].motion.current_coord == Coord(10, 10) elif anchor == "n": assert chars[0].motion.current_coord == Coord(5, 10) elif anchor == "nw": assert chars[0].motion.current_coord == Coord(1, 10) elif anchor == "w": assert chars[0].motion.current_coord == Coord(1, 6) elif anchor == "c": assert chars[0].motion.current_coord == Coord(5, 6) assert chars[1].motion.current_coord == Coord(6, 6) def test_canvas_coord_is_in_canvas() -> None: canvas = Canvas(10, 10) assert canvas.coord_is_in_canvas(Coord(5, 5)) assert canvas.coord_is_in_canvas(Coord(1, 1)) assert canvas.coord_is_in_canvas(Coord(10, 10)) assert canvas.coord_is_in_canvas(Coord(1, 10)) assert canvas.coord_is_in_canvas(Coord(10, 1)) assert not canvas.coord_is_in_canvas(Coord(0, 0)) assert not canvas.coord_is_in_canvas(Coord(11, 11)) assert not canvas.coord_is_in_canvas(Coord(0, 5)) assert not canvas.coord_is_in_canvas(Coord(5, 0)) assert not canvas.coord_is_in_canvas(Coord(11, 5)) assert not canvas.coord_is_in_canvas(Coord(5, 11)) assert not canvas.coord_is_in_canvas(Coord(0, 0)) assert not canvas.coord_is_in_canvas(Coord(11, 11)) assert not canvas.coord_is_in_canvas(Coord(0, 5)) assert not canvas.coord_is_in_canvas(Coord(5, 0)) assert not canvas.coord_is_in_canvas(Coord(11, 5)) assert not canvas.coord_is_in_canvas(Coord(5, 11)) def test_canvas_random_column() -> None: canvas = Canvas(10, 10) random_column = canvas.random_column() assert 1 <= random_column <= 10 def test_canvas_random_row() -> None: canvas = Canvas(10, 10) random_row = canvas.random_row() assert 1 <= random_row <= 10 def test_canvas_random_coord_inside_canvas() -> None: canvas = Canvas(10, 10) random_coord = canvas.random_coord() assert 1 <= random_coord.column <= 10 assert 1 <= random_coord.row <= 10 def test_canvas_random_coord_outside_canvas() -> None: canvas = Canvas(10, 10) random_coord = canvas.random_coord(outside_scope=True) if 1 <= random_coord.column <= 10: assert random_coord.row in {0, 11} elif 1 <= random_coord.row <= 10: assert random_coord.column in {0, 11} def test_terminal_init_no_config() -> None: terminal = Terminal("test") assert terminal.config == TerminalConfig._build_config() def test_terminal_init_with_config() -> None: config = TerminalConfig._build_config() config.frame_rate = 10 terminal = Terminal("test", config=config) assert terminal.config.frame_rate == 10 def test_terminal_init_no_input() -> None: terminal = Terminal(input_data="") assert len(terminal.get_characters()) == 8 def test_terminal_init_ignore_terminal_dimensions() -> None: config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True terminal = Terminal("test", config=config) terminal._terminal_height = 1 terminal._terminal_width = 4 def test_terminal_preprocess_input_data_existing_color() -> None: # test ANSI color string # char pos - symbol - fg - bg # (1,1) - a - 255,0,0 - 0,0,0 # (2,1) - b - 0,255,0 - 0,0,0 # (3,1) - c - 0,255,0 - 0,0,255 # (4,1) - d - 196 - 0,0,0 # (5,1) - e - 196 - 106 # (6,1) - f - 196 - 68 # (7,1) - g - 0,255,0 - 68 # \x1b[38;2;255;0;0ma # \x1b[38;2;0;255;0mb # \x1b[48;2;0;0;255mc # \x1b[0m # \033[38;5;196md # \033[48;5;106me # \033[48;5;68mf # \x1b[38;2;;255;mg # \x1b[0m config = TerminalConfig._build_config() config.existing_color_handling = "always" terminal = Terminal(input_data="", config=config) input_data = "\x1b[38;2;255;0;0ma\x1b[38;2;0;255;0mb\x1b[48;2;0;0;255mc\x1b[0m\033[38;5;196md\033[48;5;106me\033[48;5;68mf\x1b[38;2;;255;mg\x1b[0m" chars = terminal._preprocess_input_data(input_data)[0] assert len(chars) == 7 assert chars[0].animation.input_bg_color is None assert chars[0].animation.input_fg_color == Color("#FF0000") assert chars[1].animation.input_bg_color is None assert chars[1].animation.input_fg_color == Color("#00FF00") assert chars[2].animation.input_bg_color == Color("#0000FF") assert chars[2].animation.input_fg_color == Color("#00FF00") assert chars[3].animation.input_bg_color is None assert chars[3].animation.input_fg_color == Color(196) assert chars[4].animation.input_bg_color == Color(106) assert chars[4].animation.input_fg_color == Color(196) assert chars[5].animation.input_bg_color == Color(68) assert chars[5].animation.input_fg_color == Color(196) assert chars[6].animation.input_bg_color == Color(68) assert chars[6].animation.input_fg_color == Color("#00FF00") chars = terminal._preprocess_input_data(input_data)[0] test_char_colors = chars[0].animation.current_character_visual.colors assert test_char_colors is not None assert test_char_colors.bg_color is None assert test_char_colors.fg_color == Color("#FF0000") @pytest.mark.parametrize("anchor", ["n", "ne", "e", "se", "s", "sw", "w", "nw", "c"]) def test_terminal_calc_canvas_offsets(anchor, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Terminal, "_get_terminal_dimensions", lambda _: (10, 10)) config = TerminalConfig._build_config() config.anchor_canvas = anchor terminal = Terminal(input_data="test", config=config) assert terminal._terminal_height == 10 assert terminal._terminal_width == 10 column_offset, row_offset = terminal._calc_canvas_offsets() if anchor in ["s", "n", "c"]: assert column_offset == 3 elif anchor in ["se", "e", "ne"]: assert column_offset == 6 else: assert column_offset == 0 if anchor in ["e", "w", "c"]: assert row_offset == 5 elif anchor in ["nw", "n", "ne"]: assert row_offset == 9 else: assert row_offset == 0 def test_terminal_get_canvas_dimensions_exact(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Terminal, "_get_terminal_dimensions", lambda _: (10, 10)) config = TerminalConfig._build_config() config.canvas_width = 5 config.canvas_height = 5 terminal = Terminal(input_data="test", config=config) assert terminal.canvas.width == 5 assert terminal.canvas.height == 5 def test_terminal_get_canvas_dimensions_match_terminal(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Terminal, "_get_terminal_dimensions", lambda _: (10, 10)) config = TerminalConfig._build_config() config.canvas_width = 0 config.canvas_height = 0 terminal = Terminal(input_data="test", config=config) assert terminal.canvas.width == 10 assert terminal.canvas.height == 10 def test_terminal_get_canvas_dimensions_match_input_text(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Terminal, "_get_terminal_dimensions", lambda _: (10, 10)) config = TerminalConfig._build_config() config.canvas_width = -1 config.canvas_height = -1 terminal = Terminal(input_data="test", config=config) assert terminal.canvas.width == 4 assert terminal.canvas.height == 1 def test_terminal_get_canvas_dimensions_match_input_text_wrap_text(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Terminal, "_get_terminal_dimensions", lambda _: (10, 10)) config = TerminalConfig._build_config() config.canvas_width = -1 config.canvas_height = -1 config.wrap_text = True terminal = Terminal(input_data="testtesttest", config=config) assert terminal.canvas.width == 10 assert terminal.canvas.height == 2 def test_get_terminal_dimensions_raise_oserror(monkeypatch: pytest.MonkeyPatch) -> None: def raise_oserror() -> NoReturn: raise OSError config = TerminalConfig._build_config() terminal = Terminal(input_data="test", config=config) old_f = shutil.get_terminal_size monkeypatch.setattr(shutil, "get_terminal_size", raise_oserror) w, h = terminal._get_terminal_dimensions() monkeypatch.setattr(shutil, "get_terminal_size", old_f) # unpatch to avoid side effects in pytest output assert w == 80 assert h == 24 def test_terminal_get_piped_input_is_tty(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("sys.stdin.isatty", lambda: True) assert Terminal.get_piped_input() == "" def test_terminal_get_piped_input_is_not_tty(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("sys.stdin.isatty", lambda: False) monkeypatch.setattr("sys.stdin.read", lambda: "test") assert Terminal.get_piped_input() == "test" def test_terminal_wrap_lines() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="testtesttest", config=config) lines = terminal._wrap_lines(terminal._preprocessed_character_lines, 4) assert len(lines) == 3 def test_terminal_make_inner_fill_characters() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="test test test", config=config) assert len(terminal._inner_fill_characters) == 2 def test_terminal_make_outer_fill_characters() -> None: config = TerminalConfig._build_config() config.canvas_width = 16 terminal = Terminal(input_data="test test test", config=config) assert len(terminal._outer_fill_characters) == 2 def test_terminal_add_character() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="test", config=config) terminal.add_character("a", Coord(0, 0)) assert len(terminal.get_characters(added_chars=True)) == 5 assert len(terminal._added_characters) == 1 assert terminal._added_characters[0].input_symbol == "a" def test_terminal_input_character_uses_input_preexisting_colors() -> None: config = TerminalConfig._build_config() config.existing_color_handling = "always" terminal = Terminal(input_data="test", config=config) assert terminal.get_characters()[0].uses_input_preexisting_colors is True def test_terminal_fill_character_does_not_use_input_preexisting_colors() -> None: config = TerminalConfig._build_config() config.canvas_width = 6 config.canvas_height = 2 config.existing_color_handling = "always" terminal = Terminal(input_data="abcd\nef gh", config=config) assert terminal._inner_fill_characters[0].uses_input_preexisting_colors is False def test_terminal_added_character_does_not_use_input_preexisting_colors() -> None: config = TerminalConfig._build_config() config.existing_color_handling = "always" terminal = Terminal(input_data="test", config=config) helper = terminal.add_character("a", Coord(0, 0)) assert helper.uses_input_preexisting_colors is False @pytest.mark.parametrize( "sort", [ColorSort.LEAST_TO_MOST, ColorSort.MOST_TO_LEAST, ColorSort.RANDOM], ) def test_terminal_get_input_colors(sort) -> None: config = TerminalConfig._build_config() input_data = "\x1b[38;2;255;0;0maaaaaaa\x1b[38;2;0;255;0mb\x1b[48;2;0;0;255mcccc" terminal = Terminal(input_data=input_data, config=config) colors = terminal.get_input_colors(sort=sort) if sort == ColorSort.MOST_TO_LEAST: assert colors[0] == Color("#FF0000") elif sort == ColorSort.LEAST_TO_MOST: assert colors[0] == Color("#0000FF") else: assert len(colors) == 3 def test_terminal_get_input_colors_no_colors() -> None: config = TerminalConfig._build_config() input_data = "test" terminal = Terminal(input_data=input_data, config=config) colors = terminal.get_input_colors() assert len(colors) == 0 def test_terminal_get_input_colors_invalid_sort() -> None: config = TerminalConfig._build_config() input_data = "\x1b[38;2;255;0;0maaaaaaa\x1b[38;2;0;255;0mb\x1b[48;2;0;0;255mcccc" terminal = Terminal(input_data=input_data, config=config) with pytest.raises(InvalidColorSortError): terminal.get_input_colors(sort="invalid") # type: ignore[arg-type] # testing invalid sort @pytest.mark.parametrize("input_chars", [True, False]) @pytest.mark.parametrize("inner_fill_chars", [True, False]) @pytest.mark.parametrize("outer_fill_chars", [True, False]) @pytest.mark.parametrize("added_chars", [True, False]) def test_terminal_get_characters(input_chars, inner_fill_chars, outer_fill_chars, added_chars) -> None: config = TerminalConfig._build_config() config.canvas_width = 6 config.canvas_height = 2 terminal = Terminal(input_data="abcd\nef gh", config=config) terminal.add_character("a", Coord(0, 0)) chars = terminal.get_characters( input_chars=input_chars, inner_fill_chars=inner_fill_chars, outer_fill_chars=outer_fill_chars, added_chars=added_chars, ) expected_chars = 0 if input_chars: expected_chars += 8 if inner_fill_chars: expected_chars += 2 if outer_fill_chars: expected_chars += 2 if added_chars: expected_chars += 1 assert len(chars) == expected_chars @pytest.mark.parametrize( "sort", [ CharacterSort.BOTTOM_TO_TOP_LEFT_TO_RIGHT, CharacterSort.OUTSIDE_ROW_TO_MIDDLE, CharacterSort.BOTTOM_TO_TOP_RIGHT_TO_LEFT, CharacterSort.MIDDLE_ROW_TO_OUTSIDE, CharacterSort.TOP_TO_BOTTOM_LEFT_TO_RIGHT, CharacterSort.TOP_TO_BOTTOM_RIGHT_TO_LEFT, CharacterSort.RANDOM, ], ) def test_terminal_get_characters_with_character_sort(sort) -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcde\nfghij\nklmno", config=config) chars = terminal.get_characters(sort=sort) if sort == CharacterSort.BOTTOM_TO_TOP_LEFT_TO_RIGHT: assert chars[0].input_symbol == "k" assert chars[-1].input_symbol == "e" elif sort == CharacterSort.OUTSIDE_ROW_TO_MIDDLE: assert chars[0].input_symbol == "a" assert chars[-1].input_symbol == "h" elif sort == CharacterSort.BOTTOM_TO_TOP_RIGHT_TO_LEFT: assert chars[0].input_symbol == "o" assert chars[-1].input_symbol == "a" elif sort == CharacterSort.MIDDLE_ROW_TO_OUTSIDE: assert chars[0].input_symbol == "h" assert chars[-1].input_symbol == "a" elif sort == CharacterSort.TOP_TO_BOTTOM_LEFT_TO_RIGHT: assert chars[0].input_symbol == "a" assert chars[-1].input_symbol == "o" elif sort == CharacterSort.TOP_TO_BOTTOM_RIGHT_TO_LEFT: assert chars[0].input_symbol == "e" assert chars[-1].input_symbol == "k" else: assert len(chars) == 15 def test_terminal_get_characters_invalid_character_sort() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcde\nfghij\nklmno", config=config) with pytest.raises(InvalidCharacterSortError): terminal.get_characters(sort="invalid") # type: ignore[arg-type] # testing invalid sort @pytest.mark.parametrize("input_chars", [True, False]) @pytest.mark.parametrize("inner_fill_chars", [True, False]) @pytest.mark.parametrize("outer_fill_chars", [True, False]) @pytest.mark.parametrize("added_chars", [True, False]) def test_terminal_get_characters_grouped(input_chars, inner_fill_chars, outer_fill_chars, added_chars) -> None: config = TerminalConfig._build_config() config.canvas_width = 7 terminal = Terminal(input_data="abcde\nfg hij\nklmno", config=config) terminal.add_character("a", Coord(0, 0)) chars = terminal.get_characters_grouped( input_chars=input_chars, inner_fill_chars=inner_fill_chars, outer_fill_chars=outer_fill_chars, added_chars=added_chars, ) expected_string = "" for group in chars: expected_string += "".join([char.input_symbol for char in group]) expected_string_length = 0 if input_chars: expected_string_length += 15 if inner_fill_chars: expected_string_length += 3 if outer_fill_chars: expected_string_length += 3 if added_chars: expected_string_length += 1 assert len(expected_string) == expected_string_length @pytest.mark.parametrize( "grouping", [ CharacterGroup.CENTER_TO_OUTSIDE, CharacterGroup.COLUMN_LEFT_TO_RIGHT, CharacterGroup.COLUMN_RIGHT_TO_LEFT, CharacterGroup.ROW_TOP_TO_BOTTOM, CharacterGroup.ROW_BOTTOM_TO_TOP, CharacterGroup.OUTSIDE_TO_CENTER, CharacterGroup.DIAGONAL_TOP_RIGHT_TO_BOTTOM_LEFT, CharacterGroup.DIAGONAL_TOP_LEFT_TO_BOTTOM_RIGHT, CharacterGroup.DIAGONAL_BOTTOM_RIGHT_TO_TOP_LEFT, CharacterGroup.DIAGONAL_BOTTOM_LEFT_TO_TOP_RIGHT, ], ) def test_terminal_get_characters_grouped_with_grouping(grouping) -> None: # test data: # abcde # fghij # klmno config = TerminalConfig._build_config() terminal = Terminal(input_data="abcde\nfghij\nklmno", config=config) terminal.add_character("a", Coord(0, 0)) chars = terminal.get_characters_grouped(grouping=grouping) if grouping == CharacterGroup.CENTER_TO_OUTSIDE: assert chars[0][0].input_symbol == "h" assert chars[-1][-1].input_symbol == "e" elif grouping == CharacterGroup.COLUMN_LEFT_TO_RIGHT: assert chars[0][0].input_symbol == "k" assert chars[-1][-1].input_symbol == "e" elif grouping == CharacterGroup.COLUMN_RIGHT_TO_LEFT: assert chars[0][0].input_symbol == "o" assert chars[-1][-1].input_symbol == "a" elif grouping == CharacterGroup.ROW_TOP_TO_BOTTOM: assert chars[0][0].input_symbol == "a" assert chars[-1][-1].input_symbol == "o" elif grouping == CharacterGroup.ROW_BOTTOM_TO_TOP: assert chars[0][0].input_symbol == "k" assert chars[-1][-1].input_symbol == "e" elif grouping == CharacterGroup.OUTSIDE_TO_CENTER: assert chars[0][0].input_symbol == "k" assert chars[-1][-1].input_symbol == "h" elif grouping == CharacterGroup.DIAGONAL_TOP_RIGHT_TO_BOTTOM_LEFT: assert chars[0][0].input_symbol == "e" assert chars[-1][-1].input_symbol == "k" elif grouping == CharacterGroup.DIAGONAL_TOP_LEFT_TO_BOTTOM_RIGHT: assert chars[0][0].input_symbol == "a" assert chars[-1][-1].input_symbol == "o" elif grouping == CharacterGroup.DIAGONAL_BOTTOM_RIGHT_TO_TOP_LEFT: assert chars[0][0].input_symbol == "o" assert chars[-1][-1].input_symbol == "a" elif grouping == CharacterGroup.DIAGONAL_BOTTOM_LEFT_TO_TOP_RIGHT: assert chars[0][0].input_symbol == "k" assert chars[-1][-1].input_symbol == "e" def test_terminal_get_characters_grouped_invalid_grouping() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcde\nfghij\nklmno", config=config) with pytest.raises(InvalidCharacterGroupError): terminal.get_characters_grouped(grouping="invalid") # type: ignore[arg-type] # testing invalid group def test_terminal_get_character_by_input_coord_valid() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd", config=config) char = terminal.get_character_by_input_coord(Coord(1, 1)) assert char is not None assert char.input_symbol == "a" def test_terminal_get_character_by_input_coord_invalid() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd", config=config) char = terminal.get_character_by_input_coord(Coord(1, 2)) assert char is None @pytest.mark.parametrize("visiblity", [True, False]) def test_terminal_set_character_visibility(visiblity) -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd", config=config) assert len(terminal._visible_characters) == 0 c = terminal.get_character_by_input_coord(Coord(1, 1)) terminal.set_character_visibility(c, visiblity) # type: ignore[arg-type] if visiblity: assert len(terminal._visible_characters) == 1 else: assert len(terminal._visible_characters) == 0 def test_terminal_get_formatted_output_string() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd", config=config) output_string = terminal.get_formatted_output_string() assert output_string == " " terminal.set_character_visibility(terminal.get_character_by_input_coord(Coord(1, 1)), is_visible=True) # type: ignore[arg-type] output_string = terminal.get_formatted_output_string() assert output_string == "a " def test_terminal_update_terminal_state() -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd", config=config) terminal._update_terminal_state() assert terminal.terminal_state == [" "] terminal.set_character_visibility(terminal.get_character_by_input_coord(Coord(1, 1)), is_visible=True) # type: ignore[arg-type] terminal._update_terminal_state() assert terminal.terminal_state == ["a "] def test_terminal_prep_canvas(capsys) -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd\nefgh\nijkl", config=config) terminal.prep_canvas() captured = capsys.readouterr() assert captured.out == "\x1b[?25l \n \n \n\x1b7" def test_terminal_restore_cursor(capsys) -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd\nefgh\nijkl", config=config) terminal.restore_cursor() captured = capsys.readouterr() assert captured.out == "\x1b[?25h\n" def test_terminal_restore_cursor_end_symbol(capsys) -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd\nefgh\nijkl", config=config) terminal.restore_cursor(end_symbol="test") captured = capsys.readouterr() assert captured.out == "\x1b[?25htest" def test_terminal_restore_cursor_end_symbol_no_eol(capsys) -> None: config = TerminalConfig._build_config() config.no_eol = True terminal = Terminal(input_data="abcd\nefgh\nijkl", config=config) terminal.restore_cursor() captured = capsys.readouterr() assert captured.out == "\x1b[?25h" def test_terminal_print(capsys) -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd\nefgh\nijkl", config=config) terminal.print("abcd\nefgh\nijkl") captured = capsys.readouterr() assert captured.out == "\x1b8\x1b7\x1b[3Aabcd\nefgh\nijkl" def test_terminal_move_cursor_to_top(capsys) -> None: config = TerminalConfig._build_config() terminal = Terminal(input_data="abcd\nefgh\nijkl", config=config) terminal.move_cursor_to_top() captured = capsys.readouterr() assert captured.out == "\x1b8\x1b7\x1b[3A" terminaltexteffects-release-0.15.0/tests/engine_tests/test_terminal_ansi_sequences.py000066400000000000000000000226261517776150200314600ustar00rootroot00000000000000"""Tests for terminal input ANSI/control sequence handling.""" from __future__ import annotations from typing import Literal import pytest from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.exceptions import UnsupportedAnsiSequenceError from terminaltexteffects.utils.graphics import Color pytestmark = [pytest.mark.engine, pytest.mark.terminal, pytest.mark.smoke] def _make_terminal( input_data: str, *, existing_color_handling: Literal["always", "dynamic", "ignore"] = "ignore", ) -> Terminal: """Build a terminal that keeps the parsed input dimensions.""" config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True config.existing_color_handling = existing_color_handling return Terminal(input_data=input_data, config=config) def _line_symbols(terminal: Terminal) -> list[str]: """Return preprocessed lines as plain text.""" return ["".join(character.input_symbol for character in line) for line in terminal._preprocessed_character_lines] @pytest.mark.parametrize( "input_data", [ "abc\x1b[2Jdef", "abc\x1b[?1049hdef", "abc\x1b]0;title\x07def", "abc\x1b7def", ], ) def test_terminal_rejects_unsupported_ansi_sequences(input_data: str) -> None: """Unsupported terminal control sequences should fail before character setup.""" config = TerminalConfig._build_config() with pytest.raises(UnsupportedAnsiSequenceError): Terminal(input_data=input_data, config=config) @pytest.mark.parametrize("sequence", ["\x1b[?25l", "\x1b[?25h", "\x1b[?7l", "\x1b[?7h"]) def test_terminal_ignores_neofetch_private_mode_sequences(sequence: str) -> None: """Known neofetch DEC private mode toggles should be accepted as no-op input state.""" terminal = _make_terminal(f"a{sequence}b") assert _line_symbols(terminal) == ["ab"] def test_terminal_unsupported_private_mode_error_reports_complete_sequence() -> None: """Unsupported private mode errors should include the complete CSI sequence.""" with pytest.raises(UnsupportedAnsiSequenceError) as exc_info: _make_terminal("a\x1b[?1049hb") assert "\\x1b[?1049h" in str(exc_info.value) def test_terminal_cursor_horizontal_absolute_overwrites_existing_cells() -> None: """CSI G should move to an absolute column and allow later text to overwrite.""" terminal = _make_terminal("abc\x1b[1GX") assert _line_symbols(terminal) == ["Xbc"] def test_terminal_cursor_sequences_move_cursor_on_virtual_screen() -> None: """Common fetch-style cursor sequences should move through the virtual screen.""" input_data = "A\x1b[3CB\x1b[2DX\x1b[2BY\x1b[1AZ\x1b[3EJ\x1b[2FK\x1b[5Gl\x1b[2;3Hm\x1b[4;2fn" terminal = _make_terminal(input_data) assert _line_symbols(terminal) == [ "A XB", " m Z", "K l", " n", "J", ] def test_terminal_cursor_movement_clamps_before_origin() -> None: """Cursor movement before row or column zero should clamp to the virtual origin.""" terminal = _make_terminal("ab\x1b[10D\x1b[10AX") assert _line_symbols(terminal) == ["Xb"] def test_terminal_fetch_style_layout_places_text_next_to_logo() -> None: """fastfetch-style cursor movement should produce a side-by-side text grid.""" input_data = "aa\nbb\ncc\x1b[1G\x1b[2A\x1b[5Ctitle\n\x1b[5Cinfo" terminal = _make_terminal(input_data) assert _line_symbols(terminal) == [ "aa title", "bb info", "cc", ] def test_terminal_allows_supported_color_sequences() -> None: """Supported 8-bit, 24-bit, and reset SGR color sequences should still parse.""" input_data = "\x1b[38;2;255;0;0ma\x1b[48;5;106mb\x1b[0mc" terminal = _make_terminal(input_data) assert [character.input_symbol for character in terminal.get_characters()] == ["a", "b", "c"] @pytest.mark.parametrize( ("sequence", "expected_fg", "expected_bg"), [ ("\x1b[30ma", Color(0), None), ("\x1b[37ma", Color(7), None), ("\x1b[90ma", Color(8), None), ("\x1b[97ma", Color(15), None), ("\x1b[40ma", None, Color(0)), ("\x1b[47ma", None, Color(7)), ("\x1b[100ma", None, Color(8)), ("\x1b[107ma", None, Color(15)), ("\x1b[1;31;44ma", Color(9), Color(4)), ], ) def test_terminal_sgr_3_and_4_bit_colors( sequence: str, expected_fg: Color | None, expected_bg: Color | None, ) -> None: """3/4-bit SGR color parameters should map to xterm color indexes.""" terminal = _make_terminal(sequence) character = terminal._preprocessed_character_lines[0][0] assert character.animation.input_fg_color == expected_fg assert character.animation.input_bg_color == expected_bg @pytest.mark.parametrize( ("input_data", "expected_fg", "expected_bold"), [ ("\x1b[32ma", Color(2), False), ("\x1b[1m\x1b[32ma", Color(10), True), ("\x1b[32m\x1b[1ma", Color(10), True), ("\x1b[1m\x1b[32ma\x1b[22mb", Color(2), False), ("\x1b[1m\x1b[32ma\x1b[0mb", None, False), ], ) def test_terminal_sgr_bold_brightens_standard_foreground_colors( input_data: str, expected_fg: Color | None, expected_bold: Literal[True, False], ) -> None: """Bold standard foreground SGR colors should resolve to bright xterm colors.""" terminal = _make_terminal(input_data) character = terminal._preprocessed_character_lines[0][-1] assert character.animation.input_fg_color == expected_fg assert character.animation.input_bold is expected_bold @pytest.mark.parametrize( ("input_data", "expected_fg", "expected_bg"), [ ("\x1b[31;44ma\x1b[mb", None, None), ("\x1b[31;44ma\x1b[0mb", None, None), ("\x1b[31;44ma\x1b[39mb", None, Color(4)), ("\x1b[31;44ma\x1b[49mb", Color(1), None), ], ) def test_terminal_sgr_reset_sequences( input_data: str, expected_fg: Color | None, expected_bg: Color | None, ) -> None: """SGR reset parameters should clear the expected input color channels.""" terminal = _make_terminal(input_data) character = terminal._preprocessed_character_lines[0][1] assert character.animation.input_fg_color == expected_fg assert character.animation.input_bg_color == expected_bg def test_terminal_fastfetch_style_output_with_swatch_background_colors() -> None: """A fastfetch-shaped sequence mix should parse layout and colored swatch spaces.""" input_data = ( " .'\n" "MMM\x1b[1G\x1b[1A\x1b[5Cuser@host\n" "\x1b[5COS: Test\n" "\x1b[5C\x1b[40m \x1b[41m \x1b[m\n" "\x1b[5C\x1b[100m \x1b[101m \x1b[m" ) terminal = _make_terminal(input_data, existing_color_handling="always") assert _line_symbols(terminal) == [ " .' user@host", "MMM OS: Test", " ", " ", ] swatch_row = terminal._preprocessed_character_lines[2] assert [character.animation.input_bg_color for character in swatch_row[5:8]] == [Color(0), Color(0), Color(0)] assert [character.animation.input_bg_color for character in swatch_row[8:]] == [Color(1), Color(1), Color(1)] bright_swatch_row = terminal._preprocessed_character_lines[3] assert [character.animation.input_bg_color for character in bright_swatch_row[5:8]] == [ Color(8), Color(8), Color(8), ] assert [character.animation.input_bg_color for character in bright_swatch_row[8:]] == [ Color(9), Color(9), Color(9), ] def test_terminal_neofetch_style_output_with_private_modes_and_swatch_colors() -> None: """A neofetch-shaped sequence mix should ignore private modes and preserve layout/colors.""" input_data = ( "\x1b[?25l\x1b[?7l\x1b[0m\x1b[32m\x1b[1mL1\n" "\x1b[33mL2\n\x1b[0m" "\x1b[1A\x1b[9999999D\x1b[5C\x1b[1m\x1b[32mhost\x1b[0m\n" "\x1b[5C\x1b[30m\x1b[40m \x1b[31m\x1b[41m \x1b[m\n" "\x1b[5C\x1b[38;5;8m\x1b[48;5;8m \x1b[38;5;9m\x1b[48;5;9m \x1b[m" "\x1b[?25h\x1b[?7h" ) terminal = _make_terminal(input_data, existing_color_handling="always") assert _line_symbols(terminal) == [ "L1", "L2 host", " ", " ", ] assert terminal._preprocessed_character_lines[0][0].animation.input_fg_color == Color(10) assert terminal._preprocessed_character_lines[0][0].animation.input_bold is True assert terminal._preprocessed_character_lines[1][0].animation.input_fg_color == Color(11) assert terminal._preprocessed_character_lines[1][0].animation.input_bold is True assert terminal._preprocessed_character_lines[1][5].animation.input_fg_color == Color(10) assert terminal._preprocessed_character_lines[1][5].animation.input_bold is True standard_swatch_row = terminal._preprocessed_character_lines[2] assert [character.animation.input_bg_color for character in standard_swatch_row[5:8]] == [ Color(0), Color(0), Color(0), ] assert [character.animation.input_bg_color for character in standard_swatch_row[8:]] == [ Color(1), Color(1), Color(1), ] bright_swatch_row = terminal._preprocessed_character_lines[3] assert [character.animation.input_bg_color for character in bright_swatch_row[5:8]] == [ Color(8), Color(8), Color(8), ] assert [character.animation.input_bg_color for character in bright_swatch_row[8:]] == [ Color(9), Color(9), Color(9), ] terminaltexteffects-release-0.15.0/tests/test_cli.py000066400000000000000000000203471517776150200226360ustar00rootroot00000000000000"""CLI tests for completion generation and parser wiring.""" from __future__ import annotations import os import subprocess import sys from typing import TYPE_CHECKING import pytest from terminaltexteffects import __main__ if TYPE_CHECKING: from pathlib import Path pytestmark = [pytest.mark.smoke] 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( """ from dataclasses import dataclass from terminaltexteffects.engine.base_config import BaseConfig from terminaltexteffects.utils import argutils class PluginDemoEffect: pass @dataclass class PluginDemoConfig(BaseConfig): parser_spec: argutils.ParserSpec = argutils.ParserSpec( name="plugindemo", help="plugin help", description="plugin description", epilog="plugin epilog", ) plugin_speed: int = argutils.ArgSpec(name="--plugin-speed", default=1, type=int) def get_effect_resources(): return "plugindemo", PluginDemoEffect, PluginDemoConfig """.strip(), encoding="utf-8", ) def _run_bash(script: str, *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: """Run a clean bash shell command in the project root.""" bash_env = os.environ.copy() if env: bash_env.update(env) return subprocess.run( # noqa: S603 ["/bin/bash", "--noprofile", "--norc", "-c", script], check=True, capture_output=True, text=True, cwd=str(__main__.Path(__file__).resolve().parents[1]), env=bash_env, ) def _run_zsh(script: str, *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: """Run a clean zsh shell command in the project root.""" zsh_env = os.environ.copy() if env: zsh_env.update(env) return subprocess.run( # noqa: S603 ["/bin/zsh", "-fc", script], check=True, capture_output=True, text=True, cwd=str(__main__.Path(__file__).resolve().parents[1]), env=zsh_env, ) def test_build_parser_registers_effects() -> None: """The parser builder should expose built-in effects as subcommands.""" parser, effect_resource_map = __main__.build_parser() assert "matrix" in effect_resource_map assert "highlight" in effect_resource_map help_output = parser.format_help() assert "matrix" in help_output assert "highlight" in help_output def test_main_print_completion_bash_outputs_script( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Printing bash completion should not require stdin or an effect.""" monkeypatch.setattr(__main__.sys, "argv", ["tte", "--print-completion", "bash"]) __main__.main() output = capsys.readouterr().out assert "complete -F _tte_completion tte" in output assert "--wrap-text" in output assert "--random-effect" in output assert "--include-effects" in output assert "matrix" in output assert "laseretch" in output assert "highlight" in output assert "--highlight-brightness" in output assert "--rain-color-gradient" in output def test_main_print_completion_zsh_outputs_script( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Printing zsh completion should emit a zsh-loadable wrapper.""" monkeypatch.setattr(__main__.sys, "argv", ["tte", "--print-completion", "zsh"]) __main__.main() output = capsys.readouterr().out assert "autoload -Uz compinit bashcompinit" in output assert "bashcompinit" in output assert "complete -F _tte_completion tte" in output def test_main_print_completion_invalid_shell_exits(monkeypatch: pytest.MonkeyPatch) -> None: """Invalid shell names should fail through argparse validation.""" monkeypatch.setattr(__main__.sys, "argv", ["tte", "--print-completion", "fish"]) with pytest.raises(SystemExit, match="2"): __main__.main() def test_main_unsupported_ansi_sequence_exits_with_error( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Unsupported terminal control sequences should fail cleanly before rendering.""" monkeypatch.setattr(__main__.sys, "argv", ["tte", "rain"]) monkeypatch.setattr(__main__.Terminal, "get_piped_input", lambda: "abc\x1b[2Jdef") with pytest.raises(SystemExit) as exc_info: __main__.main() assert exc_info.value.code == 1 captured = capsys.readouterr() assert captured.out == "" assert "Unsupported ANSI/control sequence" in captured.err assert "\\x1b[2J" in captured.err def test_build_parser_includes_plugin_effect_in_completion( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: """Plugin-discovered effects should appear in generated completion output.""" _write_demo_plugin(tmp_path) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) monkeypatch.setattr(__main__.sys, "argv", ["tte", "--print-completion", "bash"]) __main__.main() output = capsys.readouterr().out assert "plugindemo" in output assert "--plugin-speed" in output def test_bash_completion_registers_in_clean_shell() -> None: """The bash completion script should register both CLI entry points.""" result = _run_bash( 'eval "$(' f"{sys.executable} -m terminaltexteffects --print-completion bash" ')"; complete -p tte; complete -p terminaltexteffects', ) assert "complete -F _tte_completion tte" in result.stdout assert "complete -F _tte_completion terminaltexteffects" in result.stdout def test_zsh_completion_registers_in_clean_shell() -> None: """The zsh completion script should self-bootstrap in a clean shell.""" result = _run_zsh( 'eval "$(' f"{sys.executable} -m terminaltexteffects --print-completion zsh" ')"; whence -w _tte_completion; complete -p tte; complete -p terminaltexteffects', ) assert "_tte_completion: function" in result.stdout assert "complete -F _tte_completion tte" in result.stdout assert "complete -F _tte_completion terminaltexteffects" in result.stdout def test_bash_completion_suggests_effect_names_and_options() -> None: """Bash completion should suggest built-in effects and effect-specific options.""" result = _run_bash( """ eval "$(""" + f"{sys.executable}" + """ -m terminaltexteffects --print-completion bash)" COMP_WORDS=(tte ma) COMP_CWORD=1 _tte_completion printf 'effects:%s\\n' "${COMPREPLY[*]}" COMP_WORDS=(tte matrix --ra) COMP_CWORD=2 _tte_completion printf 'options:%s\\n' "${COMPREPLY[*]}" """, ) assert "effects:matrix" in result.stdout assert "--rain-color-gradient" in result.stdout assert "--rain-symbols" in result.stdout def test_bash_completion_suggests_choice_and_file_values(tmp_path: Path) -> None: """Bash completion should offer choice values and file path completions.""" completion_file = tmp_path / "demo.txt" completion_file.write_text("demo", encoding="utf-8") result = _run_bash( f""" eval "$({sys.executable} -m terminaltexteffects --print-completion bash)" COMP_WORDS=(tte --print-completion "") COMP_CWORD=2 _tte_completion printf 'shells:%s\\n' "${{COMPREPLY[*]}}" COMP_WORDS=(tte --input-file "{tmp_path}/d") COMP_CWORD=2 _tte_completion printf 'files:%s\\n' "${{COMPREPLY[*]}}" """, ) assert "shells:bash zsh" in result.stdout assert str(completion_file) in result.stdout def test_bash_completion_includes_plugin_effect_in_clean_shell(tmp_path: Path) -> None: """Plugin effects should be available through the full bash completion flow.""" _write_demo_plugin(tmp_path) result = _run_bash( """ eval "$(""" + f"{sys.executable}" + """ -m terminaltexteffects --print-completion bash)" COMP_WORDS=(tte pl) COMP_CWORD=1 _tte_completion printf 'effects:%s\\n' "${COMPREPLY[*]}" COMP_WORDS=(tte plugindemo --pl) COMP_CWORD=2 _tte_completion printf 'options:%s\\n' "${COMPREPLY[*]}" """, env={"XDG_CONFIG_HOME": str(tmp_path)}, ) assert "effects:plugindemo" in result.stdout assert "--plugin-speed" in result.stdout terminaltexteffects-release-0.15.0/tests/test_effects.py000066400000000000000000000134421517776150200235040ustar00rootroot00000000000000"""Smoke and visual tests for every bundled effect.""" from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Any, Literal import pytest from terminaltexteffects.effects import effect_colorshift, effect_matrix, effect_thunderstorm if TYPE_CHECKING: from terminaltexteffects.engine.base_effect import BaseEffect from terminaltexteffects.engine.terminal import TerminalConfig TEST_INPUT_DIR = Path(__file__).parent / "testinput" MIXED_COLOR_SEQUENCE_INPUT = TEST_INPUT_DIR / "mixed_color_sequence_test.txt" MIXED_LAYOUT_STYLE_SEQUENCE_INPUT = TEST_INPUT_DIR / "mixed_layout_style_sequence_test.txt" def _shorten_visual_effect(effect_instance: BaseEffect[Any]) -> None: """Shorten long-running visual effect configurations.""" if isinstance(effect_instance, effect_matrix.Matrix): effect_instance.effect_config.rain_time = 5 elif isinstance(effect_instance, effect_thunderstorm.Thunderstorm): effect_instance.effect_config.storm_time = 1 elif isinstance(effect_instance, effect_colorshift.ColorShift): effect_instance.effect_config.cycles = 2 def _print_visual_test_parameters(test_name: str, **parameters: str) -> None: """Print a visible separator naming the visual test parameters.""" formatted_parameters = ", ".join(f"{name}={value}" for name, value in parameters.items()) print(f"\n=== {test_name}: {formatted_parameters} ===") @pytest.mark.smoke @pytest.mark.effects @pytest.mark.parametrize( "input_data", ["empty", "single_char", "single_column", "single_row", "medium", "tabs", "color_sequences"], indirect=True, ) def test_effect( effect: type[BaseEffect[Any]], input_data: str, terminal_config_default_no_framerate: TerminalConfig, ) -> None: """Test every effect against representative terminal inputs.""" effect_instance = effect(input_data) # customize some effect configs to shorten testing time if isinstance(effect_instance, effect_matrix.Matrix): effect_instance.effect_config.rain_time = 1 elif isinstance(effect_instance, effect_thunderstorm.Thunderstorm): effect_instance.effect_config.storm_time = 1 effect_instance.terminal_config = terminal_config_default_no_framerate with effect_instance.terminal_output() as terminal: for frame in effect_instance: terminal.print(frame) @pytest.mark.smoke @pytest.mark.effects @pytest.mark.parametrize("input_data", ["medium", "color_sequences"], indirect=True) @pytest.mark.parametrize("existing_color_handling", ["always", "dynamic", "ignore"]) def test_effect_color_sequence_handling( effect: type[BaseEffect[Any]], input_data: str, terminal_config_default_no_framerate: TerminalConfig, existing_color_handling: Literal["always", "dynamic", "ignore"], ) -> None: """Test each color handling mode against plain and ANSI-colored inputs.""" effect_instance = effect(input_data) if isinstance(effect_instance, effect_matrix.Matrix): effect_instance.effect_config.rain_time = 1 elif isinstance(effect_instance, effect_thunderstorm.Thunderstorm): effect_instance.effect_config.storm_time = 1 effect_instance.terminal_config = terminal_config_default_no_framerate effect_instance.terminal_config.existing_color_handling = existing_color_handling with effect_instance.terminal_output() as terminal: for frame in effect_instance: terminal.print(frame) @pytest.mark.visual @pytest.mark.parametrize("input_data", ["large"], indirect=True) def test_effect_visual(effect: type[BaseEffect[Any]], input_data: str) -> None: """Render a larger visual sample for each effect.""" effect_instance = effect(input_data) _shorten_visual_effect(effect_instance) _print_visual_test_parameters( "test_effect_visual", effect=effect.__name__, input="large", ) with effect_instance.terminal_output() as terminal: for frame in effect_instance: terminal.print(frame) @pytest.mark.visual @pytest.mark.manual @pytest.mark.parametrize( "sequence_input_path", [ pytest.param(MIXED_COLOR_SEQUENCE_INPUT, id="mixed-color"), pytest.param(MIXED_LAYOUT_STYLE_SEQUENCE_INPUT, id="mixed-layout-style"), ], ) @pytest.mark.parametrize("existing_color_handling", ["dynamic", "always", "ignore"]) def test_effect_sequence_visual( effect: type[BaseEffect[Any]], sequence_input_path: Path, existing_color_handling: Literal["dynamic", "always", "ignore"], terminal_config_default_no_framerate: TerminalConfig, ) -> None: """Render each effect with visual sequence fixtures and color handling modes.""" effect_instance = effect(sequence_input_path.read_text(encoding="utf-8")) _shorten_visual_effect(effect_instance) effect_instance.terminal_config = terminal_config_default_no_framerate effect_instance.terminal_config.existing_color_handling = existing_color_handling _print_visual_test_parameters( "test_effect_sequence_visual", effect=effect.__name__, input=sequence_input_path.stem, existing_color_handling=existing_color_handling, ) with effect_instance.terminal_output() as terminal: for frame in effect_instance: terminal.print(frame) @pytest.mark.skip @pytest.mark.manual @pytest.mark.parametrize("input_data", ["canvas"], indirect=True) def test_canvas_anchoring_large_small_canvas( input_data: str, effect: type[BaseEffect[Any]], terminal_config_with_anchoring: TerminalConfig, ) -> None: """Render each effect with manual canvas and text anchoring options.""" effect_instance = effect(input_data) effect_instance.terminal_config = terminal_config_with_anchoring with effect_instance.terminal_output() as terminal: for frame in effect_instance: terminal.print(frame) terminaltexteffects-release-0.15.0/tests/testinput/000077500000000000000000000000001517776150200225075ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/tests/testinput/astro_balloon.txt000066400000000000000000000160351517776150200261130ustar00rootroot00000000000000 ___-mmma___ _s"-_"~gggg~""= .<-_ _r_g@@@@@@@@@@D"r_g@p"q_a ,fg@@@@@@@@@D"o"o@@@@@@@P,"_o ________ /,@@@@@@B>_="g@@@@@@@@P,"o@@_ '(._-..q_ q_a jJ{@P".="<@@@@@@@@@P"=_g@@"~"g@' [ ].,[@,jW[ /_""~g@@@@@@@@@@P_="g@@P_+_@@@P_@.]F,_gP gPf /@ @@@@@@@@P"o"_@@@P_="g@@@>_P'_+.~_@"_wP; ..@,@BP".="_g@@D"->_g@@@>_*'_s",__@P _@"r ' >"_g@@@B="<>_g@@@B>-"~"o"_ "gB"~_@"o" ;D=_"s>"o@@@@B>'_->_="~ _g@P _gP"s"@ _>t"@@@_<4+"~_w>_="_^,_gB" _gD",>_D_,' -_u_Fo"gg@B>"o*"_'~_g@P"~_gM",>_d"o@B"F __1@1%mmP>"_gy@@@@@@.__~"""s"~8"<@@P"_'/ 0gW@@@@@@W@@@Q$@B@@@B>_s"_m>_g@@P"_g@Ps 1@@g5@@@@@g@@D>"-="_mD"_g@@D>__g@@@"+ '<=mmm==>"" "a_4@BP>"__g@@@BP"r" "<==mmy="" ] ] _~~B>"""->._ ] ,+_^~-"_@@B==4@g_g_ ] ,/gF^f_@P_g@BBBB@@_4@@, ] __@,/_@[_~g@@@@@@@g_8L0@L _~~1_ ,/@,//@@@@@@@@@@@@@@@@'gQ@\ jd/|g]h |@g'/@@@@@@@@@@@@@@@@@@'[@@//'.<>/[ @|.:@/gp\@@@@@@@@@@@@@@@@@ @ L=r_' !@h,,@'@@"@@@@@@@@@@@@@@@@@.@@']\ ]"==a<)@+"@@@@@@@@@@@@@B2#F@g, " ', F@@@_`qt\oW@BP@@@@0S$W@@'/AB"f| \ l4@@@_,!r0@@@@@@@@@@@P,^"g+@WJ ^'@@P/_<.<-_"""""_+"_"oFg@\s _ ">_"=_""""_<"_@"g@@@@`, !!|0@@@@BP"_g@@P"~":aV_ '_;@@@Fa[@@P+faB=4@\9p\[ @[@@@' [@B___`' `[,"~ , [@@@'[-mm=>"_''qB>J~"g[ [!@@;TR"""~__~~_g@@P'T[ |[@@g_"LG@@@@[g""~_-_@[ |[@@@8-"@@@@@@F@ "_@@8| {:"_r] 8"==*"~T'*8="- ;/@@@"_ ]|@@+_1 [@@@@g| [\@@@@, !`@@@F' \*"="' ".""_" """ terminaltexteffects-release-0.15.0/tests/testinput/canvas.txt000066400000000000000000000012111517776150200245160ustar00rootroot00000000000000TL!!!!!!!!!!!!!!!!!!!!!TOP*********************TR + <----50----> # + | # + | # L ^ | R E | | I F MID 13 | CENTER MID G T | | H - v | T - | @ - | @ - | @ BL--------------------BOTTOM...................BRterminaltexteffects-release-0.15.0/tests/testinput/color_sequence_test.txt000066400000000000000000000070041517776150200273160ustar00rootroot00000000000000........ | ggggggg ggggggg :gggggg; ggggggg ggggggg :gggggg; ggggggg ;gggggg ggggggg | @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@g | @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@g | BBBBBBB BBBBBBB !BBBBBB" BBBBBBB BBBBBBB !BBBBBB" BBBBBBB "BBBBBB BBBBBBN | ggggggg ggggggg :gg gg; ggggggg ggggggg :gggggg; ggggggg ;gggggg ggggggg | @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@g | @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@g | BBBBBBB BBBBBBB !BBBBBB" BBBBBBB BBBBBBB !BBBBBB" BBBBBBB "BBBBBB BBBBBBN | ggggggg ggggggg :gggggg; ggggggg ggggggg :gggggg; ggggggg ;gggggg ggggggg | @@@@@@@ @@@@@@@ |@@@@@@| @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@g | @@@@@@@ @@@@@@@ |@@@@@@| @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@g | ======= ======= !BBBBBB! BBBBBBB BBBBBBB '======" ======= "====== BBBBBB8 | ggggggg ggggggg :gggggg; ggggggg ggggggg :gggggg; ggggggg ggggggg ggggggg | @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ @@@@@@g | @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ @@@@@@@ @@@@@@g | BBBBBBB BBBBBBB !BBBBBB" BBBBBBB BBBBBBB !BBBBBB" BBBBBBB BBBBBBB BBBBBBN | ggggggg ggggggg :gggggg; ggggggg ggggggg :gggggg; ggggggg ;gggggg gggggg; | @@@@@@@ @@@@@@@ |@@@@@@| @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@] | @@@@@@@ @@@@@@@ |@@@@@@| @@@@@@@ @@@@@@@ |@@@@@@] @@@@@@@ [@@@@@@ @@@@@@] | BBBBBBB ======= !BBBBBB! ======= 8BBBBBB !BBBBBB" BBBBBBB "BBBBBB ======" terminaltexteffects-release-0.15.0/tests/testinput/demo.txt000066400000000000000000000030361517776150200241760ustar00rootroot00000000000000 _______ _______ _______ (_______|_______|_______) _ _ _____ | | | | | ___) | | | | | |_____ |_| |_| |_______) ========================== TerminalTextEffects applies visual effects to text in the terminal. The TTE animation engine has the following features: * Xterm 256 / RGB hex color support * Complex character movement via Paths, Waypoints, and motion easing. * Complex animations via Scenes with symbol/color changes, layers, easing, and Path synced progression. * Event handling for Path/Scene state changes with custom callback support and many pre-defined actions. * Variable stop/step color gradient generation. * Extensive effect customization via per-effect arguments. * Runs inline, preserving terminal state and workflow. Installation: * pip install TerminalTextEffects * pipx install TerminalTextEffects More Info: https://github.com/ChrisBuilds/terminaltexteffects https://pypi.org/project/terminaltexteffects/terminaltexteffects-release-0.15.0/tests/testinput/fullscreen_text.txt000066400000000000000000000252501517776150200264620ustar00rootroot00000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffgggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggghhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiijjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZterminaltexteffects-release-0.15.0/tests/testinput/mixed_color_sequence_test.txt000066400000000000000000000010751517776150200305060ustar00rootroot00000000000000RED plain BLUE no-color GREEN more plain text YELLOW and BGONLY end plain start MAGENTA plain middle BGBLUE plain end ORANGECYAN together then plain mix FGBG and BG2 then FG2 leading plain then reset still plain RED2 BGSTART plain FGSTART BOTH2 edge GRAY middle BGGRAY trailing plain terminaltexteffects-release-0.15.0/tests/testinput/mixed_layout_style_sequence_test.txt000066400000000000000000000023231517776150200321220ustar00rootroot00000000000000[?25l[?7l+------------------+ | LEFT LOGO BLOCK | | logo row A | | logo row B | | logo row C | +------------------++--------------------------------+ | RIGHT PANEL FROM H/f MOVES | | user@host | | OS: cursor layout | | std:  K  R  G  Y  | | brt:  k  r  g  y  | +--------------------------------+ CURSOR MOVEMENT CHECKS G absolute column:G-MARK starts at col 26 C forward spacing: STARTAFTER-C D back overwrite: 123456789 A/B vertical anchor from B lands two rows below anchorfrom A lands one row above B E/F line movement anchorfrom E appears two rows belowfrom F appears one row above E ABSOLUTE POSITIONING CHECKS H puts this at row 23 col 1f puts this at row 23 col 34 STYLE AND RESET CHECKS BOLD_GREEN normal-green default-fg blue-bg default-bg 8bit-bg 24bit-orange-fg plain after reset END OF LAYOUT + STYLE SEQUENCE TEST[?25h[?7h terminaltexteffects-release-0.15.0/tests/testinput/modify.txt000066400000000000000000000003511517776150200245360ustar00rootroot00000000000000Testing Input Top Line Test Input Second Line Test Input Third Line Test Input Fourth Line Test Input Bottom Lineterminaltexteffects-release-0.15.0/tests/testinput/single_char.txt000066400000000000000000000000011517776150200255150ustar00rootroot00000000000000aterminaltexteffects-release-0.15.0/tests/testinput/single_line.txt000066400000000000000000000000341517776150200255350ustar00rootroot00000000000000TerminalTextEffects is neat!terminaltexteffects-release-0.15.0/tests/testinput/small_text.txt000066400000000000000000000000241517776150200254200ustar00rootroot00000000000000012345 6789ab cdefghterminaltexteffects-release-0.15.0/tests/testinput/solid.txt000066400000000000000000000076131517776150200243710ustar00rootroot00000000000000██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████ ██████████████████████████████████████████████████████████████████terminaltexteffects-release-0.15.0/tests/testinput/sparse_text.txt000066400000000000000000000003511517776150200256100ustar00rootroot00000000000000a b c d e f g h i j k l 1 2 3 4 5 6 7 8terminaltexteffects-release-0.15.0/tests/testinput/square.txt000066400000000000000000000001671517776150200245540ustar00rootroot000000000000000000000000000000 0000000000000000 0000000000000000 0000000000000000 0000000000000000 0000000000000000 X000000000000000 terminaltexteffects-release-0.15.0/tests/testinput/tall_text.txt000066400000000000000000000002131517776150200252440ustar00rootroot000000000000000 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49terminaltexteffects-release-0.15.0/tests/testinput/titles.txt000066400000000000000000000125021517776150200245540ustar00rootroot00000000000000 _________ _______ ___ ___ _________ _______ ________ ________ _______ ________ _________ ________ |\___ ___\\ ___ \ |\ \ / /|\___ ___\ |\ ___ \ |\ _____\\ _____\\ ___ \ |\ ____\\___ ___\\ ____\ \|___ \ \_\ \ __/| \ \ \/ / ||___ \ \_| \ \ __/|\ \ \__/\ \ \__/\ \ __/|\ \ \___\|___ \ \_\ \ \___|_ \ \ \ \ \ \_|/__ \ \ / / \ \ \ \ \ \_|/_\ \ __\\ \ __\\ \ \_|/_\ \ \ \ \ \ \ \_____ \ \ \ \ \ \ \_|\ \ / \/ \ \ \ \ \ \_|\ \ \ \_| \ \ \_| \ \ \_|\ \ \ \____ \ \ \ \|____|\ \ \ \__\ \ \_______\/ /\ \ \ \__\ \ \_______\ \__\ \ \__\ \ \_______\ \_______\ \ \__\ ____\_\ \ \|__| \|_______/__/ /\ __\ \|__| \|_______|\|__| \|__| \|_______|\|_______| \|__| |\_________\ |__|/ \|__| \|_________| ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░▌ ▐░▌▐░░░░░░░░░░░▌ ▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌ ▀▀▀▀█░█▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░▌ ▐░▌ ▀▀▀▀█░█▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▀▀▀▀█░█▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░█▄▄▄▄▄▄▄▄▄ ▐░▐░▌ ▐░▌ ▐░█▄▄▄▄▄▄▄▄▄ ▐░█▄▄▄▄▄▄▄▄▄ ▐░█▄▄▄▄▄▄▄▄▄ ▐░█▄▄▄▄▄▄▄▄▄ ▐░▌ ▐░▌ ▐░█▄▄▄▄▄▄▄▄▄ ▐░▌ ▐░░░░░░░░░░░▌ ▐░▌ ▐░▌ ▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌▐░▌ ▐░▌ ▐░░░░░░░░░░░▌ ▐░▌ ▐░█▀▀▀▀▀▀▀▀▀ ▐░▌░▌ ▐░▌ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░█▀▀▀▀▀▀▀▀▀ ▐░▌ ▐░▌ ▀▀▀▀▀▀▀▀▀█░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░▌ ▐░█▄▄▄▄▄▄▄▄▄ ▐░▌ ▐░▌ ▐░▌ ▐░█▄▄▄▄▄▄▄▄▄ ▐░▌ ▐░▌ ▐░█▄▄▄▄▄▄▄▄▄ ▐░█▄▄▄▄▄▄▄▄▄ ▐░▌ ▄▄▄▄▄▄▄▄▄█░▌ ▐░▌ ▐░░░░░░░░░░░▌▐░▌ ▐░▌ ▐░▌ ▐░░░░░░░░░░░▌▐░▌ ▐░▌ ▐░░░░░░░░░░░▌▐░░░░░░░░░░░▌ ▐░▌ ▐░░░░░░░░░░░▌ ▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀ ::::::::::: :::::::::: ::: ::: ::::::::::: :::::::::: :::::::::: :::::::::: :::::::::: :::::::: ::::::::::: :::::::: :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +#+ +#++:++# +#++:+ +#+ +#++:++# :#::+::# :#::+::# +#++:++# +#+ +#+ +#++:++#++ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# ### ########## ### ### ### ########## ### ### ########## ######## ### ######## terminaltexteffects-release-0.15.0/tests/testinput/two_char.txt000066400000000000000000000001021517776150200250470ustar00rootroot00000000000000a bterminaltexteffects-release-0.15.0/tests/testinput/wide_text.txt000066400000000000000000000004351517776150200252460ustar00rootroot00000000000000This is a very long line. This is a very long line. This is a very long line. This is a very long line. This is a very long line. This is a very long line. This is a very long line. This is a very long line. This is a very long line. This is a very long line. This is a very long line.terminaltexteffects-release-0.15.0/tests/utils_tests/000077500000000000000000000000001517776150200230325ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/tests/utils_tests/__init__.py000066400000000000000000000000001517776150200251310ustar00rootroot00000000000000terminaltexteffects-release-0.15.0/tests/utils_tests/test_ansitools.py000066400000000000000000000045361517776150200264660ustar00rootroot00000000000000import pytest from terminaltexteffects.utils import ansitools pytestmark = [pytest.mark.utils, pytest.mark.smoke] @pytest.mark.parametrize("escape", ["\033[", "\x1b["]) @pytest.mark.parametrize("position", ["38;2", "48;2"]) def test_parse_ansi_color_sequence_24_bit(escape, position): assert ansitools.parse_ansi_color_sequence(f"{escape}{position};255;255;255m") == "FFFFFF" assert ansitools.parse_ansi_color_sequence(f"{escape}{position};") == "00" assert ansitools.parse_ansi_color_sequence(f"{escape}{position};255;;255m") == "FF00FF" @pytest.mark.parametrize("escape", ["\033[", "\x1b["]) @pytest.mark.parametrize("position", ["38;5", "48;5"]) def test_parse_ansi_color_sequence_8_bit(escape, position): assert ansitools.parse_ansi_color_sequence(f"{escape}{position};255m") == 255 assert ansitools.parse_ansi_color_sequence(f"{escape}{position};128") == 128 @pytest.mark.parametrize("escape", ["\032[", "\x2b["]) @pytest.mark.parametrize("position", ["37;5", "49;5"]) def test_parse_ansi_color_sequence_invalid(escape, position): with pytest.raises(ValueError): ansitools.parse_ansi_color_sequence(f"{escape}{position};255;255;255m") def test_DEC_SAVE_CURSOR_POSITION(): assert ansitools.dec_save_cursor_position() == "\0337" def test_DEC_RESTORE_CURSOR_POSITION(): assert ansitools.dec_restore_cursor_position() == "\0338" def test_HIDE_CURSOR(): assert ansitools.hide_cursor() == "\033[?25l" def test_SHOW_CURSOR(): assert ansitools.show_cursor() == "\033[?25h" def test_MOVE_CURSOR_UP(): assert ansitools.move_cursor_up(5) == "\033[5A" def test_MOVE_CURSOR_TO_COLUMN(): assert ansitools.move_cursor_to_column(5) == "\033[5G" def test_RESET_ALL(): assert ansitools.reset_all() == "\033[0m" def test_APPLY_BOLD(): assert ansitools.apply_bold() == "\033[1m" def test_APPLY_DIM(): assert ansitools.apply_dim() == "\033[2m" def test_APPLY_ITALIC(): assert ansitools.apply_italic() == "\033[3m" def test_APPLY_UNDERLINE(): assert ansitools.apply_underline() == "\033[4m" def test_APPLY_BLINK(): assert ansitools.apply_blink() == "\033[5m" def test_APPLY_REVERSE(): assert ansitools.apply_reverse() == "\033[7m" def test_APPLY_HIDDEN(): assert ansitools.apply_hidden() == "\033[8m" def test_APPLY_STRIKETHROUGH(): assert ansitools.apply_strikethrough() == "\033[9m" terminaltexteffects-release-0.15.0/tests/utils_tests/test_argutils.py000066400000000000000000000117111517776150200262760ustar00rootroot00000000000000from argparse import ArgumentTypeError import pytest from terminaltexteffects.utils import argutils, easing from terminaltexteffects.utils.graphics import Color, Gradient pytestmark = [pytest.mark.utils, pytest.mark.smoke] def test_postive_int_valid_int(): assert argutils.PositiveInt.type_parser("1") == 1 @pytest.mark.parametrize("arg", ["-1", "0", "1.1", "a"]) def test_postive_int_invalid_int(arg): with pytest.raises(ArgumentTypeError): argutils.PositiveInt.type_parser(arg) def test_non_negative_int_valid_int(): assert argutils.NonNegativeInt.type_parser("0") == 0 @pytest.mark.parametrize("arg", ["-1", "1.1", "a"]) def test_non_negative_int_invalid_int(arg): with pytest.raises(ArgumentTypeError): argutils.NonNegativeInt.type_parser(arg) def test_positive_int_range_valid_range(): assert argutils.PositiveIntRange.type_parser("1-10") == (1, 10) @pytest.mark.parametrize("arg", ["-1-10", "1.1-10", "a-10", "1-10.1", "1-a", "2-1", "0-3"]) def test_positive_int_range_invalid_range(arg): with pytest.raises(ArgumentTypeError): argutils.PositiveIntRange.type_parser(arg) def test_positive_float_valid_float(): assert argutils.PositiveFloat.type_parser("1.1") == 1.1 @pytest.mark.parametrize("arg", ["-1.1", "0", "a"]) def test_positive_float_invalid_float(arg): with pytest.raises(ArgumentTypeError): argutils.PositiveFloat.type_parser(arg) def test_non_negative_float_valid_float(): assert argutils.NonNegativeFloat.type_parser("0") == 0 assert argutils.NonNegativeFloat.type_parser("1.1") == 1.1 @pytest.mark.parametrize("arg", ["-1.1", "a"]) def test_non_negative_float_invalid_float(arg): with pytest.raises(ArgumentTypeError): argutils.NonNegativeFloat.type_parser(arg) def test_positive_float_range_valid_range(): assert argutils.PositiveFloatRange.type_parser("1.1-10.1") == (1.1, 10.1) @pytest.mark.parametrize("arg", ["-1.1-10.1", "a-10.1", "1.1-10.1.1", "1.1-a", "2.1-1.1", "0-3"]) def test_positive_float_range_invalid_range(arg): with pytest.raises(ArgumentTypeError): argutils.PositiveFloatRange.type_parser(arg) def test_NonNegativeRatio_valid_ratio(): assert argutils.NonNegativeRatio.type_parser("0.5") == 0.5 assert argutils.NonNegativeRatio.type_parser("1") == 1 assert argutils.NonNegativeRatio.type_parser("0") == 0 @pytest.mark.parametrize("arg", ["-1", "1.1", "a"]) def test_NonNegativeRatio_invalid_ratio(arg): with pytest.raises(ArgumentTypeError): argutils.NonNegativeRatio.type_parser(arg) def test_PositiveRatio_valid_ratio(): assert argutils.PositiveRatio.type_parser("0.5") == 0.5 assert argutils.PositiveRatio.type_parser("1.0") == 1 assert argutils.PositiveRatio.type_parser("0.01") == 0.01 @pytest.mark.parametrize("arg", ["-1", "1.1", "0", "a"]) def test_PositiveRatio_invalid_ratio(arg): with pytest.raises(ArgumentTypeError): argutils.PositiveRatio.type_parser(arg) def test_gradient_direction_valid_direction(): assert argutils.GradientDirection.type_parser("horizontal") == Gradient.Direction.HORIZONTAL assert argutils.GradientDirection.type_parser("vertical") == Gradient.Direction.VERTICAL def test_gradient_direction_invalid_direction(): with pytest.raises(ArgumentTypeError): argutils.GradientDirection.type_parser("invalid") def test_color_arg_valid_color(): assert argutils.ColorArg.type_parser("125") == Color(125) assert argutils.ColorArg.type_parser("ffffff") == Color("#ffffff") @pytest.mark.parametrize("arg", ["-1", "256", "ffffzz", "aaa"]) def test_color_arg_invalid_color(arg): with pytest.raises(ArgumentTypeError): argutils.ColorArg.type_parser(arg) def test_symbol_valid_symbol(): assert argutils.Symbol.type_parser("a") == "a" @pytest.mark.parametrize("arg", ["", "aa"]) def test_symbol_invalid_symbol(arg): with pytest.raises(ArgumentTypeError): argutils.Symbol.type_parser(arg) def test_canvas_dimensions_valid_dimension(): assert argutils.CanvasDimension.type_parser("0") == 0 assert argutils.CanvasDimension.type_parser("1") == 1 assert argutils.CanvasDimension.type_parser("-1") == -1 @pytest.mark.parametrize("arg", ["-2", "a", "1.1"]) def test_canvas_dimensions_invalid_dimension(arg): with pytest.raises(ArgumentTypeError): argutils.CanvasDimension.type_parser(arg) def test_terminal_dimension_valid_dimension(): assert argutils.TerminalDimension.type_parser("0") == 0 assert argutils.TerminalDimension.type_parser("1") == 1 @pytest.mark.parametrize("arg", ["a", "1.1", "-1"]) def test_terminal_dimension_invalid_dimension(arg): with pytest.raises(ArgumentTypeError): argutils.TerminalDimension.type_parser(arg) def test_ease_valid_ease(): assert argutils.Ease.type_parser("linear") == easing.linear assert argutils.Ease.type_parser("in_sine") == easing.in_sine def test_ease_invalid_ease(): with pytest.raises(ArgumentTypeError): argutils.Ease.type_parser("invalid") terminaltexteffects-release-0.15.0/tests/utils_tests/test_colorterm.py000066400000000000000000000066111517776150200264550ustar00rootroot00000000000000"""Tests for terminal color escape sequence helpers.""" from __future__ import annotations import pytest from terminaltexteffects.utils import colorterm pytestmark = [pytest.mark.utils, pytest.mark.smoke] @pytest.mark.parametrize( ("color_code", "expected_sequence"), [ pytest.param("#ffffff", "\x1b[38;2;255;255;255m", id="hex-with-hash-max"), pytest.param("#000000", "\x1b[38;2;0;0;0m", id="hex-with-hash-min"), pytest.param("ffffff", "\x1b[38;2;255;255;255m", id="hex-max"), pytest.param("000000", "\x1b[38;2;0;0;0m", id="hex-min"), pytest.param(255, "\x1b[38;5;255m", id="xterm-max"), pytest.param(0, "\x1b[38;5;0m", id="xterm-min"), ], ) def test_fg_valid_color_codes(color_code: str | int, expected_sequence: str) -> None: """Formats foreground ANSI sequences for valid hex and xterm color inputs.""" assert colorterm.fg(color_code) == expected_sequence def test_fg_invalid_hex() -> None: """Rejects malformed foreground hex color strings.""" with pytest.raises(ValueError, match="invalid literal for int\\(\\) with base 16"): colorterm.fg("fgffff") @pytest.mark.parametrize("color_code", [pytest.param(256, id="above-max"), pytest.param(-1, id="below-min")]) def test_fg_invalid_xterm(color_code: int) -> None: """Rejects out-of-range foreground xterm color indexes.""" with pytest.raises( ValueError, match=r"xterm color codes must be an integer: 0 <= n <= 255", ): colorterm.fg(color_code) def test_fg_invalid_type() -> None: """Rejects unsupported foreground color input types.""" with pytest.raises( TypeError, match=r"Color must be either hex string #000000 -> #FFFFFF or int xterm color code 0 <= n <= 255", ): colorterm.fg(3.14) # type: ignore[arg-type] @pytest.mark.parametrize( ("color_code", "expected_sequence"), [ pytest.param("#ffffff", "\x1b[48;2;255;255;255m", id="hex-with-hash-max"), pytest.param("#000000", "\x1b[48;2;0;0;0m", id="hex-with-hash-min"), pytest.param("ffffff", "\x1b[48;2;255;255;255m", id="hex-max"), pytest.param("000000", "\x1b[48;2;0;0;0m", id="hex-min"), pytest.param(255, "\x1b[48;5;255m", id="xterm-max"), pytest.param(0, "\x1b[48;5;0m", id="xterm-min"), ], ) def test_bg_valid_color_codes(color_code: str | int, expected_sequence: str) -> None: """Formats background ANSI sequences for valid hex and xterm color inputs.""" assert colorterm.bg(color_code) == expected_sequence def test_bg_invalid_hex() -> None: """Rejects malformed background hex color strings.""" with pytest.raises(ValueError, match="invalid literal for int\\(\\) with base 16"): colorterm.bg("fgffff") @pytest.mark.parametrize("color_code", [pytest.param(256, id="above-max"), pytest.param(-1, id="below-min")]) def test_bg_invalid_xterm(color_code: int) -> None: """Rejects out-of-range background xterm color indexes.""" with pytest.raises( ValueError, match=r"xterm color codes must be an integer: 0 <= n <= 255", ): colorterm.bg(color_code) def test_bg_invalid_type() -> None: """Rejects unsupported background color input types.""" with pytest.raises( TypeError, match=r"Color must be either hex string #000000 -> #FFFFFF or int xterm color code 0 <= n <= 255", ): colorterm.bg(3.14) # type: ignore[arg-type] terminaltexteffects-release-0.15.0/tests/utils_tests/test_easing.py000066400000000000000000000006421517776150200257130ustar00rootroot00000000000000import pytest pytestmark = [pytest.mark.utils, pytest.mark.smoke] def test_ease_valid_progress(easing_function_1) -> None: assert round(easing_function_1(0)) == 0 assert round(easing_function_1(1)) == 1 @pytest.mark.parametrize("progress", [n / 10 for n in range(1, 11)]) def test_ease_progress_ratios(progress, easing_function_1) -> None: easing_function_1(progress) # should not raise an exception terminaltexteffects-release-0.15.0/tests/utils_tests/test_geometry.py000066400000000000000000000176441517776150200263120ustar00rootroot00000000000000"""Test the geometry module.""" from __future__ import annotations import pytest from terminaltexteffects.utils import geometry pytestmark = [pytest.mark.utils, pytest.mark.smoke] @pytest.fixture def coord() -> geometry.Coord: """Return a coordinate for testing.""" return geometry.Coord(1, 2) def test_coord_init(coord: geometry.Coord) -> None: """Test that the coordinate is initialized correctly.""" assert coord.column == 1 assert coord.row == 2 def test_coord_equalities(coord: geometry.Coord) -> None: """Test that the coordinate is equal to itself.""" coord1 = geometry.Coord(1, 2) assert coord1 == coord def test_find_coords_on_circle_coords_limit(coord: geometry.Coord) -> None: """Test that the function returns the correct number of coordinates.""" coords = geometry.find_coords_on_circle(coord, 5, 5, unique=False) assert len(coords) == 5 def test_find_coords_on_circle_zero_radius(coord: geometry.Coord) -> None: """Test that the function returns an empty list when the radius is zero.""" coords = geometry.find_coords_on_circle(coord, 0, 5, unique=False) assert len(coords) == 0 def test_find_coords_on_circle_unique(coord: geometry.Coord) -> None: """Test that the function returns the correct number of unique coordinates.""" coords = geometry.find_coords_on_circle(coord, 5, 0, unique=True) assert len(set(coords)) == len(coords) def test_find_coords_in_circle(coord: geometry.Coord) -> None: """Test that the function returns the correct number of coordinates.""" coords = geometry.find_coords_in_circle(coord, 5) assert len(coords) > 0 def test_find_coords_in_circle_zero_radius(coord: geometry.Coord) -> None: """Test that the function returns an empty list when the radius is zero.""" coords = geometry.find_coords_in_circle(coord, 0) assert len(coords) == 0 def test_find_coords_in_rect(coord: geometry.Coord) -> None: """Test that the function returns the correct number of coordinates.""" coords = geometry.find_coords_in_rect(coord, 5) assert len(coords) > 0 def test_find_coords_in_rect_zero_width(coord: geometry.Coord) -> None: """Test that the function returns an empty list when the width is zero.""" coords = geometry.find_coords_in_rect(coord, 0) assert len(coords) == 0 def test_find_coords_on_rect_perimeter_and_bounds() -> None: """Test that the perimeter of the rectangle is returned and that the coordinates are within the bounds.""" origin = geometry.Coord(5, 5) half_width, half_height = 2, 3 coords = geometry.find_coords_on_rect(origin, half_width, half_height) assert len(coords) == 4 * (half_width + half_height) left = origin.column - half_width right = origin.column + half_width top = origin.row - half_height bottom = origin.row + half_height assert all( (left <= c.column <= right) and (top <= c.row <= bottom) and (c.column in (left, right) or c.row in (top, bottom)) for c in coords ) assert len(coords) == len(set(coords)) def test_find_coords_on_rect_zero_dimensions() -> None: """Test that the function returns an empty list when the half width or half height is zero.""" assert geometry.find_coords_on_rect(geometry.Coord(0, 0), 0, 3) == [] assert geometry.find_coords_on_rect(geometry.Coord(0, 0), 3, 0) == [] def test_find_coords_on_rect_small_exact_points() -> None: """Test that the function returns the correct coordinates for a small rectangle.""" origin = geometry.Coord(2, 2) coords = set(geometry.find_coords_on_rect(origin, 1, 1)) expected = { geometry.Coord(1, 1), geometry.Coord(2, 1), geometry.Coord(3, 1), geometry.Coord(1, 2), geometry.Coord(3, 2), geometry.Coord(1, 3), geometry.Coord(2, 3), geometry.Coord(3, 3), } assert coords == expected def test_find_coord_at_distance(coord: geometry.Coord) -> None: """Test that the function returns the correct coordinate.""" new_coord = geometry.Coord(coord.column + 5, coord.row + 5) coord_at_distance = geometry.extrapolate_along_ray(coord, new_coord, 3) # verify the coord returned is further away from the target coord assert coord_at_distance == geometry.Coord(8, 9) def test_find_coord_at_distance_zero_distance(coord: geometry.Coord) -> None: """Test that the function returns the same coordinate when the distance is zero.""" coord_at_distance = geometry.extrapolate_along_ray(coord, coord, 0) assert coord_at_distance.column == coord.column assert coord_at_distance.row == coord.row def test_find_coord_on_bezier_curve() -> None: """Test that the function returns the correct coordinate.""" start = geometry.Coord(0, 0) end = geometry.Coord(10, 10) control = geometry.Coord(5, 0) coord_on_curve = geometry.find_coord_on_bezier_curve(start, (control,), end, 0.5) assert coord_on_curve == geometry.Coord(5, 2) def test_find_coord_on_bezier_curve_two_control_points() -> None: """Test that the function returns the correct coordinate.""" start = geometry.Coord(0, 0) end = geometry.Coord(10, 10) control1 = geometry.Coord(5, 0) control2 = geometry.Coord(5, 10) # verify a Coord is returned and no exception is raised assert isinstance(geometry.find_coord_on_bezier_curve(start, (control1, control2), end, 0.5), geometry.Coord) def test_find_coord_on_line() -> None: """Test that the function returns the correct coordinate.""" start = geometry.Coord(0, 0) end = geometry.Coord(10, 10) coord = geometry.find_coord_on_line(start, end, 0.5) assert coord.column == 5 assert coord.row == 5 def test_find_length_of_bezier_curve() -> None: """Test that the function returns the correct length.""" start = geometry.Coord(0, 0) end = geometry.Coord(10, 10) control = geometry.Coord(5, 0) length = geometry.find_length_of_bezier_curve(start, end, control) assert length == 19.008767012245137 def test_find_length_of_bezier_curve_two_control_points() -> None: """Test that the function returns the correct length.""" start = geometry.Coord(0, 0) end = geometry.Coord(10, 10) control1 = geometry.Coord(5, 0) control2 = geometry.Coord(5, 10) length = geometry.find_length_of_bezier_curve(start, (control1, control2), end) assert length == 22.662619116234062 def test_find_length_of_line() -> None: """Test that the function returns the correct length.""" start = geometry.Coord(0, 0) end = geometry.Coord(10, 10) length = geometry.find_length_of_line(start, end) assert length == 14.142135623730951 def test_find_length_of_line_double_row_diff() -> None: """Test that the function returns the correct length.""" start = geometry.Coord(0, 0) end = geometry.Coord(0, 10) length = geometry.find_length_of_line(start, end, double_row_diff=True) assert length == 20 def test_find_normalized_distance_from_center() -> None: """Test that the function returns the correct distance.""" coord = geometry.Coord(3, 3) distance = geometry.find_normalized_distance_from_center(1, 10, 1, 10, coord) assert distance == 0.4 def test_find_normalized_distance_from_center_with_offset() -> None: """Test that the function returns the correct distance.""" coord = geometry.Coord(6, 6) distance = geometry.find_normalized_distance_from_center(4, 13, 4, 13, coord) assert distance == 0.4 def test_find_normalized_distance_from_center_out_of_bounds() -> None: """Test that the function raises an error when the coordinate is out of bounds.""" coord = geometry.Coord(1, 1) with pytest.raises(ValueError, match="Coordinate is not within the rectangle"): geometry.find_normalized_distance_from_center(4, 13, 4, 13, coord) coord = geometry.Coord(14, 14) with pytest.raises(ValueError, match="Coordinate is not within the rectangle"): geometry.find_normalized_distance_from_center(4, 13, 4, 13, coord) terminaltexteffects-release-0.15.0/tests/utils_tests/test_gradient.py000066400000000000000000000223121517776150200262400ustar00rootroot00000000000000import pytest from terminaltexteffects.engine.motion import Coord from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient, random_color pytestmark = [pytest.mark.utils, pytest.mark.smoke] def test_random_color() -> None: assert isinstance(random_color(), Color) def test_color_pair_init() -> None: cp = ColorPair("#ffffff", "#000000") assert cp.fg_color == Color("#ffffff") assert cp.bg_color == Color("#000000") def test_color_pair_init_single_color() -> None: cp = ColorPair("#ffffff") assert cp.fg_color == Color("#ffffff") assert cp.bg_color is None def test_gradient_zero_stops() -> None: with pytest.raises(ValueError): Gradient() def test_gradient_zero_steps() -> None: with pytest.raises(ValueError): Gradient(Color("#ffffff"), steps=0) def test_gradient_zero_steps_tuple() -> None: with pytest.raises(ValueError): Gradient(Color("#ffffff"), Color("#000000"), Color("#ff0000"), steps=(1, 0)) def test_gradient_slice() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) assert g[0] == Color("#ffffff") assert g[-1] == Color("#000000") assert g[1:3] == [Color("#bfbfbf"), Color("#7f7f7f")] def test_gradient_iter() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) for color in g: assert isinstance(color, Color) def test_gradient_str() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) assert "Stops(ffffff, 000000)" in str(g) def test_gradient_len() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) assert len(g) == 5 def test_gradient_length_single_color() -> None: g = Gradient(Color("#ffffff"), steps=5) assert len(g.spectrum) == 5 def test_gradient_length_two_colors() -> None: g = Gradient(Color("#000000"), Color("#ffffff"), steps=5) assert len(g.spectrum) == 6 def test_gradient_length_three_colors() -> None: g = Gradient(Color("#000000"), Color("#ffffff"), Color("#000000"), steps=5) assert len(g.spectrum) == 11 def test_gradient_length_same_color_multiple_times() -> None: g = Gradient(Color("#ffffff"), Color("#ffffff"), Color("#ffffff"), Color("#ffffff"), steps=4) assert len(g.spectrum) == 13 def test_gradient_length_same_color_multiple_times_with_tuple_steps() -> None: g = Gradient(Color("#ffffff"), Color("#ffffff"), Color("#ffffff"), Color("#ffffff"), steps=(4, 6)) assert len(g.spectrum) == 17 def test_gradient_single_color() -> None: g = Gradient(Color("#ffffff"), steps=5) assert all(color == Color("#ffffff") for color in g.spectrum) def test_gradient_two_colors() -> None: g = Gradient(Color("#000000"), Color("#ffffff"), steps=3) assert g.spectrum[0] == Color("#000000") and g.spectrum[-1] == Color("#ffffff") def test_gradient_single_step() -> None: g = Gradient(Color("#ffffff"), steps=1) assert g.spectrum[0] == Color("#ffffff") def test_gradient_three_colors() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), Color("#ffffff"), steps=4) assert ( g.spectrum[0] == Color("#ffffff") and g.spectrum[4] == Color("#000000") and g.spectrum[-1] == Color("#ffffff") ) def test_gradient_loop() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4, loop=True) assert g.spectrum[-1] == Color("#ffffff") def test_gradient_get_color_at_fraction() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) assert g.get_color_at_fraction(0) == Color("#ffffff") assert g.get_color_at_fraction(0.5) == Color("#7f7f7f") assert g.get_color_at_fraction(1) == Color("#000000") def test_gradient_get_color_at_fraction_invalid_float() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) with pytest.raises(ValueError): g.get_color_at_fraction(1.1) @pytest.mark.parametrize( "direction", [ Gradient.Direction.DIAGONAL, Gradient.Direction.HORIZONTAL, Gradient.Direction.VERTICAL, Gradient.Direction.RADIAL, ], ) def test_gradient_build_coordinate_color_mapping(direction) -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) coordinate_map = g.build_coordinate_color_mapping(1, 10, 1, 10, direction) if direction == Gradient.Direction.DIAGONAL: assert coordinate_map[Coord(1, 1)] == Color("#ffffff") assert coordinate_map[Coord(10, 10)] == Color("#000000") elif direction == Gradient.Direction.HORIZONTAL: assert coordinate_map[Coord(1, 1)] == Color("#ffffff") assert coordinate_map[Coord(10, 1)] == Color("#000000") elif direction == Gradient.Direction.VERTICAL: assert coordinate_map[Coord(1, 1)] == Color("#ffffff") assert coordinate_map[Coord(1, 10)] == Color("#000000") elif direction == Gradient.Direction.RADIAL: assert coordinate_map[Coord(5, 5)] == Color("#ffffff") assert coordinate_map[Coord(10, 10)] == Color("#000000") @pytest.mark.parametrize( "direction", [ Gradient.Direction.DIAGONAL, Gradient.Direction.HORIZONTAL, Gradient.Direction.VERTICAL, Gradient.Direction.RADIAL, ], ) @pytest.mark.parametrize("min_column", [1, 5]) @pytest.mark.parametrize("max_column", [5, 10]) @pytest.mark.parametrize("min_row", [1, 5]) @pytest.mark.parametrize("max_row", [5, 10]) def test_gradient_build_coordinate_color_mapping_no_exceptions( direction, min_column, max_column, min_row, max_row, ) -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) if min_column > max_column or min_row > max_row: with pytest.raises(ValueError): coordinate_map = g.build_coordinate_color_mapping(min_row, max_row, min_column, max_column, direction) else: # check for exceptions across single row/column and issue that might arise from math calculations coordinate_map = g.build_coordinate_color_mapping(min_row, max_row, min_column, max_column, direction) def test_gradient_build_coordinate_color_mapping_single_row() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) coordinate_map = g.build_coordinate_color_mapping(1, 1, 1, 10, Gradient.Direction.HORIZONTAL) assert coordinate_map[Coord(1, 1)] == Color("#ffffff") assert coordinate_map[Coord(10, 1)] == Color("#000000") def test_gradient_build_coordinate_color_mapping_horizontal_respects_min_row() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) coordinate_map = g.build_coordinate_color_mapping(5, 10, 1, 10, Gradient.Direction.HORIZONTAL) assert Coord(1, 4) not in coordinate_map assert coordinate_map[Coord(1, 5)] == Color("#ffffff") assert coordinate_map[Coord(10, 10)] == Color("#000000") def test_gradient_build_coordinate_color_mapping_single_column() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) coordinate_map = g.build_coordinate_color_mapping(1, 10, 1, 1, Gradient.Direction.VERTICAL) assert coordinate_map[Coord(1, 1)] == Color("#ffffff") assert coordinate_map[Coord(1, 10)] == Color("#000000") def test_gradient_build_coordinate_color_mapping_single_row_column() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) coordinate_map = g.build_coordinate_color_mapping(1, 1, 1, 1, Gradient.Direction.HORIZONTAL) assert coordinate_map[Coord(1, 1)] == Color("#000000") def test_gradient_build_coordinate_color_mapping_invalid_row_column() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) with pytest.raises(ValueError): g.build_coordinate_color_mapping(0, 10, 0, 10, Gradient.Direction.HORIZONTAL) with pytest.raises(ValueError): g.build_coordinate_color_mapping(10, 0, 10, 0, Gradient.Direction.HORIZONTAL) def test_gradient_build_coordinate_color_mapping_max_less_than_min() -> None: g = Gradient(Color("#ffffff"), Color("#000000"), steps=4) with pytest.raises(ValueError): g.build_coordinate_color_mapping(10, 1, 10, 1, Gradient.Direction.HORIZONTAL) def test_color_invalid_xterm_color() -> None: with pytest.raises(ValueError): Color(256) def test_color_invalid_hex_color() -> None: with pytest.raises(ValueError): Color("#ffffzz") def test_color_valid_hex_with_hash(): assert Color("#ffffff") == Color("#ffffff") def test_color_hex_rgb_ints(): assert Color("#000000").rgb_ints == (0, 0, 0) def test_color_xterm_rgb_ints(): assert Color(0).rgb_ints == (0, 0, 0) def test_color_not_equal(): assert Color("#ffffff") != Color("#000000") def test_color_not_equal_different_types(): assert Color("#ffffff") != 0 assert Color(0) != "ffffff" def test_color_is_hashable(): hash(Color("#ffffff")) hash(Color(0)) def test_color_is_iterable(): assert list(Color("#ffffff")) == [Color("#ffffff")] def test_color_repr(): assert repr(Color("#ffffff")) == "Color('ffffff')" def test_color_str(): assert "Color Code: ffffff" in str(Color("#ffffff")) def test_color_str_includes_xterm_zero() -> None: assert "XTerm Color: 0" in str(Color(0)) def test_color_pair_str_includes_xterm_zero() -> None: color_pair = ColorPair(0, 0) color_pair_str = str(color_pair) assert "Foreground XTerm Color: 0" in color_pair_str assert "Background XTerm Color: 0" in color_pair_str terminaltexteffects-release-0.15.0/tests/utils_tests/test_hexterm.py000066400000000000000000000046771517776150200261350ustar00rootroot00000000000000"""Unit tests for the hexterm module in terminaltexteffects.utils. This module tests the following functions: - hexterm.hex_to_xterm: * Validates the conversion from hexadecimal color codes to xterm color indices. * Ensures that invalid hexadecimal strings raise a ValueError. - hexterm.xterm_to_hex: * Validates the conversion from xterm color indices to hexadecimal color codes. * Ensures that invalid xterm color indices raise a ValueError. - hexterm.is_valid_color: * Checks whether a provided value (hexadecimal string or xterm index) is a valid color. * The function returns True for valid colors and False for invalid inputs. Tests are marked with 'utils' and 'smoke' pytest markers to classify them appropriately. """ import pytest from terminaltexteffects.utils import hexterm pytestmark = [pytest.mark.utils, pytest.mark.smoke] def test_hex_to_xterm() -> None: """Test conversion from hex to xterm colors.""" assert hexterm.hex_to_xterm("#ffffff") == 15 def test_hex_to_xterm_invalid_hex_chars() -> None: """Test hex_to_xterm with invalid hexadecimal characters.""" with pytest.raises(ValueError): # noqa: PT011 hexterm.hex_to_xterm("zzzzzz") def test_xterm_to_hex() -> None: """Test conversion from xterm to hex colors.""" assert hexterm.xterm_to_hex(1) == "800000" def test_xterm_to_hex_invalid_xterm() -> None: """Test that converting an invalid xterm color raises a ValueError.""" with pytest.raises(ValueError): # noqa: PT011 hexterm.xterm_to_hex(256) def test_is_valid_color_valid_hex_color() -> None: """Test that a valid hexadecimal color is recognized as valid.""" assert hexterm.is_valid_color("#ffffff") is True def test_is_valid_color_valid_xterm_color() -> None: """Test that a valid xterm color is recognized as valid.""" assert hexterm.is_valid_color(255) is True def test_is_valid_color_invalid_hex_color_chars() -> None: """Test that invalid hexadecimal color characters are recognized as invalid.""" assert hexterm.is_valid_color("#zzzzzz") is False def test_is_valid_color_invalid_hex_length() -> None: """Test that an invalid hexadecimal color length is recognized as invalid.""" assert hexterm.is_valid_color("ff") is False def test_is_valid_color_invalid_xterm_color() -> None: """Test that an invalid xterm color is recognized as invalid.""" assert hexterm.is_valid_color(256) is False terminaltexteffects-release-0.15.0/tests/utils_tests/test_spanningtree_aldousbroder.py000066400000000000000000000211401517776150200317030ustar00rootroot00000000000000"""Tests for the Aldous-Broder spanning-tree generator.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.spanningtree.algo.aldousbroder import AldousBroder if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter pytestmark = [pytest.mark.utils, pytest.mark.smoke] def make_terminal() -> Terminal: """Build a compact terminal with both text and outer-fill characters.""" config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True config.canvas_width = 3 config.canvas_height = 3 return Terminal(input_data="ab\ncd", config=config) def get_char(terminal: Terminal, column: int, row: int) -> EffectCharacter: """Fetch a terminal character by coordinate and assert it exists.""" character = terminal.get_character_by_input_coord(Coord(column, row)) assert character is not None return character def set_neighbors( subject: EffectCharacter, *, north: EffectCharacter | None = None, east: EffectCharacter | None = None, south: EffectCharacter | None = None, west: EffectCharacter | None = None, ) -> None: """Overwrite the subject's neighbor map for a deterministic test setup.""" subject.neighbors.clear() subject.neighbors.update( { "north": north, "east": east, "south": south, "west": west, }, ) def test_aldous_broder_init_uses_explicit_starting_character() -> None: """Verify initialization stores the provided starting character and initial walk state.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = AldousBroder(terminal, starting_char=starting_char) assert generator._current_char is starting_char assert generator.char_last_linked is starting_char assert generator.char_link_order == [starting_char] assert generator.linked_char_last_visited is starting_char assert starting_char not in generator._unlinked_chars assert generator.complete is False def test_aldous_broder_init_selects_random_starting_character_when_not_provided( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization resolves a random starting character when none is provided.""" terminal = make_terminal() random_start = get_char(terminal, 2, 1) def fake_random_coord() -> Coord: """Return a deterministic coordinate for random start lookup.""" return Coord(2, 1) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) generator = AldousBroder(terminal) assert generator._current_char is random_start assert generator.char_link_order == [random_start] assert generator.linked_char_last_visited is random_start def test_aldous_broder_init_raises_value_error_when_no_starting_character_is_found( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization fails when a starting character cannot be resolved.""" terminal = make_terminal() def fake_random_coord() -> Coord: """Return a coordinate that does not resolve to a terminal character.""" return Coord(9, 9) def fake_get_character_by_input_coord(coord: Coord) -> None: """Simulate a failed starting-character lookup.""" assert coord == Coord(9, 9) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) monkeypatch.setattr(terminal, "get_character_by_input_coord", fake_get_character_by_input_coord) with pytest.raises(ValueError, match=r"Unable to find a starting character\."): AldousBroder(terminal) def test_aldous_broder_step_links_an_unvisited_neighbor( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a step links a newly encountered neighbor and records it in link order.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 2, 1) generator = AldousBroder(terminal, starting_char=starting_char) set_neighbors(starting_char, west=west_neighbor, south=south_neighbor) def fake_choice(neighbors: list[EffectCharacter]) -> EffectCharacter: """Return the west neighbor from the provided random-walk candidates.""" assert west_neighbor in neighbors return west_neighbor monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.aldousbroder.random.choice", fake_choice, ) generator.step() assert west_neighbor in starting_char.links assert starting_char in west_neighbor.links assert generator._current_char is west_neighbor assert generator.char_last_linked is west_neighbor assert generator.linked_char_last_visited is None assert generator.char_link_order == [starting_char, west_neighbor] assert west_neighbor not in generator._unlinked_chars assert generator.complete is False def test_aldous_broder_step_records_already_linked_neighbor_as_last_visited( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a step records revisiting an already-linked neighbor without creating a new link.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) linked_neighbor = get_char(terminal, 1, 2) linked_neighbor._link(get_char(terminal, 1, 1)) generator = AldousBroder(terminal, starting_char=starting_char) set_neighbors(starting_char, west=linked_neighbor) def fake_choice(neighbors: list[EffectCharacter]) -> EffectCharacter: """Return the already-linked neighbor from the random-walk candidates.""" assert neighbors == [linked_neighbor] return neighbors[0] monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.aldousbroder.random.choice", fake_choice, ) generator.step() assert generator._current_char is linked_neighbor assert generator.char_last_linked is None assert generator.linked_char_last_visited is linked_neighbor assert generator.char_link_order == [starting_char] def test_aldous_broder_step_grows_link_order_across_multiple_walk_steps( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify repeated walk steps append newly linked characters in encounter order.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 1, 1) generator = AldousBroder(terminal, starting_char=starting_char) set_neighbors(starting_char, west=west_neighbor) set_neighbors(west_neighbor, east=starting_char, south=south_neighbor) walk_targets = iter([west_neighbor, south_neighbor]) def fake_choice(neighbors: list[EffectCharacter]) -> EffectCharacter: """Return the next predetermined walk target from the available candidates.""" next_target = next(walk_targets) assert next_target in neighbors return next_target monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.aldousbroder.random.choice", fake_choice, ) generator.step() generator.step() assert generator._current_char is south_neighbor assert generator.char_link_order == [starting_char, west_neighbor, south_neighbor] assert generator.char_last_linked is south_neighbor assert generator.linked_char_last_visited is None def test_aldous_broder_step_returns_immediately_when_all_characters_are_linked( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a completed generator returns without advancing the walk or recording a visited neighbor.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) linked_neighbor = get_char(terminal, 1, 2) linked_neighbor._link(get_char(terminal, 1, 1)) generator = AldousBroder(terminal, starting_char=starting_char) generator._unlinked_chars.clear() set_neighbors(starting_char, west=linked_neighbor) def fake_choice(neighbors: list[EffectCharacter]) -> EffectCharacter: """Fail if random neighbor selection is attempted after completion.""" msg = f"random.choice should not be called after completion, received {neighbors!r}" raise AssertionError(msg) monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.aldousbroder.random.choice", fake_choice, ) generator.step() assert generator.complete is True assert generator._current_char is starting_char assert generator.char_last_linked is None assert generator.linked_char_last_visited is None terminaltexteffects-release-0.15.0/tests/utils_tests/test_spanningtree_base_generator.py000066400000000000000000000144701517776150200322060ustar00rootroot00000000000000"""Tests for the spanning-tree generator base class.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.spanningtree.base_generator import SpanningTreeGenerator if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter pytestmark = [pytest.mark.utils, pytest.mark.smoke] class DummySpanningTreeGenerator(SpanningTreeGenerator): """Concrete test helper for the abstract spanning-tree base class.""" def step(self) -> None: """No-op step implementation for tests.""" def make_terminal() -> Terminal: """Build a compact terminal with both text and outer-fill characters.""" config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True config.canvas_width = 3 config.canvas_height = 3 return Terminal(input_data="ab\ncd", config=config) def get_char(terminal: Terminal, column: int, row: int) -> EffectCharacter: """Fetch a terminal character by coordinate and assert it exists.""" character = terminal.get_character_by_input_coord(Coord(column, row)) assert character is not None return character def set_neighbors( subject: EffectCharacter, *, north: EffectCharacter | None = None, east: EffectCharacter | None = None, south: EffectCharacter | None = None, west: EffectCharacter | None = None, ) -> None: """Overwrite the subject's neighbor map for a deterministic test setup.""" subject.neighbors.clear() subject.neighbors.update( { "north": north, "east": east, "south": south, "west": west, }, ) def test_spanning_tree_generator_init_stores_terminal_reference() -> None: """Verify the base generator keeps the terminal instance passed at construction.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) assert generator.terminal is terminal def test_get_neighbors_ignores_none_entries_and_returns_unlinked_neighbors_by_default() -> None: """Verify neighbor lookup ignores ``None`` entries and returns unlinked neighbors by default.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) subject = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 2, 1) set_neighbors(subject, north=None, east=None, south=south_neighbor, west=west_neighbor) assert set(generator.get_neighbors(subject)) == {west_neighbor, south_neighbor} def test_get_neighbors_excludes_neighbors_that_already_have_links() -> None: """Verify default neighbor lookup excludes neighbors that already belong to a tree.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) subject = get_char(terminal, 2, 2) unlinked_neighbor = get_char(terminal, 1, 2) linked_neighbor = get_char(terminal, 2, 1) link_target = get_char(terminal, 1, 1) linked_neighbor._link(link_target) set_neighbors(subject, south=linked_neighbor, west=unlinked_neighbor) assert generator.get_neighbors(subject) == [unlinked_neighbor] def test_get_neighbors_limit_to_text_boundary_true_excludes_outer_fill_neighbors() -> None: """Verify text-boundary filtering removes otherwise eligible outer-fill neighbors.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) subject = get_char(terminal, 2, 2) in_text_neighbor = get_char(terminal, 1, 2) outer_fill_neighbor = get_char(terminal, 3, 2) set_neighbors(subject, east=outer_fill_neighbor, west=in_text_neighbor) assert generator.get_neighbors(subject, limit_to_text_boundary=True) == [in_text_neighbor] def test_get_neighbors_limit_to_text_boundary_false_keeps_outer_fill_neighbors() -> None: """Verify outer-fill neighbors remain eligible when text-boundary filtering is disabled.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) subject = get_char(terminal, 2, 2) in_text_neighbor = get_char(terminal, 1, 2) outer_fill_neighbor = get_char(terminal, 3, 2) set_neighbors(subject, east=outer_fill_neighbor, west=in_text_neighbor) assert set(generator.get_neighbors(subject, limit_to_text_boundary=False)) == { in_text_neighbor, outer_fill_neighbor, } def test_get_neighbors_unlinked_only_false_includes_linked_neighbors() -> None: """Verify disabling the unlinked-only filter includes both linked and unlinked neighbors.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) subject = get_char(terminal, 2, 2) in_text_neighbor = get_char(terminal, 1, 2) linked_outer_fill_neighbor = get_char(terminal, 3, 2) link_target = get_char(terminal, 3, 1) linked_outer_fill_neighbor._link(link_target) set_neighbors(subject, east=linked_outer_fill_neighbor, west=in_text_neighbor) assert generator.get_neighbors(subject, unlinked_only=False) == [linked_outer_fill_neighbor, in_text_neighbor] def test_get_neighbors_combined_filters_return_only_unlinked_in_text_neighbors() -> None: """Verify combined filters can include linked in-text neighbors while excluding out-of-text neighbors.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) subject = get_char(terminal, 2, 2) in_text_unlinked_neighbor = get_char(terminal, 1, 2) in_text_linked_neighbor = get_char(terminal, 2, 1) outer_fill_neighbor = get_char(terminal, 3, 2) in_text_linked_neighbor._link(get_char(terminal, 1, 1)) set_neighbors(subject, east=outer_fill_neighbor, south=in_text_linked_neighbor, west=in_text_unlinked_neighbor) assert generator.get_neighbors(subject, unlinked_only=False, limit_to_text_boundary=True) == [ in_text_linked_neighbor, in_text_unlinked_neighbor, ] def test_get_neighbors_returns_empty_list_when_subject_has_no_neighbors() -> None: """Verify neighbor lookup returns an empty list when the subject has no neighbors configured.""" terminal = make_terminal() generator = DummySpanningTreeGenerator(terminal) subject = get_char(terminal, 2, 2) set_neighbors(subject) assert generator.get_neighbors(subject) == [] terminaltexteffects-release-0.15.0/tests/utils_tests/test_spanningtree_breadthfirst.py000066400000000000000000000117231517776150200317050ustar00rootroot00000000000000"""Tests for the breadth-first spanning-tree traversal.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.spanningtree.algo.breadthfirst import BreadthFirst if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter pytestmark = [pytest.mark.utils, pytest.mark.smoke] def make_terminal() -> Terminal: """Build a compact terminal with both text and outer-fill characters.""" config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True config.canvas_width = 3 config.canvas_height = 3 return Terminal(input_data="ab\ncd", config=config) def get_char(terminal: Terminal, column: int, row: int) -> EffectCharacter: """Fetch a terminal character by coordinate and assert it exists.""" character = terminal.get_character_by_input_coord(Coord(column, row)) assert character is not None return character def test_breadth_first_init_uses_explicit_starting_character() -> None: """Verify initialization stores the provided starting character and initial frontier state.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = BreadthFirst(terminal, starting_char=starting_char) assert generator._limit_to_text_boundary is False assert generator.starting_char is starting_char assert generator._frontier == [starting_char] assert generator._explored == {starting_char: starting_char} assert generator.explored_last_step == [] assert generator.char_explore_order == [] assert generator.complete is False def test_breadth_first_init_selects_random_starting_character_when_not_provided( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization resolves a random starting character when none is provided.""" terminal = make_terminal() random_start = get_char(terminal, 2, 1) def fake_random_coord(*, within_text_boundary: bool = False) -> Coord: """Return a deterministic in-text coordinate for random start lookup.""" assert within_text_boundary is True return Coord(2, 1) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) generator = BreadthFirst(terminal, limit_to_text_boundary=True) assert generator.starting_char is random_start assert generator._frontier == [random_start] assert generator._explored == {random_start: random_start} def test_breadth_first_step_explores_linked_neighbors_and_updates_frontier() -> None: """Verify a step discovers linked neighbors, records them, and pushes them onto the frontier.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 2, 1) starting_char._link(west_neighbor) starting_char._link(south_neighbor) generator = BreadthFirst(terminal, starting_char=starting_char) generator.step() assert set(generator.explored_last_step) == {west_neighbor, south_neighbor} assert set(generator.char_explore_order) == {west_neighbor, south_neighbor} assert set(generator._frontier) == {west_neighbor, south_neighbor} assert generator._explored[west_neighbor] is starting_char assert generator._explored[south_neighbor] is starting_char assert generator.complete is False def test_breadth_first_step_records_each_discovery_once_with_the_first_discovering_parent() -> None: """Verify each discovered character is recorded once and keeps the first frontier parent that found it.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) first_frontier_char = get_char(terminal, 1, 2) second_frontier_char = get_char(terminal, 2, 1) discovered_char = get_char(terminal, 1, 1) first_frontier_char._link(discovered_char) generator = BreadthFirst(terminal, starting_char=starting_char) generator._frontier = [first_frontier_char, second_frontier_char] generator._explored = { starting_char: starting_char, first_frontier_char: starting_char, second_frontier_char: starting_char, } generator.step() assert generator.explored_last_step == [discovered_char] assert generator.char_explore_order == [discovered_char] assert generator._frontier == [discovered_char] assert generator._explored[discovered_char] is first_frontier_char def test_breadth_first_step_marks_complete_when_frontier_is_empty() -> None: """Verify a step marks the traversal complete when no frontier nodes remain.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = BreadthFirst(terminal, starting_char=starting_char) generator._frontier.clear() generator.explored_last_step = [starting_char] generator.step() assert generator.complete is True assert generator.explored_last_step == [] terminaltexteffects-release-0.15.0/tests/utils_tests/test_spanningtree_primssimple.py000066400000000000000000000200541517776150200315650ustar00rootroot00000000000000"""Tests for the simplified Prim's spanning-tree generator.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.spanningtree.algo.primssimple import PrimsSimple if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter pytestmark = [pytest.mark.utils, pytest.mark.smoke] def make_terminal() -> Terminal: """Build a compact terminal with both text and outer-fill characters.""" config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True config.canvas_width = 3 config.canvas_height = 3 return Terminal(input_data="ab\ncd", config=config) def get_char(terminal: Terminal, column: int, row: int) -> EffectCharacter: """Fetch a terminal character by coordinate and assert it exists.""" character = terminal.get_character_by_input_coord(Coord(column, row)) assert character is not None return character def set_neighbors( subject: EffectCharacter, *, north: EffectCharacter | None = None, east: EffectCharacter | None = None, south: EffectCharacter | None = None, west: EffectCharacter | None = None, ) -> None: """Overwrite the subject's neighbor map for a deterministic test setup.""" subject.neighbors.clear() subject.neighbors.update( { "north": north, "east": east, "south": south, "west": west, }, ) def test_prims_simple_init_uses_explicit_starting_character() -> None: """Verify initialization stores the provided starting character and default edge state.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = PrimsSimple(terminal, starting_char=starting_char) assert generator.limit_to_text_boundary is False assert generator._current_char is starting_char assert generator.char_last_linked is starting_char assert generator.char_link_order == [starting_char] assert generator.edge_chars == [starting_char] assert generator.edge_last_added is starting_char assert generator.edge_last_popped is None assert generator.complete is False def test_prims_simple_init_selects_random_starting_character_when_not_provided( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization resolves a random starting character when none is provided.""" terminal = make_terminal() random_start = get_char(terminal, 2, 1) def fake_random_coord(*, within_text_boundary: bool = False) -> Coord: """Return a deterministic coordinate for the random start lookup.""" assert within_text_boundary is False return Coord(2, 1) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) generator = PrimsSimple(terminal) assert generator._current_char is random_start assert generator.char_link_order == [random_start] assert generator.edge_chars == [random_start] def test_prims_simple_init_raises_value_error_when_no_starting_character_is_found( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization fails when a starting character cannot be resolved.""" terminal = make_terminal() def fake_random_coord(*, within_text_boundary: bool = False) -> Coord: """Return a coordinate that does not resolve to a terminal character.""" assert within_text_boundary is False return Coord(9, 9) def fake_get_character_by_input_coord(coord: Coord) -> None: """Simulate a failed starting-character lookup.""" assert coord == Coord(9, 9) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) monkeypatch.setattr(terminal, "get_character_by_input_coord", fake_get_character_by_input_coord) with pytest.raises(ValueError, match=r"Unable to find a starting character\."): PrimsSimple(terminal) def test_prims_simple_step_links_neighbor_and_updates_edge_bookkeeping( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a step links a chosen neighbor and updates the current edge pool.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 2, 1) downstream_neighbor = get_char(terminal, 1, 1) generator = PrimsSimple(terminal, starting_char=starting_char) set_neighbors(starting_char, west=west_neighbor, south=south_neighbor) set_neighbors(south_neighbor, north=starting_char, west=downstream_neighbor) def fake_randrange(stop: int) -> int: """Select the first available edge and then the first neighbor by iteration order.""" assert stop in {1, 2} return 0 monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.primssimple.random.randrange", fake_randrange, ) generator.step() assert south_neighbor in starting_char.links assert starting_char in south_neighbor.links assert generator._current_char is starting_char assert generator.edge_last_popped is starting_char assert generator.char_last_linked is south_neighbor assert generator.char_link_order == [starting_char, south_neighbor] assert generator.edge_chars == [starting_char, south_neighbor] assert generator.edge_last_added is south_neighbor assert generator.complete is False def test_prims_simple_step_leaves_link_state_unchanged_when_popped_edge_has_no_unlinked_neighbors( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a no-link step preserves the current link-state fields while emptying the edge pool.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) linked_neighbor = get_char(terminal, 1, 2) linked_neighbor._link(get_char(terminal, 1, 1)) generator = PrimsSimple(terminal, starting_char=starting_char) set_neighbors(starting_char, west=linked_neighbor) def fake_randrange(stop: int) -> int: """Select the only available edge character.""" assert stop == 1 return 0 monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.primssimple.random.randrange", fake_randrange, ) generator.step() assert generator.edge_last_popped is starting_char assert generator.char_last_linked is starting_char assert generator.edge_last_added is starting_char assert generator.edge_chars == [] assert generator.complete is False def test_prims_simple_step_marks_complete_when_no_edge_characters_remain() -> None: """Verify a step marks the generator complete when the edge list is already empty.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = PrimsSimple(terminal, starting_char=starting_char) generator.edge_chars.clear() generator.step() assert generator.complete is True def test_prims_simple_limit_to_text_boundary_blocks_outer_fill_neighbors( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify text-boundary filtering prevents selecting and requeueing via outer-fill neighbors.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) in_text_neighbor = get_char(terminal, 1, 2) outer_fill_neighbor = get_char(terminal, 3, 2) generator = PrimsSimple(terminal, starting_char=starting_char, limit_to_text_boundary=True) set_neighbors(starting_char, west=in_text_neighbor, east=outer_fill_neighbor) set_neighbors(in_text_neighbor, east=starting_char, north=outer_fill_neighbor) def fake_randrange(stop: int) -> int: """Select the only available edge and filtered neighbor candidate.""" assert stop == 1 return 0 monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.primssimple.random.randrange", fake_randrange, ) generator.step() assert in_text_neighbor in starting_char.links assert outer_fill_neighbor not in starting_char.links assert generator.char_last_linked is in_text_neighbor assert generator.edge_chars == [] terminaltexteffects-release-0.15.0/tests/utils_tests/test_spanningtree_primsweighted.py000066400000000000000000000263171517776150200321040ustar00rootroot00000000000000"""Tests for the weighted Prim's spanning-tree generator.""" from __future__ import annotations from collections import defaultdict from typing import TYPE_CHECKING import pytest from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.spanningtree.algo.primsweighted import PrimsWeighted, WeightedLink if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter pytestmark = [pytest.mark.utils, pytest.mark.smoke] def make_terminal() -> Terminal: """Build a compact terminal with both text and outer-fill characters.""" config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True config.canvas_width = 3 config.canvas_height = 3 return Terminal(input_data="ab\ncd", config=config) def get_char(terminal: Terminal, column: int, row: int) -> EffectCharacter: """Fetch a terminal character by coordinate and assert it exists.""" character = terminal.get_character_by_input_coord(Coord(column, row)) assert character is not None return character def set_neighbors( subject: EffectCharacter, *, north: EffectCharacter | None = None, east: EffectCharacter | None = None, south: EffectCharacter | None = None, west: EffectCharacter | None = None, ) -> None: """Overwrite the subject's neighbor map for a deterministic test setup.""" subject.neighbors.clear() subject.neighbors.update( { "north": north, "east": east, "south": south, "west": west, }, ) def make_generator( terminal: Terminal, starting_char: EffectCharacter, *, limit_to_text_boundary: bool = False, ) -> PrimsWeighted: """Construct a deterministic generator by pinning all random weights to zero initially.""" def fake_randint(start: int, stop: int) -> int: """Return a deterministic weight for constructor-time initialization.""" assert (start, stop) == (0, 99) return 0 original_randint = PrimsWeighted.__init__.__globals__["random"].randint PrimsWeighted.__init__.__globals__["random"].randint = fake_randint try: return PrimsWeighted(terminal, starting_char=starting_char, limit_to_text_boundary=limit_to_text_boundary) finally: PrimsWeighted.__init__.__globals__["random"].randint = original_randint def test_prims_weighted_init_uses_explicit_starting_character() -> None: """Verify initialization stores the provided starting character and seeds pending links.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = make_generator(terminal, starting_char) assert generator.limit_to_text_boundary is False assert generator._current_char is starting_char assert generator.char_last_linked is starting_char assert generator.char_link_order == [starting_char] assert generator.neighbors_last_added assert generator.complete is False assert generator._pending_weighted_links def test_prims_weighted_init_selects_random_starting_character_when_not_provided( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization resolves a random starting character and honors text-boundary selection.""" terminal = make_terminal() random_start = get_char(terminal, 2, 1) def fake_random_coord(*, within_text_boundary: bool = False) -> Coord: """Return a deterministic coordinate for random start lookup.""" assert within_text_boundary is True return Coord(2, 1) def fake_randint(start: int, stop: int) -> int: """Return a deterministic constructor-time weight.""" assert (start, stop) == (0, 99) return 0 monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.primsweighted.random.randint", fake_randint, ) generator = PrimsWeighted(terminal, limit_to_text_boundary=True) assert generator._current_char is random_start assert generator.char_link_order == [random_start] def test_prims_weighted_init_raises_value_error_when_no_starting_character_is_found( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization fails when a starting character cannot be resolved.""" terminal = make_terminal() def fake_random_coord(*, within_text_boundary: bool = False) -> Coord: """Return a coordinate that does not resolve to a terminal character.""" assert within_text_boundary is False return Coord(9, 9) def fake_get_character_by_input_coord(coord: Coord) -> None: """Simulate a failed starting-character lookup.""" assert coord == Coord(9, 9) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) monkeypatch.setattr(terminal, "get_character_by_input_coord", fake_get_character_by_input_coord) with pytest.raises(ValueError, match=r"Unable to find a starting character\."): PrimsWeighted(terminal) def test_add_weighted_links_tracks_neighbors_and_buckets_links_by_weight() -> None: """Verify weighted-link creation records neighbors and stores links by the target weight.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 2, 1) generator = make_generator(terminal, starting_char) set_neighbors(starting_char, west=west_neighbor, south=south_neighbor) generator._char_weights = { west_neighbor: 3, south_neighbor: 1, } generator._pending_weighted_links = defaultdict(list) generator.add_weighted_links(starting_char) assert generator.neighbors_last_added == [south_neighbor, west_neighbor] assert [link.char_b for link in generator._pending_weighted_links[1]] == [south_neighbor] assert [link.char_b for link in generator._pending_weighted_links[3]] == [west_neighbor] def test_get_lowest_weight_link_skips_stale_links_and_returns_next_lowest_valid_link( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify stale links are discarded until the lowest-weight unlinked target is found.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) stale_target = get_char(terminal, 1, 2) fresh_target = get_char(terminal, 2, 1) stale_target._link(get_char(terminal, 1, 1)) generator = make_generator(terminal, starting_char) generator._pending_weighted_links = defaultdict( list, { 1: [WeightedLink(starting_char, stale_target, 1)], 2: [WeightedLink(starting_char, fresh_target, 2)], }, ) def fake_randrange(stop: int) -> int: """Select the only weighted link available at each weight bucket.""" assert stop == 1 return 0 monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.primsweighted.random.randrange", fake_randrange, ) link = generator.get_lowest_weight_link() assert link == WeightedLink(starting_char, fresh_target, 2) assert generator._pending_weighted_links == {} def test_prims_weighted_step_links_lowest_weight_neighbor_and_adds_new_candidates( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a step links the lowest-weight neighbor and queues its unlinked neighbors.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 2, 1) downstream_neighbor = get_char(terminal, 1, 1) generator = make_generator(terminal, starting_char) set_neighbors(starting_char, west=west_neighbor, south=south_neighbor) set_neighbors(south_neighbor, north=starting_char, west=downstream_neighbor) generator._char_weights = { west_neighbor: 5, south_neighbor: 1, downstream_neighbor: 4, } generator._pending_weighted_links = defaultdict(list) generator.add_weighted_links(starting_char) def fake_randrange(stop: int) -> int: """Select the only weighted link present in each candidate bucket.""" assert stop == 1 return 0 monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.primsweighted.random.randrange", fake_randrange, ) generator.step() assert south_neighbor in starting_char.links assert generator.char_last_linked is south_neighbor assert generator.char_link_order == [starting_char, south_neighbor] assert generator.neighbors_last_added == [downstream_neighbor] assert [link.char_b for link in generator._pending_weighted_links[4]] == [downstream_neighbor] assert [link.char_b for link in generator._pending_weighted_links[5]] == [west_neighbor] assert generator.complete is False def test_prims_weighted_step_marks_complete_when_only_stale_pending_links_remain( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a step marks the generator complete when all pending links target linked characters.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) stale_target = get_char(terminal, 1, 2) stale_target._link(get_char(terminal, 1, 1)) generator = make_generator(terminal, starting_char) generator._pending_weighted_links = defaultdict( list, { 1: [WeightedLink(starting_char, stale_target, 1)], }, ) def fake_randrange(stop: int) -> int: """Select the only stale weighted link in the pending pool.""" assert stop == 1 return 0 monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.primsweighted.random.randrange", fake_randrange, ) generator.step() assert generator.complete is True assert generator.char_last_linked is starting_char def test_prims_weighted_step_marks_complete_and_clears_last_neighbors_when_no_pending_links_remain() -> None: """Verify a step marks the generator complete and clears transient state when no links are pending.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = make_generator(terminal, starting_char) generator._pending_weighted_links = defaultdict(list) generator.neighbors_last_added = [get_char(terminal, 2, 2)] generator.step() assert generator.complete is True assert generator.char_last_linked is None assert generator.neighbors_last_added == [] def test_prims_weighted_limit_to_text_boundary_blocks_outer_fill_neighbors() -> None: """Verify text-boundary filtering excludes outer-fill neighbors from pending weighted links.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) in_text_neighbor = get_char(terminal, 1, 2) outer_fill_neighbor = get_char(terminal, 3, 2) generator = make_generator(terminal, starting_char, limit_to_text_boundary=True) set_neighbors(starting_char, west=in_text_neighbor, east=outer_fill_neighbor) generator._char_weights = { in_text_neighbor: 2, outer_fill_neighbor: 1, } generator._pending_weighted_links = defaultdict(list) generator.add_weighted_links(starting_char) assert generator.neighbors_last_added == [in_text_neighbor] assert list(generator._pending_weighted_links) == [2] terminaltexteffects-release-0.15.0/tests/utils_tests/test_spanningtree_recursivebacktracker.py000066400000000000000000000173471517776150200334400ustar00rootroot00000000000000"""Tests for the recursive backtracker spanning-tree generator.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from terminaltexteffects.engine.terminal import Terminal, TerminalConfig from terminaltexteffects.utils.geometry import Coord from terminaltexteffects.utils.spanningtree.algo.recursivebacktracker import RecursiveBacktracker if TYPE_CHECKING: from terminaltexteffects.engine.base_character import EffectCharacter pytestmark = [pytest.mark.utils, pytest.mark.smoke] def make_terminal() -> Terminal: """Build a compact terminal with both text and outer-fill characters.""" config = TerminalConfig._build_config() config.ignore_terminal_dimensions = True config.canvas_width = 3 config.canvas_height = 3 return Terminal(input_data="ab\ncd", config=config) def get_char(terminal: Terminal, column: int, row: int) -> EffectCharacter: """Fetch a terminal character by coordinate and assert it exists.""" character = terminal.get_character_by_input_coord(Coord(column, row)) assert character is not None return character def set_neighbors( subject: EffectCharacter, *, north: EffectCharacter | None = None, east: EffectCharacter | None = None, south: EffectCharacter | None = None, west: EffectCharacter | None = None, ) -> None: """Overwrite the subject's neighbor map for a deterministic test setup.""" subject.neighbors.clear() subject.neighbors.update( { "north": north, "east": east, "south": south, "west": west, }, ) def test_recursive_backtracker_init_uses_explicit_starting_character() -> None: """Verify initialization stores the provided starting character and default state.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = RecursiveBacktracker(terminal, starting_char=starting_char) assert generator.limit_to_text_boundary is False assert generator._current_char is starting_char assert generator.char_last_linked is starting_char assert generator.char_link_order == [starting_char] assert generator.stack == [starting_char] assert generator.stack_last_popped is None assert generator.complete is False def test_recursive_backtracker_init_selects_random_starting_character_when_not_provided( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization resolves a random starting character when none is provided.""" terminal = make_terminal() random_start = get_char(terminal, 2, 1) def fake_random_coord(*, within_text_boundary: bool = False) -> Coord: """Return a deterministic coordinate for the random start lookup.""" assert within_text_boundary is False return Coord(2, 1) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) generator = RecursiveBacktracker(terminal) assert generator._current_char is random_start assert generator.char_link_order == [random_start] assert generator.stack == [random_start] def test_recursive_backtracker_init_raises_value_error_when_no_starting_character_is_found( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify initialization fails when a starting character cannot be resolved.""" terminal = make_terminal() def fake_random_coord(*, within_text_boundary: bool = False) -> Coord: """Return a coordinate that does not resolve to a terminal character.""" assert within_text_boundary is False return Coord(9, 9) def fake_get_character_by_input_coord(coord: Coord) -> None: """Simulate a failed starting-character lookup.""" assert coord == Coord(9, 9) monkeypatch.setattr(terminal.canvas, "random_coord", fake_random_coord) monkeypatch.setattr(terminal, "get_character_by_input_coord", fake_get_character_by_input_coord) with pytest.raises(ValueError, match=r"Unable to find a starting character\."): RecursiveBacktracker(terminal) def test_recursive_backtracker_step_links_an_unvisited_neighbor( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify a step links the chosen unvisited neighbor and advances the stack.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) west_neighbor = get_char(terminal, 1, 2) south_neighbor = get_char(terminal, 2, 1) generator = RecursiveBacktracker(terminal, starting_char=starting_char) set_neighbors(starting_char, west=west_neighbor, south=south_neighbor) def fake_choice(neighbors: list[EffectCharacter]) -> EffectCharacter: """Return the west neighbor from the provided candidate list.""" assert west_neighbor in neighbors return west_neighbor monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.recursivebacktracker.random.choice", fake_choice, ) generator.step() assert west_neighbor in starting_char.links assert starting_char in west_neighbor.links assert generator._current_char is west_neighbor assert generator.char_last_linked is west_neighbor assert generator.char_link_order == [starting_char, west_neighbor] assert generator.stack == [starting_char, west_neighbor] assert generator.stack_last_popped is None assert generator.complete is False def test_recursive_backtracker_step_backtracks_when_current_character_has_no_unvisited_neighbors() -> None: """Verify a step pops the stack and moves back when no unvisited neighbors remain.""" terminal = make_terminal() root_char = get_char(terminal, 1, 2) current_char = get_char(terminal, 2, 2) linked_neighbor = get_char(terminal, 2, 1) linked_neighbor._link(get_char(terminal, 1, 1)) generator = RecursiveBacktracker(terminal, starting_char=root_char) generator.stack.append(current_char) generator._current_char = current_char set_neighbors(current_char, south=linked_neighbor) generator.step() assert generator._current_char is root_char assert generator.char_last_linked is None assert generator.stack == [root_char] assert generator.stack_last_popped is current_char assert generator.complete is False def test_recursive_backtracker_step_marks_complete_when_stack_is_empty() -> None: """Verify a step marks the generator complete when there is no remaining traversal stack.""" terminal = make_terminal() starting_char = get_char(terminal, 1, 2) generator = RecursiveBacktracker(terminal, starting_char=starting_char) generator.stack.clear() generator.step() assert generator.complete is True assert generator.char_last_linked is None assert generator.stack_last_popped is None def test_recursive_backtracker_limit_to_text_boundary_blocks_outer_fill_neighbors( monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify text-boundary filtering prevents linking to outer-fill neighbors.""" terminal = make_terminal() starting_char = get_char(terminal, 2, 2) in_text_neighbor = get_char(terminal, 1, 2) outer_fill_neighbor = get_char(terminal, 3, 2) generator = RecursiveBacktracker(terminal, starting_char=starting_char, limit_to_text_boundary=True) set_neighbors(starting_char, west=in_text_neighbor, east=outer_fill_neighbor) def fake_choice(neighbors: list[EffectCharacter]) -> EffectCharacter: """Return the only in-text candidate after boundary filtering.""" assert neighbors == [in_text_neighbor] return neighbors[0] monkeypatch.setattr( "terminaltexteffects.utils.spanningtree.algo.recursivebacktracker.random.choice", fake_choice, ) generator.step() assert in_text_neighbor in starting_char.links assert outer_fill_neighbor not in starting_char.links assert generator.char_last_linked is in_text_neighbor terminaltexteffects-release-0.15.0/tox.ini000066400000000000000000000003231517776150200206170ustar00rootroot00000000000000[tox] envlist = py38, py39, py310, py311, py312 isolated_build = True skip_missing_interpreters = True [testenv] deps = pytest pytest-cov pytest-randomly pytest-xdist commands = pytest -m smoke -n=auto terminaltexteffects-release-0.15.0/uv.lock000066400000000000000000011014661517776150200206230ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", "python_full_version < '3.9'", ] [[package]] name = "astunparse" version = "1.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six", marker = "python_full_version < '3.9'" }, { name = "wheel", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/af/4182184d3c338792894f34a62672919db7ca008c89abee9b564dd34d8029/astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", size = 18290, upload-time = "2019-12-22T18:12:13.129Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2b/03/13dde6512ad7b4557eb792fbcf0c653af6076b81e5941d36ec61f7ce6028/astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8", size = 12732, upload-time = "2019-12-22T18:12:11.297Z" }, ] [[package]] name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytz", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] [[package]] name = "backrefs" version = "5.7.post1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/df/30/903f35159c87ff1d92aa3fcf8cb52de97632a21e0ae43ed940f5d033e01a/backrefs-5.7.post1.tar.gz", hash = "sha256:8b0f83b770332ee2f1c8244f4e03c77d127a0fa529328e6a0e77fa25bee99678", size = 6582270, upload-time = "2024-06-16T18:38:20.166Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/24/bb/47fc255d1060dcfd55b460236380edd8ebfc5b2a42a0799ca90c9fc983e3/backrefs-5.7.post1-py310-none-any.whl", hash = "sha256:c5e3fd8fd185607a7cb1fefe878cfb09c34c0be3c18328f12c574245f1c0287e", size = 380429, upload-time = "2024-06-16T18:38:10.131Z" }, { url = "https://files.pythonhosted.org/packages/89/72/39ef491caef3abae945f5a5fd72830d3b596bfac0630508629283585e213/backrefs-5.7.post1-py311-none-any.whl", hash = "sha256:712ea7e494c5bf3291156e28954dd96d04dc44681d0e5c030adf2623d5606d51", size = 392234, upload-time = "2024-06-16T18:38:12.283Z" }, { url = "https://files.pythonhosted.org/packages/6a/00/33403f581b732ca70fdebab558e8bbb426a29c34e0c3ed674a479b74beea/backrefs-5.7.post1-py312-none-any.whl", hash = "sha256:a6142201c8293e75bce7577ac29e1a9438c12e730d73a59efdd1b75528d1a6c5", size = 398110, upload-time = "2024-06-16T18:38:14.257Z" }, { url = "https://files.pythonhosted.org/packages/5d/ea/df0ac74a26838f6588aa012d5d801831448b87d0a7d0aefbbfabbe894870/backrefs-5.7.post1-py38-none-any.whl", hash = "sha256:ec61b1ee0a4bfa24267f6b67d0f8c5ffdc8e0d7dc2f18a2685fd1d8d9187054a", size = 369477, upload-time = "2024-06-16T18:38:16.196Z" }, { url = "https://files.pythonhosted.org/packages/6f/e8/e43f535c0a17a695e5768670fc855a0e5d52dc0d4135b3915bfa355f65ac/backrefs-5.7.post1-py39-none-any.whl", hash = "sha256:05c04af2bf752bb9a6c9dcebb2aff2fab372d3d9d311f2a138540e307756bd3a", size = 380429, upload-time = "2024-06-16T18:38:18.079Z" }, ] [[package]] name = "backrefs" version = "5.9" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] [[package]] name = "certifi" version = "2025.10.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, { url = "https://files.pythonhosted.org/packages/0a/4e/3926a1c11f0433791985727965263f788af00db3482d89a7545ca5ecc921/charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", size = 198599, upload-time = "2025-10-14T04:41:53.213Z" }, { url = "https://files.pythonhosted.org/packages/ec/7c/b92d1d1dcffc34592e71ea19c882b6709e43d20fa498042dea8b815638d7/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", size = 143090, upload-time = "2025-10-14T04:41:54.385Z" }, { url = "https://files.pythonhosted.org/packages/84/ce/61a28d3bb77281eb24107b937a497f3c43089326d27832a63dcedaab0478/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", size = 139490, upload-time = "2025-10-14T04:41:55.551Z" }, { url = "https://files.pythonhosted.org/packages/c0/bd/c9e59a91b2061c6f8bb98a150670cb16d4cd7c4ba7d11ad0cdf789155f41/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", size = 155334, upload-time = "2025-10-14T04:41:56.724Z" }, { url = "https://files.pythonhosted.org/packages/bf/37/f17ae176a80f22ff823456af91ba3bc59df308154ff53aef0d39eb3d3419/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", size = 152823, upload-time = "2025-10-14T04:41:58.236Z" }, { url = "https://files.pythonhosted.org/packages/bf/fa/cf5bb2409a385f78750e78c8d2e24780964976acdaaed65dbd6083ae5b40/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", size = 147618, upload-time = "2025-10-14T04:41:59.409Z" }, { url = "https://files.pythonhosted.org/packages/9b/63/579784a65bc7de2d4518d40bb8f1870900163e86f17f21fd1384318c459d/charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", size = 145516, upload-time = "2025-10-14T04:42:00.579Z" }, { url = "https://files.pythonhosted.org/packages/a3/a9/94ec6266cd394e8f93a4d69cca651d61bf6ac58d2a0422163b30c698f2c7/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", size = 145266, upload-time = "2025-10-14T04:42:01.684Z" }, { url = "https://files.pythonhosted.org/packages/09/14/d6626eb97764b58c2779fa7928fa7d1a49adb8ce687c2dbba4db003c1939/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", size = 139559, upload-time = "2025-10-14T04:42:02.902Z" }, { url = "https://files.pythonhosted.org/packages/09/01/ddbe6b01313ba191dbb0a43c7563bc770f2448c18127f9ea4b119c44dff0/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", size = 156653, upload-time = "2025-10-14T04:42:04.005Z" }, { url = "https://files.pythonhosted.org/packages/95/c8/d05543378bea89296e9af4510b44c704626e191da447235c8fdedfc5b7b2/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", size = 145644, upload-time = "2025-10-14T04:42:05.211Z" }, { url = "https://files.pythonhosted.org/packages/72/01/2866c4377998ef8a1f6802f6431e774a4c8ebe75b0a6e569ceec55c9cbfb/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", size = 153964, upload-time = "2025-10-14T04:42:06.341Z" }, { url = "https://files.pythonhosted.org/packages/4a/66/66c72468a737b4cbd7851ba2c522fe35c600575fbeac944460b4fd4a06fe/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", size = 148777, upload-time = "2025-10-14T04:42:07.535Z" }, { url = "https://files.pythonhosted.org/packages/50/94/d0d56677fdddbffa8ca00ec411f67bb8c947f9876374ddc9d160d4f2c4b3/charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", size = 98687, upload-time = "2025-10-14T04:42:08.678Z" }, { url = "https://files.pythonhosted.org/packages/00/64/c3bc303d1b586480b1c8e6e1e2191a6d6dd40255244e5cf16763dcec52e6/charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", size = 106115, upload-time = "2025-10-14T04:42:09.793Z" }, { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609, upload-time = "2025-10-14T04:42:10.922Z" }, { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029, upload-time = "2025-10-14T04:42:12.38Z" }, { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580, upload-time = "2025-10-14T04:42:13.549Z" }, { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340, upload-time = "2025-10-14T04:42:14.892Z" }, { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619, upload-time = "2025-10-14T04:42:16.676Z" }, { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980, upload-time = "2025-10-14T04:42:17.917Z" }, { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174, upload-time = "2025-10-14T04:42:19.018Z" }, { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666, upload-time = "2025-10-14T04:42:20.171Z" }, { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550, upload-time = "2025-10-14T04:42:21.324Z" }, { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721, upload-time = "2025-10-14T04:42:22.46Z" }, { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127, upload-time = "2025-10-14T04:42:23.712Z" }, { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175, upload-time = "2025-10-14T04:42:24.87Z" }, { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375, upload-time = "2025-10-14T04:42:27.246Z" }, { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692, upload-time = "2025-10-14T04:42:28.425Z" }, { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192, upload-time = "2025-10-14T04:42:29.482Z" }, { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220, upload-time = "2025-10-14T04:42:30.632Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.9.*'", "python_full_version < '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] name = "click" version = "8.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" version = "7.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791, upload-time = "2024-08-04T19:45:30.9Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690, upload-time = "2024-08-04T19:43:07.695Z" }, { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127, upload-time = "2024-08-04T19:43:10.15Z" }, { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654, upload-time = "2024-08-04T19:43:12.405Z" }, { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598, upload-time = "2024-08-04T19:43:14.078Z" }, { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732, upload-time = "2024-08-04T19:43:16.632Z" }, { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816, upload-time = "2024-08-04T19:43:19.049Z" }, { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325, upload-time = "2024-08-04T19:43:21.246Z" }, { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418, upload-time = "2024-08-04T19:43:22.945Z" }, { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343, upload-time = "2024-08-04T19:43:25.121Z" }, { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136, upload-time = "2024-08-04T19:43:26.851Z" }, { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796, upload-time = "2024-08-04T19:43:29.115Z" }, { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244, upload-time = "2024-08-04T19:43:31.285Z" }, { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279, upload-time = "2024-08-04T19:43:33.581Z" }, { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859, upload-time = "2024-08-04T19:43:35.301Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549, upload-time = "2024-08-04T19:43:37.578Z" }, { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477, upload-time = "2024-08-04T19:43:39.92Z" }, { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134, upload-time = "2024-08-04T19:43:41.453Z" }, { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910, upload-time = "2024-08-04T19:43:43.037Z" }, { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348, upload-time = "2024-08-04T19:43:44.787Z" }, { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230, upload-time = "2024-08-04T19:43:46.707Z" }, { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983, upload-time = "2024-08-04T19:43:49.082Z" }, { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221, upload-time = "2024-08-04T19:43:52.15Z" }, { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342, upload-time = "2024-08-04T19:43:53.746Z" }, { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371, upload-time = "2024-08-04T19:43:55.993Z" }, { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455, upload-time = "2024-08-04T19:43:57.618Z" }, { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924, upload-time = "2024-08-04T19:44:00.012Z" }, { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252, upload-time = "2024-08-04T19:44:01.713Z" }, { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897, upload-time = "2024-08-04T19:44:03.898Z" }, { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606, upload-time = "2024-08-04T19:44:05.532Z" }, { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373, upload-time = "2024-08-04T19:44:07.079Z" }, { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007, upload-time = "2024-08-04T19:44:09.453Z" }, { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269, upload-time = "2024-08-04T19:44:11.045Z" }, { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886, upload-time = "2024-08-04T19:44:12.83Z" }, { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037, upload-time = "2024-08-04T19:44:15.393Z" }, { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038, upload-time = "2024-08-04T19:44:17.466Z" }, { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690, upload-time = "2024-08-04T19:44:19.336Z" }, { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765, upload-time = "2024-08-04T19:44:20.994Z" }, { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611, upload-time = "2024-08-04T19:44:22.616Z" }, { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671, upload-time = "2024-08-04T19:44:24.418Z" }, { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368, upload-time = "2024-08-04T19:44:26.276Z" }, { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758, upload-time = "2024-08-04T19:44:29.028Z" }, { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035, upload-time = "2024-08-04T19:44:30.673Z" }, { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839, upload-time = "2024-08-04T19:44:32.412Z" }, { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569, upload-time = "2024-08-04T19:44:34.547Z" }, { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927, upload-time = "2024-08-04T19:44:36.313Z" }, { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401, upload-time = "2024-08-04T19:44:38.155Z" }, { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301, upload-time = "2024-08-04T19:44:39.883Z" }, { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598, upload-time = "2024-08-04T19:44:41.59Z" }, { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307, upload-time = "2024-08-04T19:44:43.301Z" }, { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453, upload-time = "2024-08-04T19:44:45.677Z" }, { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674, upload-time = "2024-08-04T19:44:47.694Z" }, { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101, upload-time = "2024-08-04T19:44:49.32Z" }, { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554, upload-time = "2024-08-04T19:44:51.631Z" }, { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440, upload-time = "2024-08-04T19:44:53.464Z" }, { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889, upload-time = "2024-08-04T19:44:55.165Z" }, { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142, upload-time = "2024-08-04T19:44:57.269Z" }, { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805, upload-time = "2024-08-04T19:44:59.033Z" }, { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655, upload-time = "2024-08-04T19:45:01.398Z" }, { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296, upload-time = "2024-08-04T19:45:03.819Z" }, { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137, upload-time = "2024-08-04T19:45:06.25Z" }, { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688, upload-time = "2024-08-04T19:45:08.358Z" }, { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120, upload-time = "2024-08-04T19:45:11.526Z" }, { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249, upload-time = "2024-08-04T19:45:13.202Z" }, { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237, upload-time = "2024-08-04T19:45:14.961Z" }, { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311, upload-time = "2024-08-04T19:45:16.924Z" }, { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453, upload-time = "2024-08-04T19:45:18.672Z" }, { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958, upload-time = "2024-08-04T19:45:20.63Z" }, { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938, upload-time = "2024-08-04T19:45:23.062Z" }, { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352, upload-time = "2024-08-04T19:45:25.042Z" }, { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153, upload-time = "2024-08-04T19:45:27.079Z" }, { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926, upload-time = "2024-08-04T19:45:28.875Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version < '3.9'" }, ] [[package]] name = "coverage" version = "7.10.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version == '3.9.*'" }, ] [[package]] name = "coverage" version = "7.11.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fd/68/b53157115ef76d50d1d916d6240e5cd5b3c14dba8ba1b984632b8221fc2e/coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5", size = 216377, upload-time = "2025-11-10T00:10:27.317Z" }, { url = "https://files.pythonhosted.org/packages/14/c1/d2f9d8e37123fe6e7ab8afcaab8195f13bc84a8b2f449a533fd4812ac724/coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7", size = 216892, upload-time = "2025-11-10T00:10:30.624Z" }, { url = "https://files.pythonhosted.org/packages/83/73/18f05d8010149b650ed97ee5c9f7e4ae68c05c7d913391523281e41c2495/coverage-7.11.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb", size = 243650, upload-time = "2025-11-10T00:10:32.392Z" }, { url = "https://files.pythonhosted.org/packages/63/3c/c0cbb296c0ecc6dcbd70f4b473fcd7fe4517bbef8b09f4326d78f38adb87/coverage-7.11.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1", size = 245478, upload-time = "2025-11-10T00:10:34.157Z" }, { url = "https://files.pythonhosted.org/packages/b9/9a/dad288cf9faa142a14e75e39dc646d968b93d74e15c83e9b13fd628f2cb3/coverage-7.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c", size = 247337, upload-time = "2025-11-10T00:10:35.655Z" }, { url = "https://files.pythonhosted.org/packages/e3/ba/f6148ebf5547b3502013175e41bf3107a4e34b7dd19f9793a6ce0e1cd61f/coverage-7.11.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31", size = 244328, upload-time = "2025-11-10T00:10:37.459Z" }, { url = "https://files.pythonhosted.org/packages/e6/4d/b93784d0b593c5df89a0d48cbbd2d0963e0ca089eaf877405849792e46d3/coverage-7.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2", size = 245381, upload-time = "2025-11-10T00:10:39.229Z" }, { url = "https://files.pythonhosted.org/packages/3a/8d/6735bfd4f0f736d457642ee056a570d704c9d57fdcd5c91ea5d6b15c944e/coverage-7.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507", size = 243390, upload-time = "2025-11-10T00:10:40.984Z" }, { url = "https://files.pythonhosted.org/packages/db/3d/7ba68ed52d1873d450aefd8d2f5a353e67b421915cb6c174e4222c7b918c/coverage-7.11.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832", size = 243654, upload-time = "2025-11-10T00:10:42.496Z" }, { url = "https://files.pythonhosted.org/packages/14/26/be2720c4c7bf73c6591ae4ab503a7b5a31c7a60ced6dba855cfcb4a5af7e/coverage-7.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e", size = 244272, upload-time = "2025-11-10T00:10:44.39Z" }, { url = "https://files.pythonhosted.org/packages/90/20/086f5697780df146dbc0df4ae9b6db2b23ddf5aa550f977b2825137728e9/coverage-7.11.3-cp310-cp310-win32.whl", hash = "sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb", size = 218969, upload-time = "2025-11-10T00:10:45.863Z" }, { url = "https://files.pythonhosted.org/packages/98/5c/cc6faba945ede5088156da7770e30d06c38b8591785ac99bcfb2074f9ef6/coverage-7.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8", size = 219903, upload-time = "2025-11-10T00:10:47.676Z" }, { url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504, upload-time = "2025-11-10T00:10:49.524Z" }, { url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006, upload-time = "2025-11-10T00:10:51.32Z" }, { url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415, upload-time = "2025-11-10T00:10:52.805Z" }, { url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332, upload-time = "2025-11-10T00:10:54.35Z" }, { url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443, upload-time = "2025-11-10T00:10:55.832Z" }, { url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554, upload-time = "2025-11-10T00:10:57.829Z" }, { url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139, upload-time = "2025-11-10T00:10:59.465Z" }, { url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209, upload-time = "2025-11-10T00:11:01.432Z" }, { url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936, upload-time = "2025-11-10T00:11:02.964Z" }, { url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835, upload-time = "2025-11-10T00:11:04.405Z" }, { url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994, upload-time = "2025-11-10T00:11:05.966Z" }, { url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926, upload-time = "2025-11-10T00:11:07.936Z" }, { url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599, upload-time = "2025-11-10T00:11:09.578Z" }, { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload-time = "2025-11-10T00:11:11.566Z" }, { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload-time = "2025-11-10T00:11:13.12Z" }, { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload-time = "2025-11-10T00:11:15.023Z" }, { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload-time = "2025-11-10T00:11:16.628Z" }, { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload-time = "2025-11-10T00:11:18.249Z" }, { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload-time = "2025-11-10T00:11:20.202Z" }, { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload-time = "2025-11-10T00:11:21.689Z" }, { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload-time = "2025-11-10T00:11:23.384Z" }, { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload-time = "2025-11-10T00:11:25.07Z" }, { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload-time = "2025-11-10T00:11:26.992Z" }, { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload-time = "2025-11-10T00:11:28.619Z" }, { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload-time = "2025-11-10T00:11:30.234Z" }, { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload-time = "2025-11-10T00:11:32.165Z" }, { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload-time = "2025-11-10T00:11:34.296Z" }, { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload-time = "2025-11-10T00:11:36.281Z" }, { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload-time = "2025-11-10T00:11:37.903Z" }, { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload-time = "2025-11-10T00:11:39.509Z" }, { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload-time = "2025-11-10T00:11:41.372Z" }, { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload-time = "2025-11-10T00:11:43.27Z" }, { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload-time = "2025-11-10T00:11:45.274Z" }, { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload-time = "2025-11-10T00:11:46.93Z" }, { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload-time = "2025-11-10T00:11:48.563Z" }, { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload-time = "2025-11-10T00:11:50.581Z" }, { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload-time = "2025-11-10T00:11:52.184Z" }, { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload-time = "2025-11-10T00:11:53.871Z" }, { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload-time = "2025-11-10T00:11:55.597Z" }, { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload-time = "2025-11-10T00:11:57.59Z" }, { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload-time = "2025-11-10T00:11:59.519Z" }, { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload-time = "2025-11-10T00:12:01.592Z" }, { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload-time = "2025-11-10T00:12:03.639Z" }, { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload-time = "2025-11-10T00:12:05.485Z" }, { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload-time = "2025-11-10T00:12:07.137Z" }, { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload-time = "2025-11-10T00:12:09.138Z" }, { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload-time = "2025-11-10T00:12:11.195Z" }, { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload-time = "2025-11-10T00:12:12.843Z" }, { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload-time = "2025-11-10T00:12:15.128Z" }, { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload-time = "2025-11-10T00:12:17.274Z" }, { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload-time = "2025-11-10T00:12:19.292Z" }, { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload-time = "2025-11-10T00:12:21.251Z" }, { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload-time = "2025-11-10T00:12:28.555Z" }, { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload-time = "2025-11-10T00:12:30.246Z" }, { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload-time = "2025-11-10T00:12:32.195Z" }, { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload-time = "2025-11-10T00:12:33.969Z" }, { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload-time = "2025-11-10T00:12:36.341Z" }, { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload-time = "2025-11-10T00:12:38.167Z" }, { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload-time = "2025-11-10T00:12:40.354Z" }, { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload-time = "2025-11-10T00:12:42.231Z" }, { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload-time = "2025-11-10T00:12:44.065Z" }, { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload-time = "2025-11-10T00:12:46.18Z" }, { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload-time = "2025-11-10T00:12:48.392Z" }, { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload-time = "2025-11-10T00:12:50.182Z" }, { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload-time = "2025-11-10T00:12:52.451Z" }, { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload-time = "2025-11-10T00:12:54.667Z" }, { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload-time = "2025-11-10T00:12:56.841Z" }, { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload-time = "2025-11-10T00:12:58.687Z" }, { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload-time = "2025-11-10T00:13:00.502Z" }, { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload-time = "2025-11-10T00:13:02.747Z" }, { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload-time = "2025-11-10T00:13:04.689Z" }, { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload-time = "2025-11-10T00:13:06.559Z" }, { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload-time = "2025-11-10T00:13:08.635Z" }, { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload-time = "2025-11-10T00:13:10.734Z" }, { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload-time = "2025-11-10T00:13:12.712Z" }, { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, ] [[package]] name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "ghp-import" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] name = "griffe" version = "1.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "astunparse", marker = "python_full_version < '3.9'" }, { name = "colorama", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/05/e9/b2c86ad9d69053e497a24ceb25d661094fb321ab4ed39a8b71793dcbae82/griffe-1.4.0.tar.gz", hash = "sha256:8fccc585896d13f1221035d32c50dec65830c87d23f9adb9b1e6f3d63574f7f5", size = 381028, upload-time = "2024-10-11T12:53:54.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/7c/e9e66869c2e4c9b378474e49c993128ec0131ef4721038b6d06e50538caf/griffe-1.4.0-py3-none-any.whl", hash = "sha256:e589de8b8c137e99a46ec45f9598fc0ac5b6868ce824b24db09c02d117b89bc5", size = 127015, upload-time = "2024-10-11T12:53:52.383Z" }, ] [[package]] name = "griffe" version = "1.14.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "importlib-metadata" version = "8.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ { name = "zipp", version = "3.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.9.*'", "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "markdown" version = "3.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086, upload-time = "2024-08-16T15:55:17.812Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349, upload-time = "2024-08-16T15:55:16.176Z" }, ] [[package]] name = "markdown" version = "3.9" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "importlib-metadata", version = "8.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, ] [[package]] name = "markupsafe" version = "2.1.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206, upload-time = "2024-02-02T16:30:04.105Z" }, { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079, upload-time = "2024-02-02T16:30:06.5Z" }, { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620, upload-time = "2024-02-02T16:30:08.31Z" }, { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818, upload-time = "2024-02-02T16:30:09.577Z" }, { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493, upload-time = "2024-02-02T16:30:11.488Z" }, { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630, upload-time = "2024-02-02T16:30:13.144Z" }, { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745, upload-time = "2024-02-02T16:30:14.222Z" }, { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021, upload-time = "2024-02-02T16:30:16.032Z" }, { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659, upload-time = "2024-02-02T16:30:17.079Z" }, { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213, upload-time = "2024-02-02T16:30:18.251Z" }, { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219, upload-time = "2024-02-02T16:30:19.988Z" }, { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098, upload-time = "2024-02-02T16:30:21.063Z" }, { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014, upload-time = "2024-02-02T16:30:22.926Z" }, { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220, upload-time = "2024-02-02T16:30:24.76Z" }, { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756, upload-time = "2024-02-02T16:30:25.877Z" }, { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988, upload-time = "2024-02-02T16:30:26.935Z" }, { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718, upload-time = "2024-02-02T16:30:28.111Z" }, { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317, upload-time = "2024-02-02T16:30:29.214Z" }, { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670, upload-time = "2024-02-02T16:30:30.915Z" }, { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224, upload-time = "2024-02-02T16:30:32.09Z" }, { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215, upload-time = "2024-02-02T16:30:33.081Z" }, { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069, upload-time = "2024-02-02T16:30:34.148Z" }, { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452, upload-time = "2024-02-02T16:30:35.149Z" }, { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462, upload-time = "2024-02-02T16:30:36.166Z" }, { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869, upload-time = "2024-02-02T16:30:37.834Z" }, { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906, upload-time = "2024-02-02T16:30:39.366Z" }, { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296, upload-time = "2024-02-02T16:30:40.413Z" }, { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038, upload-time = "2024-02-02T16:30:42.243Z" }, { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572, upload-time = "2024-02-02T16:30:43.326Z" }, { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127, upload-time = "2024-02-02T16:30:44.418Z" }, { url = "https://files.pythonhosted.org/packages/f8/ff/2c942a82c35a49df5de3a630ce0a8456ac2969691b230e530ac12314364c/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", size = 18192, upload-time = "2024-02-02T16:30:57.715Z" }, { url = "https://files.pythonhosted.org/packages/4f/14/6f294b9c4f969d0c801a4615e221c1e084722ea6114ab2114189c5b8cbe0/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", size = 14072, upload-time = "2024-02-02T16:30:58.844Z" }, { url = "https://files.pythonhosted.org/packages/81/d4/fd74714ed30a1dedd0b82427c02fa4deec64f173831ec716da11c51a50aa/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", size = 26928, upload-time = "2024-02-02T16:30:59.922Z" }, { url = "https://files.pythonhosted.org/packages/c7/bd/50319665ce81bb10e90d1cf76f9e1aa269ea6f7fa30ab4521f14d122a3df/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", size = 26106, upload-time = "2024-02-02T16:31:01.582Z" }, { url = "https://files.pythonhosted.org/packages/4c/6f/f2b0f675635b05f6afd5ea03c094557bdb8622fa8e673387444fe8d8e787/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68", size = 25781, upload-time = "2024-02-02T16:31:02.71Z" }, { url = "https://files.pythonhosted.org/packages/51/e0/393467cf899b34a9d3678e78961c2c8cdf49fb902a959ba54ece01273fb1/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", size = 30518, upload-time = "2024-02-02T16:31:04.392Z" }, { url = "https://files.pythonhosted.org/packages/f6/02/5437e2ad33047290dafced9df741d9efc3e716b75583bbd73a9984f1b6f7/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", size = 29669, upload-time = "2024-02-02T16:31:05.53Z" }, { url = "https://files.pythonhosted.org/packages/0e/7d/968284145ffd9d726183ed6237c77938c021abacde4e073020f920e060b2/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", size = 29933, upload-time = "2024-02-02T16:31:06.636Z" }, { url = "https://files.pythonhosted.org/packages/bf/f3/ecb00fc8ab02b7beae8699f34db9357ae49d9f21d4d3de6f305f34fa949e/MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", size = 16656, upload-time = "2024-02-02T16:31:07.767Z" }, { url = "https://files.pythonhosted.org/packages/92/21/357205f03514a49b293e214ac39de01fadd0970a6e05e4bf1ddd0ffd0881/MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", size = 17206, upload-time = "2024-02-02T16:31:08.843Z" }, { url = "https://files.pythonhosted.org/packages/0f/31/780bb297db036ba7b7bbede5e1d7f1e14d704ad4beb3ce53fb495d22bc62/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", size = 18193, upload-time = "2024-02-02T16:31:10.155Z" }, { url = "https://files.pythonhosted.org/packages/6c/77/d77701bbef72892affe060cdacb7a2ed7fd68dae3b477a8642f15ad3b132/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", size = 14073, upload-time = "2024-02-02T16:31:11.442Z" }, { url = "https://files.pythonhosted.org/packages/d9/a7/1e558b4f78454c8a3a0199292d96159eb4d091f983bc35ef258314fe7269/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", size = 26486, upload-time = "2024-02-02T16:31:12.488Z" }, { url = "https://files.pythonhosted.org/packages/5f/5a/360da85076688755ea0cceb92472923086993e86b5613bbae9fbc14136b0/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", size = 25685, upload-time = "2024-02-02T16:31:13.726Z" }, { url = "https://files.pythonhosted.org/packages/6a/18/ae5a258e3401f9b8312f92b028c54d7026a97ec3ab20bfaddbdfa7d8cce8/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", size = 25338, upload-time = "2024-02-02T16:31:14.812Z" }, { url = "https://files.pythonhosted.org/packages/0b/cc/48206bd61c5b9d0129f4d75243b156929b04c94c09041321456fd06a876d/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", size = 30439, upload-time = "2024-02-02T16:31:15.946Z" }, { url = "https://files.pythonhosted.org/packages/d1/06/a41c112ab9ffdeeb5f77bc3e331fdadf97fa65e52e44ba31880f4e7f983c/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", size = 29531, upload-time = "2024-02-02T16:31:17.13Z" }, { url = "https://files.pythonhosted.org/packages/02/8c/ab9a463301a50dab04d5472e998acbd4080597abc048166ded5c7aa768c8/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", size = 29823, upload-time = "2024-02-02T16:31:18.247Z" }, { url = "https://files.pythonhosted.org/packages/bc/29/9bc18da763496b055d8e98ce476c8e718dcfd78157e17f555ce6dd7d0895/MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", size = 16658, upload-time = "2024-02-02T16:31:19.583Z" }, { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211, upload-time = "2024-02-02T16:31:20.96Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] name = "mkdocs" version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "ghp-import" }, { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "importlib-metadata", version = "8.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "jinja2" }, { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "mergedeep" }, { name = "mkdocs-get-deps" }, { name = "packaging" }, { name = "pathspec" }, { name = "pyyaml" }, { name = "pyyaml-env-tag", version = "0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pyyaml-env-tag", version = "1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "watchdog", version = "4.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "watchdog", version = "6.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] name = "mkdocs-autorefs" version = "1.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "mkdocs", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/ae/0f1154c614d6a8b8a36fff084e5b82af3a15f7d2060cf0dcdb1c53297a71/mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f", size = 40262, upload-time = "2024-09-01T18:29:18.514Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/26/4d39d52ea2219604053a4d05b98e90d6a335511cc01806436ec4886b1028/mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f", size = 16522, upload-time = "2024-09-01T18:29:16.605Z" }, ] [[package]] name = "mkdocs-autorefs" version = "1.4.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "mkdocs", marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, ] [[package]] name = "mkdocs-get-deps" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "importlib-metadata", version = "8.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "mergedeep" }, { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "platformdirs", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] [[package]] name = "mkdocs-material" version = "9.6.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, { name = "backrefs", version = "5.7.post1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "backrefs", version = "5.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "colorama" }, { name = "jinja2" }, { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "mkdocs" }, { name = "mkdocs-material-extensions" }, { name = "paginate" }, { name = "pygments" }, { name = "pymdown-extensions", version = "10.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pymdown-extensions", version = "10.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/5d/317e37b6c43325cb376a1d6439df9cc743b8ee41c84603c2faf7286afc82/mkdocs_material-9.6.22.tar.gz", hash = "sha256:87c158b0642e1ada6da0cbd798a3389b0bc5516b90e5ece4a0fb939f00bacd1c", size = 4044968, upload-time = "2025-10-15T09:21:15.409Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/82/6fdb9a7a04fb222f4849ffec1006f891a0280825a20314d11f3ccdee14eb/mkdocs_material-9.6.22-py3-none-any.whl", hash = "sha256:14ac5f72d38898b2f98ac75a5531aaca9366eaa427b0f49fc2ecf04d99b7ad84", size = 9206252, upload-time = "2025-10-15T09:21:12.175Z" }, ] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] name = "mkdocstrings" version = "0.26.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "jinja2", marker = "python_full_version < '3.9'" }, { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "mkdocs", marker = "python_full_version < '3.9'" }, { name = "mkdocs-autorefs", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pymdown-extensions", version = "10.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/170ff04de72227f715d67da32950c7b8434449f3805b2ec3dd1085db4d7c/mkdocstrings-0.26.1.tar.gz", hash = "sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33", size = 92677, upload-time = "2024-09-06T10:26:06.736Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/23/cc/8ba127aaee5d1e9046b0d33fa5b3d17da95a9d705d44902792e0569257fd/mkdocstrings-0.26.1-py3-none-any.whl", hash = "sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf", size = 29643, upload-time = "2024-09-06T10:26:04.498Z" }, ] [[package]] name = "mkdocstrings" version = "0.30.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "importlib-metadata", version = "8.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "jinja2", marker = "python_full_version >= '3.9'" }, { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "mkdocs", marker = "python_full_version >= '3.9'" }, { name = "mkdocs-autorefs", version = "1.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pymdown-extensions", version = "10.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, ] [[package]] name = "mkdocstrings-python" version = "1.11.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "griffe", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "mkdocs-autorefs", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "mkdocstrings", version = "0.26.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/ba/534c934cd0a809f51c91332d6ed278782ee4126b8ba8db02c2003f162b47/mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322", size = 166890, upload-time = "2024-09-03T17:20:54.904Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f2/2a2c48fda645ac6bbe73bcc974587a579092b6868e6ff8bc6d177f4db38a/mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af", size = 109297, upload-time = "2024-09-03T17:20:52.621Z" }, ] [[package]] name = "mkdocstrings-python" version = "1.18.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "griffe", version = "1.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "mkdocs-autorefs", version = "1.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "mkdocstrings", version = "0.30.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/ae/58ab2bfbee2792e92a98b97e872f7c003deb903071f75d8d83aa55db28fa/mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323", size = 207972, upload-time = "2025-08-28T16:11:19.847Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215, upload-time = "2025-08-28T16:11:18.176Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "paginate" version = "0.5.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] name = "platformdirs" version = "4.3.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, ] [[package]] name = "platformdirs" version = "4.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] [[package]] name = "platformdirs" version = "4.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pymdown-extensions" version = "10.15" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pyyaml", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/92/a7296491dbf5585b3a987f3f3fc87af0e632121ff3e490c14b5f2d2b4eb5/pymdown_extensions-10.15.tar.gz", hash = "sha256:0e5994e32155f4b03504f939e501b981d306daf7ec2aa1cd2eb6bd300784f8f7", size = 852320, upload-time = "2025-04-27T23:48:29.183Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a7/d1/c54e608505776ce4e7966d03358ae635cfd51dff1da6ee421c090dbc797b/pymdown_extensions-10.15-py3-none-any.whl", hash = "sha256:46e99bb272612b0de3b7e7caf6da8dd5f4ca5212c0b273feb9304e236c484e5f", size = 265845, upload-time = "2025-04-27T23:48:27.359Z" }, ] [[package]] name = "pymdown-extensions" version = "10.16.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pyyaml", marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, ] [[package]] name = "pytest" version = "8.3.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "packaging", marker = "python_full_version < '3.9'" }, { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "tomli", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] [[package]] name = "pytest" version = "8.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "packaging", marker = "python_full_version >= '3.9'" }, { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pygments", marker = "python_full_version >= '3.9'" }, { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] name = "pytest-cov" version = "5.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" }, ] [[package]] name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.9.*'" }, { name = "coverage", version = "7.11.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] name = "pytest-xdist" version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "execnet", marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060, upload-time = "2024-04-28T19:29:54.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108, upload-time = "2024-04-28T19:29:52.813Z" }, ] [[package]] name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "execnet", marker = "python_full_version >= '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0d/a2/09f67a3589cb4320fb5ce90d3fd4c9752636b8b6ad8f34b54d76c5a54693/PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", size = 186824, upload-time = "2025-09-29T20:27:35.918Z" }, { url = "https://files.pythonhosted.org/packages/02/72/d972384252432d57f248767556ac083793292a4adf4e2d85dfe785ec2659/PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", size = 795069, upload-time = "2025-09-29T20:27:38.15Z" }, { url = "https://files.pythonhosted.org/packages/a7/3b/6c58ac0fa7c4e1b35e48024eb03d00817438310447f93ef4431673c24138/PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", size = 862585, upload-time = "2025-09-29T20:27:39.715Z" }, { url = "https://files.pythonhosted.org/packages/25/a2/b725b61ac76a75583ae7104b3209f75ea44b13cfd026aa535ece22b7f22e/PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", size = 806018, upload-time = "2025-09-29T20:27:41.444Z" }, { url = "https://files.pythonhosted.org/packages/6f/b0/b2227677b2d1036d84f5ee95eb948e7af53d59fe3e4328784e4d290607e0/PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", size = 802822, upload-time = "2025-09-29T20:27:42.885Z" }, { url = "https://files.pythonhosted.org/packages/99/a5/718a8ea22521e06ef19f91945766a892c5ceb1855df6adbde67d997ea7ed/PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", size = 143744, upload-time = "2025-09-29T20:27:44.487Z" }, { url = "https://files.pythonhosted.org/packages/76/b2/2b69cee94c9eb215216fc05778675c393e3aa541131dc910df8e52c83776/PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", size = 160082, upload-time = "2025-09-29T20:27:46.049Z" }, { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] name = "pyyaml-env-tag" version = "0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "pyyaml", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631, upload-time = "2020-11-12T02:38:26.239Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911, upload-time = "2020-11-12T02:38:24.638Z" }, ] [[package]] name = "pyyaml-env-tag" version = "1.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "pyyaml", marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] name = "requests" version = "2.32.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ { name = "certifi", marker = "python_full_version < '3.9'" }, { name = "charset-normalizer", marker = "python_full_version < '3.9'" }, { name = "idna", marker = "python_full_version < '3.9'" }, { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, ] [[package]] name = "requests" version = "2.32.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] dependencies = [ { name = "certifi", marker = "python_full_version >= '3.9'" }, { name = "charset-normalizer", marker = "python_full_version >= '3.9'" }, { name = "idna", marker = "python_full_version >= '3.9'" }, { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "terminaltexteffects" version = "0.14.2" source = { editable = "." } [package.dev-dependencies] dev = [ { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings-python", version = "1.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "mkdocstrings-python", version = "1.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pymdown-extensions", version = "10.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pymdown-extensions", version = "10.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-cov", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-xdist", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-xdist", version = "3.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-material", specifier = ">=9.6.22" }, { name = "mkdocstrings-python", specifier = ">=1.11.1" }, { name = "pymdown-extensions", specifier = ">=10.15" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, ] [[package]] name = "tomli" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] [[package]] name = "typing-extensions" version = "4.13.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "urllib3" version = "2.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, ] [[package]] name = "urllib3" version = "2.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] name = "watchdog" version = "4.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/4f/38/764baaa25eb5e35c9a043d4c4588f9836edfe52a708950f4b6d5f714fd42/watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270", size = 126587, upload-time = "2024-08-11T07:38:01.623Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/46/b0/219893d41c16d74d0793363bf86df07d50357b81f64bba4cb94fe76e7af4/watchdog-4.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ede7f010f2239b97cc79e6cb3c249e72962404ae3865860855d5cbe708b0fd22", size = 100257, upload-time = "2024-08-11T07:37:04.209Z" }, { url = "https://files.pythonhosted.org/packages/6d/c6/8e90c65693e87d98310b2e1e5fd7e313266990853b489e85ce8396cc26e3/watchdog-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2cffa171445b0efa0726c561eca9a27d00a1f2b83846dbd5a4f639c4f8ca8e1", size = 92249, upload-time = "2024-08-11T07:37:06.364Z" }, { url = "https://files.pythonhosted.org/packages/6f/cd/2e306756364a934532ff8388d90eb2dc8bb21fe575cd2b33d791ce05a02f/watchdog-4.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c50f148b31b03fbadd6d0b5980e38b558046b127dc483e5e4505fcef250f9503", size = 92888, upload-time = "2024-08-11T07:37:08.275Z" }, { url = "https://files.pythonhosted.org/packages/de/78/027ad372d62f97642349a16015394a7680530460b1c70c368c506cb60c09/watchdog-4.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c7d4bf585ad501c5f6c980e7be9c4f15604c7cc150e942d82083b31a7548930", size = 100256, upload-time = "2024-08-11T07:37:11.017Z" }, { url = "https://files.pythonhosted.org/packages/59/a9/412b808568c1814d693b4ff1cec0055dc791780b9dc947807978fab86bc1/watchdog-4.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:914285126ad0b6eb2258bbbcb7b288d9dfd655ae88fa28945be05a7b475a800b", size = 92252, upload-time = "2024-08-11T07:37:13.098Z" }, { url = "https://files.pythonhosted.org/packages/04/57/179d76076cff264982bc335dd4c7da6d636bd3e9860bbc896a665c3447b6/watchdog-4.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:984306dc4720da5498b16fc037b36ac443816125a3705dfde4fd90652d8028ef", size = 92888, upload-time = "2024-08-11T07:37:15.077Z" }, { url = "https://files.pythonhosted.org/packages/92/f5/ea22b095340545faea37ad9a42353b265ca751f543da3fb43f5d00cdcd21/watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a", size = 100342, upload-time = "2024-08-11T07:37:16.393Z" }, { url = "https://files.pythonhosted.org/packages/cb/d2/8ce97dff5e465db1222951434e3115189ae54a9863aef99c6987890cc9ef/watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29", size = 92306, upload-time = "2024-08-11T07:37:17.997Z" }, { url = "https://files.pythonhosted.org/packages/49/c4/1aeba2c31b25f79b03b15918155bc8c0b08101054fc727900f1a577d0d54/watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a", size = 92915, upload-time = "2024-08-11T07:37:19.967Z" }, { url = "https://files.pythonhosted.org/packages/79/63/eb8994a182672c042d85a33507475c50c2ee930577524dd97aea05251527/watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b", size = 100343, upload-time = "2024-08-11T07:37:21.935Z" }, { url = "https://files.pythonhosted.org/packages/ce/82/027c0c65c2245769580605bcd20a1dc7dfd6c6683c8c4e2ef43920e38d27/watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d", size = 92313, upload-time = "2024-08-11T07:37:23.314Z" }, { url = "https://files.pythonhosted.org/packages/2a/89/ad4715cbbd3440cb0d336b78970aba243a33a24b1a79d66f8d16b4590d6a/watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7", size = 92919, upload-time = "2024-08-11T07:37:24.715Z" }, { url = "https://files.pythonhosted.org/packages/55/08/1a9086a3380e8828f65b0c835b86baf29ebb85e5e94a2811a2eb4f889cfd/watchdog-4.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:aa160781cafff2719b663c8a506156e9289d111d80f3387cf3af49cedee1f040", size = 100255, upload-time = "2024-08-11T07:37:26.862Z" }, { url = "https://files.pythonhosted.org/packages/6c/3e/064974628cf305831f3f78264800bd03b3358ec181e3e9380a36ff156b93/watchdog-4.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f6ee8dedd255087bc7fe82adf046f0b75479b989185fb0bdf9a98b612170eac7", size = 92257, upload-time = "2024-08-11T07:37:28.253Z" }, { url = "https://files.pythonhosted.org/packages/23/69/1d2ad9c12d93bc1e445baa40db46bc74757f3ffc3a3be592ba8dbc51b6e5/watchdog-4.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0b4359067d30d5b864e09c8597b112fe0a0a59321a0f331498b013fb097406b4", size = 92886, upload-time = "2024-08-11T07:37:29.52Z" }, { url = "https://files.pythonhosted.org/packages/68/eb/34d3173eceab490d4d1815ba9a821e10abe1da7a7264a224e30689b1450c/watchdog-4.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:770eef5372f146997638d737c9a3c597a3b41037cfbc5c41538fc27c09c3a3f9", size = 100254, upload-time = "2024-08-11T07:37:30.888Z" }, { url = "https://files.pythonhosted.org/packages/18/a1/4bbafe7ace414904c2cc9bd93e472133e8ec11eab0b4625017f0e34caad8/watchdog-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eeea812f38536a0aa859972d50c76e37f4456474b02bd93674d1947cf1e39578", size = 92249, upload-time = "2024-08-11T07:37:32.193Z" }, { url = "https://files.pythonhosted.org/packages/f3/11/ec5684e0ca692950826af0de862e5db167523c30c9cbf9b3f4ce7ec9cc05/watchdog-4.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b2c45f6e1e57ebb4687690c05bc3a2c1fb6ab260550c4290b8abb1335e0fd08b", size = 92891, upload-time = "2024-08-11T07:37:34.212Z" }, { url = "https://files.pythonhosted.org/packages/3b/9a/6f30f023324de7bad8a3eb02b0afb06bd0726003a3550e9964321315df5a/watchdog-4.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10b6683df70d340ac3279eff0b2766813f00f35a1d37515d2c99959ada8f05fa", size = 91775, upload-time = "2024-08-11T07:37:35.567Z" }, { url = "https://files.pythonhosted.org/packages/87/62/8be55e605d378a154037b9ba484e00a5478e627b69c53d0f63e3ef413ba6/watchdog-4.0.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7c739888c20f99824f7aa9d31ac8a97353e22d0c0e54703a547a218f6637eb3", size = 92255, upload-time = "2024-08-11T07:37:37.596Z" }, { url = "https://files.pythonhosted.org/packages/6b/59/12e03e675d28f450bade6da6bc79ad6616080b317c472b9ae688d2495a03/watchdog-4.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c100d09ac72a8a08ddbf0629ddfa0b8ee41740f9051429baa8e31bb903ad7508", size = 91682, upload-time = "2024-08-11T07:37:38.901Z" }, { url = "https://files.pythonhosted.org/packages/ef/69/241998de9b8e024f5c2fbdf4324ea628b4231925305011ca8b7e1c3329f6/watchdog-4.0.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f5315a8c8dd6dd9425b974515081fc0aadca1d1d61e078d2246509fd756141ee", size = 92249, upload-time = "2024-08-11T07:37:40.143Z" }, { url = "https://files.pythonhosted.org/packages/70/3f/2173b4d9581bc9b5df4d7f2041b6c58b5e5448407856f68d4be9981000d0/watchdog-4.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2d468028a77b42cc685ed694a7a550a8d1771bb05193ba7b24006b8241a571a1", size = 91773, upload-time = "2024-08-11T07:37:42.095Z" }, { url = "https://files.pythonhosted.org/packages/f0/de/6fff29161d5789048f06ef24d94d3ddcc25795f347202b7ea503c3356acb/watchdog-4.0.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f15edcae3830ff20e55d1f4e743e92970c847bcddc8b7509bcd172aa04de506e", size = 92250, upload-time = "2024-08-11T07:37:44.052Z" }, { url = "https://files.pythonhosted.org/packages/8a/b1/25acf6767af6f7e44e0086309825bd8c098e301eed5868dc5350642124b9/watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83", size = 82947, upload-time = "2024-08-11T07:37:45.388Z" }, { url = "https://files.pythonhosted.org/packages/e8/90/aebac95d6f954bd4901f5d46dcd83d68e682bfd21798fd125a95ae1c9dbf/watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c", size = 82942, upload-time = "2024-08-11T07:37:46.722Z" }, { url = "https://files.pythonhosted.org/packages/15/3a/a4bd8f3b9381824995787488b9282aff1ed4667e1110f31a87b871ea851c/watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a", size = 82947, upload-time = "2024-08-11T07:37:48.941Z" }, { url = "https://files.pythonhosted.org/packages/09/cc/238998fc08e292a4a18a852ed8274159019ee7a66be14441325bcd811dfd/watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73", size = 82946, upload-time = "2024-08-11T07:37:50.279Z" }, { url = "https://files.pythonhosted.org/packages/80/f1/d4b915160c9d677174aa5fae4537ae1f5acb23b3745ab0873071ef671f0a/watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc", size = 82947, upload-time = "2024-08-11T07:37:51.55Z" }, { url = "https://files.pythonhosted.org/packages/db/02/56ebe2cf33b352fe3309588eb03f020d4d1c061563d9858a9216ba004259/watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757", size = 82944, upload-time = "2024-08-11T07:37:52.855Z" }, { url = "https://files.pythonhosted.org/packages/01/d2/c8931ff840a7e5bd5dcb93f2bb2a1fd18faf8312e9f7f53ff1cf76ecc8ed/watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8", size = 82947, upload-time = "2024-08-11T07:37:55.172Z" }, { url = "https://files.pythonhosted.org/packages/d0/d8/cdb0c21a4a988669d7c210c75c6a2c9a0e16a3b08d9f7e633df0d9a16ad8/watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19", size = 82935, upload-time = "2024-08-11T07:37:56.668Z" }, { url = "https://files.pythonhosted.org/packages/99/2e/b69dfaae7a83ea64ce36538cc103a3065e12c447963797793d5c0a1d5130/watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b", size = 82934, upload-time = "2024-08-11T07:37:57.991Z" }, { url = "https://files.pythonhosted.org/packages/b0/0b/43b96a9ecdd65ff5545b1b13b687ca486da5c6249475b1a45f24d63a1858/watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c", size = 82933, upload-time = "2024-08-11T07:37:59.573Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390, upload-time = "2024-11-01T14:06:49.325Z" }, { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386, upload-time = "2024-11-01T14:06:50.536Z" }, { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017, upload-time = "2024-11-01T14:06:51.717Z" }, { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903, upload-time = "2024-11-01T14:06:57.052Z" }, { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381, upload-time = "2024-11-01T14:06:58.193Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] name = "wheel" version = "0.45.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, ] [[package]] name = "zipp" version = "3.20.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199, upload-time = "2024-09-13T13:44:16.101Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200, upload-time = "2024-09-13T13:44:14.38Z" }, ] [[package]] name = "zipp" version = "3.23.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.9.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ]