Browse Source

Start pulling Animation a bit at a time through chat gpt from https://github.com/ChrisBuilds/terminaltexteffects

tznind 1 year ago
parent
commit
f3120226c4

+ 566 - 0
Terminal.Gui/TextEffects/Animation.cs

@@ -0,0 +1,566 @@
+using static Unix.Terminal.Curses;
+
+namespace Terminal.Gui.TextEffects;
+
+public enum SyncMetric
+{
+    Distance,
+    Step
+}
+
+public class CharacterVisual
+{
+    public string Symbol { get; set; }
+    public bool Bold { get; set; }
+    public bool Dim { get; set; }
+    public bool Italic { get; set; }
+    public bool Underline { get; set; }
+    public bool Blink { get; set; }
+    public bool Reverse { get; set; }
+    public bool Hidden { get; set; }
+    public bool Strike { get; set; }
+    public Color Color { get; set; }
+    public string FormattedSymbol { get; private set; }
+    private string _colorCode;
+
+    public CharacterVisual (string symbol, bool bold = false, bool dim = false, bool italic = false, bool underline = false, bool blink = false, bool reverse = false, bool hidden = false, bool strike = false, Color color = null, string colorCode = null)
+    {
+        Symbol = symbol;
+        Bold = bold;
+        Dim = dim;
+        Italic = italic;
+        Underline = underline;
+        Blink = blink;
+        Reverse = reverse;
+        Hidden = hidden;
+        Strike = strike;
+        Color = color;
+        _colorCode = colorCode;
+        FormattedSymbol = FormatSymbol ();
+    }
+
+    private string FormatSymbol ()
+    {
+        string formattingString = "";
+        if (Bold) formattingString += Ansitools.ApplyBold ();
+        if (Italic) formattingString += Ansitools.ApplyItalic ();
+        if (Underline) formattingString += Ansitools.ApplyUnderline ();
+        if (Blink) formattingString += Ansitools.ApplyBlink ();
+        if (Reverse) formattingString += Ansitools.ApplyReverse ();
+        if (Hidden) formattingString += Ansitools.ApplyHidden ();
+        if (Strike) formattingString += Ansitools.ApplyStrikethrough ();
+        if (_colorCode != null) formattingString += Colorterm.Fg (_colorCode);
+
+        return $"{formattingString}{Symbol}{(formattingString != "" ? Ansitools.ResetAll () : "")}";
+    }
+
+    public void DisableModes ()
+    {
+        Bold = false;
+        Dim = false;
+        Italic = false;
+        Underline = false;
+        Blink = false;
+        Reverse = false;
+        Hidden = false;
+        Strike = false;
+    }
+}
+
+public class Frame
+{
+    public CharacterVisual CharacterVisual { get; }
+    public int Duration { get; }
+    public int TicksElapsed { get; set; }
+
+    public Frame (CharacterVisual characterVisual, int duration)
+    {
+        CharacterVisual = characterVisual;
+        Duration = duration;
+        TicksElapsed = 0;
+    }
+
+    public void IncrementTicks ()
+    {
+        TicksElapsed++;
+    }
+}
+
+public class Scene
+{
+    public string SceneId { get; }
+    public bool IsLooping { get; }
+    public SyncMetric? Sync { get; }
+    public EasingFunction Ease { get; }
+    public bool NoColor { get; set; }
+    public bool UseXtermColors { get; set; }
+    public List<Frame> Frames { get; } = new List<Frame> ();
+    public List<Frame> PlayedFrames { get; } = new List<Frame> ();
+    public Dictionary<int, Frame> FrameIndexMap { get; } = new Dictionary<int, Frame> ();
+    public int EasingTotalSteps { get; private set; }
+    public int EasingCurrentStep { get; private set; }
+    public static Dictionary<string, int> XtermColorMap { get; } = new Dictionary<string, int> ();
+
+    public Scene (string sceneId, bool isLooping = false, SyncMetric? sync = null, EasingFunction ease = null, bool noColor = false, bool useXtermColors = false)
+    {
+        SceneId = sceneId;
+        IsLooping = isLooping;
+        Sync = sync;
+        Ease = ease;
+        NoColor = noColor;
+        UseXtermColors = useXtermColors;
+    }
+
+    public void AddFrame (string symbol, int duration, Color color = null, bool bold = false, bool dim = false, bool italic = false, bool underline = false, bool blink = false, bool reverse = false, bool hidden = false, bool strike = false)
+    {
+        string charVisColor = null;
+        if (color != null)
+        {
+            if (NoColor)
+            {
+                charVisColor = null;
+            }
+            else if (UseXtermColors)
+            {
+                if (color.XtermColor != null)
+                {
+                    charVisColor = color.XtermColor;
+                }
+                else if (XtermColorMap.ContainsKey (color.RgbColor))
+                {
+                    // Build error says  Error CS0029  Cannot implicitly convert type 'int' to 'string'    Terminal.Gui (net8.0)   D:\Repos\TerminalGuiDesigner\gui.cs\Terminal.Gui\TextEffects\Animation.cs   120 Active
+                    charVisColor = XtermColorMap [color.RgbColor].ToString ();
+                }
+                else
+                {
+                    var xtermColor = Hexterm.HexToXterm (color.RgbColor);
+                    XtermColorMap [color.RgbColor] = int.Parse (xtermColor);
+                    charVisColor = xtermColor;
+                }
+            }
+            else
+            {
+                charVisColor = color.RgbColor;
+            }
+        }
+
+        if (duration < 1)
+        {
+            throw new ArgumentException ("duration must be greater than 0");
+        }
+
+        var charVis = new CharacterVisual (symbol, bold, dim, italic, underline, blink, reverse, hidden, strike, color, charVisColor);
+        var frame = new Frame (charVis, duration);
+        Frames.Add (frame);
+        for (int i = 0; i < frame.Duration; i++)
+        {
+            FrameIndexMap [EasingTotalSteps] = frame;
+            EasingTotalSteps++;
+        }
+    }
+
+    public CharacterVisual Activate ()
+    {
+        if (Frames.Count > 0)
+        {
+            return Frames [0].CharacterVisual;
+        }
+        else
+        {
+            throw new InvalidOperationException ("Scene has no frames.");
+        }
+    }
+
+    public CharacterVisual GetNextVisual ()
+    {
+        var currentFrame = Frames [0];
+        var nextVisual = currentFrame.CharacterVisual;
+        currentFrame.IncrementTicks ();
+        if (currentFrame.TicksElapsed == currentFrame.Duration)
+        {
+            currentFrame.TicksElapsed = 0;
+            PlayedFrames.Add (Frames [0]);
+            Frames.RemoveAt (0);
+            if (IsLooping && Frames.Count == 0)
+            {
+                Frames.AddRange (PlayedFrames);
+                PlayedFrames.Clear ();
+            }
+        }
+        return nextVisual;
+    }
+
+    public void ApplyGradientToSymbols (Gradient gradient, IList<string> symbols, int duration)
+    {
+        int lastIndex = 0;
+        for (int symbolIndex = 0; symbolIndex < symbols.Count; symbolIndex++)
+        {
+            var symbol = symbols [symbolIndex];
+            double symbolProgress = (symbolIndex + 1) / (double)symbols.Count;
+            int gradientIndex = (int)(symbolProgress * gradient.Spectrum.Count);
+            foreach (var color in gradient.Spectrum.GetRange (lastIndex, Math.Max (gradientIndex - lastIndex, 1)))
+            {
+                AddFrame (symbol, duration, color);
+            }
+            lastIndex = gradientIndex;
+        }
+    }
+
+    public void ResetScene ()
+    {
+        foreach (var sequence in Frames)
+        {
+            sequence.TicksElapsed = 0;
+            PlayedFrames.Add (sequence);
+        }
+        Frames.Clear ();
+        Frames.AddRange (PlayedFrames);
+        PlayedFrames.Clear ();
+    }
+
+    public override bool Equals (object obj)
+    {
+        if (obj is Scene other)
+        {
+            return SceneId == other.SceneId;
+        }
+        return false;
+    }
+
+    public override int GetHashCode ()
+    {
+        return SceneId.GetHashCode ();
+    }
+}
+
+public class Animation
+{
+    public Dictionary<string, Scene> Scenes { get; } = new Dictionary<string, Scene> ();
+    public EffectCharacter Character { get; }
+    public Scene ActiveScene { get; private set; }
+    public bool UseXtermColors { get; set; } = false;
+    public bool NoColor { get; set; } = false;
+    public Dictionary<string, int> XtermColorMap { get; } = new Dictionary<string, int> ();
+    public int ActiveSceneCurrentStep { get; private set; } = 0;
+    public CharacterVisual CurrentCharacterVisual { get; private set; }
+
+    public Animation (EffectCharacter character)
+    {
+        Character = character;
+        CurrentCharacterVisual = new CharacterVisual (character.InputSymbol);
+    }
+
+    public Scene NewScene (bool isLooping = false, SyncMetric? sync = null, EasingFunction ease = null, string id = "")
+    {
+        if (string.IsNullOrEmpty (id))
+        {
+            bool foundUnique = false;
+            int currentId = Scenes.Count;
+            while (!foundUnique)
+            {
+                id = $"{Scenes.Count}";
+                if (!Scenes.ContainsKey (id))
+                {
+                    foundUnique = true;
+                }
+                else
+                {
+                    currentId++;
+                }
+            }
+        }
+
+        var newScene = new Scene (id, isLooping, sync, ease);
+        Scenes [id] = newScene;
+        newScene.NoColor = NoColor;
+        newScene.UseXtermColors = UseXtermColors;
+        return newScene;
+    }
+
+    public Scene QueryScene (string sceneId)
+    {
+        if (!Scenes.TryGetValue (sceneId, out var scene))
+        {
+            throw new ArgumentException ($"Scene {sceneId} does not exist.");
+        }
+        return scene;
+    }
+
+    public bool ActiveSceneIsComplete ()
+    {
+        if (ActiveScene == null)
+        {
+            return true;
+        }
+        return ActiveScene.Frames.Count == 0 && !ActiveScene.IsLooping;
+    }
+
+    public void SetAppearance (string symbol, Color? color = null)
+    {
+        string charVisColor = null;
+        if (color != null)
+        {
+            if (NoColor)
+            {
+                charVisColor = null;
+            }
+            else if (UseXtermColors)
+            {
+                charVisColor = color.XtermColor;
+            }
+            else
+            {
+                charVisColor = color.RgbColor;
+            }
+        }
+        CurrentCharacterVisual = new CharacterVisual (symbol, color: color, _colorCode: charVisColor);
+    }
+
+    public static Color RandomColor ()
+    {
+        var random = new Random ();
+        var colorHex = random.Next (0, 0xFFFFFF).ToString ("X6");
+        return new Color (colorHex);
+    }
+
+    public static Color AdjustColorBrightness (Color color, float brightness)
+    {
+        float HueToRgb (float p, float q, float t)
+        {
+            if (t < 0) t += 1;
+            if (t > 1) t -= 1;
+            if (t < 1 / 6f) return p + (q - p) * 6 * t;
+            if (t < 1 / 2f) return q;
+            if (t < 2 / 3f) return p + (q - p) * (2 / 3f - t) * 6;
+            return p;
+        }
+
+        float r = int.Parse (color.RgbColor.Substring (0, 2), System.Globalization.NumberStyles.HexNumber) / 255f;
+        float g = int.Parse (color.RgbColor.Substring (2, 2), System.Globalization.NumberStyles.HexNumber) / 255f;
+        float b = int.Parse (color.RgbColor.Substring (4, 2), System.Globalization.NumberStyles.HexNumber) / 255f;
+
+        float max = Math.Max (r, Math.Max (g, b));
+        float min = Math.Min (r, Math.Min (g, b));
+        float h, s, l = (max + min) / 2f;
+
+        if (max == min)
+        {
+            h = s = 0; // achromatic
+        }
+        else
+        {
+            float d = max - min;
+            s = l > 0.5f ? d / (2f - max - min) : d / (max + min);
+            if (max == r)
+            {
+                h = (g - b) / d + (g < b ? 6 : 0);
+            }
+            else if (max == g)
+            {
+                h = (b - r) / d + 2;
+            }
+            else
+            {
+                h = (r - g) / d + 4;
+            }
+            h /= 6;
+        }
+
+        l = Math.Max (Math.Min (l * brightness, 1), 0);
+
+        if (s == 0)
+        {
+            r = g = b = l; // achromatic
+        }
+        else
+        {
+            float q = l < 0.5f ? l * (1 + s) : l + s - l * s;
+            float p = 2 * l - q;
+            r = HueToRgb (p, q, h + 1 / 3f);
+            g = HueToRgb (p, q, h);
+            b = HueToRgb (p, q, h - 1 / 3f);
+        }
+
+        var adjustedColor = $"{(int)(r * 255):X2}{(int)(g * 255):X2}{(int)(b * 255):X2}";
+        return new Color (adjustedColor);
+    }
+
+    private float EaseAnimation (EasingFunction easingFunc)
+    {
+        if (ActiveScene == null)
+        {
+            return 0;
+        }
+        float elapsedStepRatio = ActiveScene.EasingCurrentStep / (float)ActiveScene.EasingTotalSteps;
+        return easingFunc (elapsedStepRatio);
+    }
+
+    public void StepAnimation ()
+    {
+        if (ActiveScene != null && ActiveScene.Frames.Count > 0)
+        {
+            if (ActiveScene.Sync != null)
+            {
+                if (Character.Motion.ActivePath != null)
+                {
+                    int sequenceIndex = 0;
+                    if (ActiveScene.Sync == SyncMetric.Step)
+                    {
+                        sequenceIndex = (int)Math.Round ((ActiveScene.Frames.Count - 1) *
+                            (Math.Max (Character.Motion.ActivePath.CurrentStep, 1) /
+                            (float)Math.Max (Character.Motion.ActivePath.MaxSteps, 1)));
+                    }
+                    else if (ActiveScene.Sync == SyncMetric.Distance)
+                    {
+                        sequenceIndex = (int)Math.Round ((ActiveScene.Frames.Count - 1) *
+                            (Math.Max (Math.Max (Character.Motion.ActivePath.TotalDistance, 1) -
+                            Math.Max (Character.Motion.ActivePath.TotalDistance -
+                            Character.Motion.ActivePath.LastDistanceReached, 1), 1) /
+                            (float)Math.Max (Character.Motion.ActivePath.TotalDistance, 1)));
+                    }
+                    try
+                    {
+                        CurrentCharacterVisual = ActiveScene.Frames [sequenceIndex].CharacterVisual;
+                    }
+                    catch (IndexOutOfRangeException)
+                    {
+                        CurrentCharacterVisual = ActiveScene.Frames [^1].CharacterVisual;
+                    }
+                }
+                else
+                {
+                    CurrentCharacterVisual = ActiveScene.Frames [^1].CharacterVisual;
+                    ActiveScene.PlayedFrames.AddRange (ActiveScene.Frames);
+                    ActiveScene.Frames.Clear ();
+                }
+            }
+            else if (ActiveScene.Ease != null)
+            {
+                float easingFactor = EaseAnimation (ActiveScene.Ease);
+                int frameIndex = (int)Math.Round (easingFactor * Math.Max (ActiveScene.EasingTotalSteps - 1, 0));
+                frameIndex = Math.Max (Math.Min (frameIndex, ActiveScene.EasingTotalSteps - 1), 0);
+                Frame frame = ActiveScene.FrameIndexMap [frameIndex];
+                CurrentCharacterVisual = frame.CharacterVisual;
+                ActiveScene.EasingCurrentStep++;
+                if (ActiveScene.EasingCurrentStep == ActiveScene.EasingTotalSteps)
+                {
+                    if (ActiveScene.IsLooping)
+                    {
+                        ActiveScene.EasingCurrentStep = 0;
+                    }
+                    else
+                    {
+                        ActiveScene.PlayedFrames.AddRange (ActiveScene.Frames);
+                        ActiveScene.Frames.Clear ();
+                    }
+                }
+            }
+            else
+            {
+                CurrentCharacterVisual = ActiveScene.GetNextVisual ();
+            }
+            if (ActiveSceneIsComplete ())
+            {
+                var completedScene = ActiveScene;
+                if (!ActiveScene.IsLooping)
+                {
+                    ActiveScene.ResetScene ();
+                    ActiveScene = null;
+                }
+                Character.EventHandler.HandleEvent (Event.SceneComplete, completedScene);
+            }
+        }
+    }
+
+    public void ActivateScene (Scene scene)
+    {
+        ActiveScene = scene;
+        ActiveSceneCurrentStep = 0;
+        CurrentCharacterVisual = ActiveScene.Activate ();
+        Character.EventHandler.HandleEvent (Event.SceneActivated, scene);
+    }
+
+    public void DeactivateScene (Scene scene)
+    {
+        if (ActiveScene == scene)
+        {
+            ActiveScene = null;
+        }
+    }
+}
+
+public class EffectCharacter
+{
+    public string InputSymbol { get; }
+    public CharacterVisual CharacterVisual { get; set; }
+    public Animation Animation { get; set; }
+
+    public EffectCharacter (string inputSymbol)
+    {
+        InputSymbol = inputSymbol;
+        CharacterVisual = new CharacterVisual (inputSymbol);
+        Animation = new Animation (this);
+    }
+
+    public void Animate (string sceneId)
+    {
+        Animation.ActivateScene (sceneId);
+        Animation.IncrementScene ();
+        CharacterVisual = Animation.CurrentCharacterVisual;
+    }
+
+    public void ResetEffects ()
+    {
+        CharacterVisual.DisableModes ();
+    }
+}
+
+public class Color
+{
+    public string RgbColor { get; }
+    public string XtermColor { get; }
+
+    public Color (string rgbColor, string xtermColor)
+    {
+        RgbColor = rgbColor;
+        XtermColor = xtermColor;
+    }
+}
+
+public class Gradient
+{
+    public List<Color> Spectrum { get; }
+
+    public Gradient (List<Color> spectrum)
+    {
+        Spectrum = spectrum;
+    }
+}
+
+
+// Dummy classes for Ansitools, Colorterm, and Hexterm as placeholders
+public static class Ansitools
+{
+    public static string ApplyBold () => "\x1b[1m";
+    public static string ApplyItalic () => "\x1b[3m";
+    public static string ApplyUnderline () => "\x1b[4m";
+    public static string ApplyBlink () => "\x1b[5m";
+    public static string ApplyReverse () => "\x1b[7m";
+    public static string ApplyHidden () => "\x1b[8m";
+    public static string ApplyStrikethrough () => "\x1b[9m";
+    public static string ResetAll () => "\x1b[0m";
+}
+
+public static class Colorterm
+{
+    public static string Fg (string colorCode) => $"\x1b[38;5;{colorCode}m";
+}
+
+public static class Hexterm
+{
+    public static string HexToXterm (string hex)
+    {
+        // Convert hex color to xterm color code (0-255)
+        return "15"; // Example output
+    }
+}

+ 303 - 0
Terminal.Gui/TextEffects/Easing.cs

@@ -0,0 +1,303 @@
+namespace Terminal.Gui.TextEffects;
+using System;
+
+public delegate float EasingFunction (float progressRatio);
+
+public static class Easing
+{
+    public static float Linear (float progressRatio)
+    {
+        return progressRatio;
+    }
+
+    public static float InSine (float progressRatio)
+    {
+        return 1 - (float)Math.Cos ((progressRatio * Math.PI) / 2);
+    }
+
+    public static float OutSine (float progressRatio)
+    {
+        return (float)Math.Sin ((progressRatio * Math.PI) / 2);
+    }
+
+    public static float InOutSine (float progressRatio)
+    {
+        return -(float)(Math.Cos (Math.PI * progressRatio) - 1) / 2;
+    }
+
+    public static float InQuad (float progressRatio)
+    {
+        return progressRatio * progressRatio;
+    }
+
+    public static float OutQuad (float progressRatio)
+    {
+        return 1 - (1 - progressRatio) * (1 - progressRatio);
+    }
+
+    public static float InOutQuad (float progressRatio)
+    {
+        if (progressRatio < 0.5)
+        {
+            return 2 * progressRatio * progressRatio;
+        }
+        else
+        {
+            return 1 - (float)Math.Pow (-2 * progressRatio + 2, 2) / 2;
+        }
+    }
+
+    public static float InCubic (float progressRatio)
+    {
+        return progressRatio * progressRatio * progressRatio;
+    }
+
+    public static float OutCubic (float progressRatio)
+    {
+        return 1 - (float)Math.Pow (1 - progressRatio, 3);
+    }
+
+    public static float InOutCubic (float progressRatio)
+    {
+        if (progressRatio < 0.5)
+        {
+            return 4 * progressRatio * progressRatio * progressRatio;
+        }
+        else
+        {
+            return 1 - (float)Math.Pow (-2 * progressRatio + 2, 3) / 2;
+        }
+    }
+
+    public static float InQuart (float progressRatio)
+    {
+        return progressRatio * progressRatio * progressRatio * progressRatio;
+    }
+
+    public static float OutQuart (float progressRatio)
+    {
+        return 1 - (float)Math.Pow (1 - progressRatio, 4);
+    }
+
+    public static float InOutQuart (float progressRatio)
+    {
+        if (progressRatio < 0.5)
+        {
+            return 8 * progressRatio * progressRatio * progressRatio * progressRatio;
+        }
+        else
+        {
+            return 1 - (float)Math.Pow (-2 * progressRatio + 2, 4) / 2;
+        }
+    }
+
+    public static float InQuint (float progressRatio)
+    {
+        return progressRatio * progressRatio * progressRatio * progressRatio * progressRatio;
+    }
+
+    public static float OutQuint (float progressRatio)
+    {
+        return 1 - (float)Math.Pow (1 - progressRatio, 5);
+    }
+
+    public static float InOutQuint (float progressRatio)
+    {
+        if (progressRatio < 0.5)
+        {
+            return 16 * progressRatio * progressRatio * progressRatio * progressRatio * progressRatio;
+        }
+        else
+        {
+            return 1 - (float)Math.Pow (-2 * progressRatio + 2, 5) / 2;
+        }
+    }
+
+    public static float InExpo (float progressRatio)
+    {
+        if (progressRatio == 0)
+        {
+            return 0;
+        }
+        else
+        {
+            return (float)Math.Pow (2, 10 * progressRatio - 10);
+        }
+    }
+
+    public static float OutExpo (float progressRatio)
+    {
+        if (progressRatio == 1)
+        {
+            return 1;
+        }
+        else
+        {
+            return 1 - (float)Math.Pow (2, -10 * progressRatio);
+        }
+    }
+
+    public static float InOutExpo (float progressRatio)
+    {
+        if (progressRatio == 0)
+        {
+            return 0;
+        }
+        else if (progressRatio == 1)
+        {
+            return 1;
+        }
+        else if (progressRatio < 0.5)
+        {
+            return (float)Math.Pow (2, 20 * progressRatio - 10) / 2;
+        }
+        else
+        {
+            return (2 - (float)Math.Pow (2, -20 * progressRatio + 10)) / 2;
+        }
+    }
+
+    public static float InCirc (float progressRatio)
+    {
+        return 1 - (float)Math.Sqrt (1 - progressRatio * progressRatio);
+    }
+
+    public static float OutCirc (float progressRatio)
+    {
+        return (float)Math.Sqrt (1 - (progressRatio - 1) * (progressRatio - 1));
+    }
+
+    public static float InOutCirc (float progressRatio)
+    {
+        if (progressRatio < 0.5)
+        {
+            return (1 - (float)Math.Sqrt (1 - (2 * progressRatio) * (2 * progressRatio))) / 2;
+        }
+        else
+        {
+            return ((float)Math.Sqrt (1 - (-2 * progressRatio + 2) * (-2 * progressRatio + 2)) + 1) / 2;
+        }
+    }
+
+    public static float InBack (float progressRatio)
+    {
+        const float c1 = 1.70158f;
+        const float c3 = c1 + 1;
+        return c3 * progressRatio * progressRatio * progressRatio - c1 * progressRatio * progressRatio;
+    }
+
+    public static float OutBack (float progressRatio)
+    {
+        const float c1 = 1.70158f;
+        const float c3 = c1 + 1;
+        return 1 + c3 * (progressRatio - 1) * (progressRatio - 1) * (progressRatio - 1) + c1 * (progressRatio - 1) * (progressRatio - 1);
+    }
+
+    public static float InOutBack (float progressRatio)
+    {
+        const float c1 = 1.70158f;
+        const float c2 = c1 * 1.525f;
+        if (progressRatio < 0.5)
+        {
+            return ((2 * progressRatio) * (2 * progressRatio) * ((c2 + 1) * 2 * progressRatio - c2)) / 2;
+        }
+        else
+        {
+            return ((2 * progressRatio - 2) * (2 * progressRatio - 2) * ((c2 + 1) * (progressRatio * 2 - 2) + c2) + 2) / 2;
+        }
+    }
+
+    public static float InElastic (float progressRatio)
+    {
+        const float c4 = (2 * (float)Math.PI) / 3;
+        if (progressRatio == 0)
+        {
+            return 0;
+        }
+        else if (progressRatio == 1)
+        {
+            return 1;
+        }
+        else
+        {
+            return -(float)Math.Pow (2, 10 * progressRatio - 10) * (float)Math.Sin ((progressRatio * 10 - 10.75) * c4);
+        }
+    }
+
+    public static float OutElastic (float progressRatio)
+    {
+        const float c4 = (2 * (float)Math.PI) / 3;
+        if (progressRatio == 0)
+        {
+            return 0;
+        }
+        else if (progressRatio == 1)
+        {
+            return 1;
+        }
+        else
+        {
+            return (float)Math.Pow (2, -10 * progressRatio) * (float)Math.Sin ((progressRatio * 10 - 0.75) * c4) + 1;
+        }
+    }
+
+    public static float InOutElastic (float progressRatio)
+    {
+        const float c5 = (2 * (float)Math.PI) / 4.5f;
+        if (progressRatio == 0)
+        {
+            return 0;
+        }
+        else if (progressRatio == 1)
+        {
+            return 1;
+        }
+        else if (progressRatio < 0.5)
+        {
+            return -(float)Math.Pow (2, 20 * progressRatio - 10) * (float)Math.Sin ((20 * progressRatio - 11.125) * c5) / 2;
+        }
+        else
+        {
+            return ((float)Math.Pow (2, -20 * progressRatio + 10) * (float)Math.Sin ((20 * progressRatio - 11.125) * c5)) / 2 + 1;
+        }
+    }
+
+    public static float InBounce (float progressRatio)
+    {
+        return 1 - OutBounce (1 - progressRatio);
+    }
+
+    public static float OutBounce (float progressRatio)
+    {
+        const float n1 = 7.5625f;
+        const float d1 = 2.75f;
+        if (progressRatio < 1 / d1)
+        {
+            return n1 * progressRatio * progressRatio;
+        }
+        else if (progressRatio < 2 / d1)
+        {
+            return n1 * (progressRatio - 1.5f / d1) * (progressRatio - 1.5f / d1) + 0.75f;
+        }
+        else if (progressRatio < 2.5 / d1)
+        {
+            return n1 * (progressRatio - 2.25f / d1) * (progressRatio - 2.25f / d1) + 0.9375f;
+        }
+        else
+        {
+            return n1 * (progressRatio - 2.625f / d1) * (progressRatio - 2.625f / d1) + 0.984375f;
+        }
+    }
+
+    public static float InOutBounce (float progressRatio)
+    {
+        if (progressRatio < 0.5)
+        {
+            return (1 - OutBounce (1 - 2 * progressRatio)) / 2;
+        }
+        else
+        {
+            return (1 + OutBounce (2 * progressRatio - 1)) / 2;
+        }
+    }
+}

+ 191 - 0
UnitTests/TextEffects/AnimationTests.cs

@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using Terminal.Gui.TextEffects;
+using Xunit;
+
+namespace Terminal.Gui.TextEffectsTests;
+
+public class AnimationTests
+{
+    private EffectCharacter character;
+
+    public AnimationTests ()
+    {
+        character = new EffectCharacter (0, "a", 0, 0);
+    }
+
+    [Fact]
+    public void TestCharacterVisualInit ()
+    {
+        var visual = new CharacterVisual (
+            symbol: "a",
+            bold: true,
+            dim: false,
+            italic: true,
+            underline: false,
+            blink: true,
+            reverse: false,
+            hidden: true,
+            strike: false,
+            color: new Color ("ffffff"),
+            colorCode: "ffffff"
+        );
+        Assert.Equal ("\x1b[1m\x1b[3m\x1b[5m\x1b[8m\x1b[38;2;255;255;255ma\x1b[0m", visual.FormattedSymbol);
+        Assert.True (visual.Bold);
+        Assert.False (visual.Dim);
+        Assert.True (visual.Italic);
+        Assert.False (visual.Underline);
+        Assert.True (visual.Blink);
+        Assert.False (visual.Reverse);
+        Assert.True (visual.Hidden);
+        Assert.False (visual.Strike);
+        Assert.Equal (new Color ("ffffff"), visual.Color);
+        Assert.Equal ("ffffff", visual.ColorCode);
+    }
+
+    [Fact]
+    public void TestFrameInit ()
+    {
+        var visual = new CharacterVisual (
+            symbol: "a",
+            bold: true,
+            dim: false,
+            italic: true,
+            underline: false,
+            blink: true,
+            reverse: false,
+            hidden: true,
+            strike: false,
+            color: new Color ("ffffff")
+        );
+        var frame = new Frame (characterVisual: visual, duration: 5);
+        Assert.Equal (visual, frame.CharacterVisual);
+        Assert.Equal (5, frame.Duration);
+        Assert.Equal (0, frame.TicksElapsed);
+    }
+
+    [Fact]
+    public void TestSceneInit ()
+    {
+        var scene = new Scene (sceneId: "test_scene", isLooping: true, sync: SyncMetric.Step, ease: Easing.InSine);
+        Assert.Equal ("test_scene", scene.SceneId);
+        Assert.True (scene.IsLooping);
+        Assert.Equal (SyncMetric.Step, scene.Sync);
+        Assert.Equal (Easing.InSine, scene.Ease);
+    }
+
+    [Fact]
+    public void TestSceneAddFrame ()
+    {
+        var scene = new Scene (sceneId: "test_scene");
+        scene.AddFrame (symbol: "a", duration: 5, color: new Color ("ffffff"), bold: true, italic: true, blink: true, hidden: true);
+        Assert.Single (scene.Frames);
+        var frame = scene.Frames [0];
+        Assert.Equal ("\x1b[1m\x1b[3m\x1b[5m\x1b[8m\x1b[38;2;255;255;255ma\x1b[0m", frame.CharacterVisual.FormattedSymbol);
+        Assert.Equal (5, frame.Duration);
+        Assert.Equal (new Color ("ffffff"), frame.CharacterVisual.Color);
+        Assert.True (frame.CharacterVisual.Bold);
+    }
+
+    [Fact]
+    public void TestSceneAddFrameInvalidDuration ()
+    {
+        var scene = new Scene (sceneId: "test_scene");
+        var exception = Assert.Throws<ArgumentException> (() => scene.AddFrame (symbol: "a", duration: 0, color: new Color ("ffffff")));
+        Assert.Equal ("duration must be greater than 0", exception.Message);
+    }
+
+    [Fact]
+    public void TestSceneApplyGradientToSymbolsEqualColorsAndSymbols ()
+    {
+        var scene = new Scene (sceneId: "test_scene");
+        var gradient = new Gradient (new Color ("000000"), new Color ("ffffff"), steps: 2);
+        var symbols = new List<string> { "a", "b", "c" };
+        scene.ApplyGradientToSymbols (gradient, symbols, duration: 1);
+        Assert.Equal (3, scene.Frames.Count);
+        for (int i = 0; i < scene.Frames.Count; i++)
+        {
+            Assert.Equal (1, scene.Frames [i].Duration);
+            Assert.Equal (gradient.Spectrum [i].RgbColor, scene.Frames [i].CharacterVisual.ColorCode);
+        }
+    }
+
+    [Fact]
+    public void TestSceneApplyGradientToSymbolsUnequalColorsAndSymbols ()
+    {
+        var scene = new Scene (sceneId: "test_scene");
+        var gradient = new Gradient (new Color ("000000"), new Color ("ffffff"), steps: 4);
+        var symbols = new List<string> { "q", "z" };
+        scene.ApplyGradientToSymbols (gradient, symbols, duration: 1);
+        Assert.Equal (5, scene.Frames.Count);
+        Assert.Equal (gradient.Spectrum [0].RgbColor, scene.Frames [0].CharacterVisual.ColorCode);
+        Assert.Contains ("q", scene.Frames [0].CharacterVisual.Symbol);
+        Assert.Equal (gradient.Spectrum [^1].RgbColor, scene.Frames [^1].CharacterVisual.ColorCode);
+        Assert.Contains ("z", scene.Frames [^1].CharacterVisual.Symbol);
+    }
+
+    [Fact]
+    public void TestAnimationInit ()
+    {
+        var animation = character.Animation;
+        Assert.Equal (character, animation.Character);
+        Assert.Empty (animation.Scenes);
+        Assert.Null (animation.ActiveScene);
+        Assert.False (animation.UseXtermColors);
+        Assert.False (animation.NoColor);
+        Assert.Empty (animation.XtermColorMap);
+        Assert.Equal (0, animation.ActiveSceneCurrentStep);
+    }
+
+    [Fact]
+    public void TestAnimationNewScene ()
+    {
+        var animation = character.Animation;
+        var scene = animation.NewScene ("test_scene", isLooping: true);
+        Assert.IsType<Scene> (scene);
+        Assert.Equal ("test_scene", scene.SceneId);
+        Assert.True (scene.IsLooping);
+        Assert.True (animation.Scenes.ContainsKey ("test_scene"));
+    }
+
+    [Fact]
+    public void TestAnimationNewSceneWithoutId ()
+    {
+        var animation = character.Animation;
+        var scene = animation.NewScene ();
+        Assert.IsType<Scene> (scene);
+        Assert.Equal ("0", scene.SceneId);
+        Assert.True (animation.Scenes.ContainsKey ("0"));
+    }
+
+    [Fact]
+    public void TestAnimationQueryScene ()
+    {
+        var animation = character.Animation;
+        var scene = animation.NewScene ("test_scene", isLooping: true);
+        Assert.Equal (scene, animation.QueryScene ("test_scene"));
+    }
+
+    [Fact]
+    public void TestAnimationLoopingActiveSceneIsComplete ()
+    {
+        var animation = character.Animation;
+        var scene = animation.NewScene ("test_scene", isLooping: true);
+        scene.AddFrame (symbol: "a", duration: 2);
+        animation.ActivateScene (scene);
+        Assert.True (animation.ActiveSceneIsComplete ());
+    }
+
+    [Fact]
+    public void TestAnimationNonLoopingActiveSceneIsComplete ()
+    {
+        var animation = character.Animation;
+        var scene = animation.NewScene ("test_scene");
+        scene.AddFrame (symbol: "a", duration: 1);
+        animation.ActivateScene (scene);
+        Assert.False (animation.ActiveSceneIsComplete ());
+        animation.StepAnimation ();
+        Assert.True (animation.ActiveSceneIsComplete ());
+    }
+}