diff --git a/Terminal.Gui/TextEffects/ArgValidators.cs b/Terminal.Gui/TextEffects/ArgValidators.cs new file mode 100644 index 000000000..4e31e7a51 --- /dev/null +++ b/Terminal.Gui/TextEffects/ArgValidators.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Terminal.Gui.TextEffects; + +using Color = Terminal.Gui.TextEffects.Color; + +public static class PositiveInt +{ + public static int Parse (string arg) + { + if (int.TryParse (arg, out int value) && value > 0) + { + return value; + } + else + { + throw new ArgumentException ($"invalid value: '{arg}' is not > 0."); + } + } +} + +public static class NonNegativeInt +{ + public static int Parse (string arg) + { + if (int.TryParse (arg, out int value) && value >= 0) + { + return value; + } + else + { + throw new ArgumentException ($"invalid value: '{arg}' Argument must be int >= 0."); + } + } +} + +public static class IntRange +{ + public static (int, int) Parse (string arg) + { + var parts = arg.Split ('-'); + if (parts.Length == 2 && int.TryParse (parts [0], out int start) && int.TryParse (parts [1], out int end) && start > 0 && start <= end) + { + return (start, end); + } + else + { + throw new ArgumentException ($"invalid range: '{arg}' is not a valid range. Must be start-end. Ex: 1-10"); + } + } +} + +public static class PositiveFloat +{ + public static float Parse (string arg) + { + if (float.TryParse (arg, out float value) && value > 0) + { + return value; + } + else + { + throw new ArgumentException ($"invalid value: '{arg}' is not a valid value. Argument must be a float > 0."); + } + } +} + +public static class NonNegativeFloat +{ + public static float Parse (string arg) + { + if (float.TryParse (arg, out float value) && value >= 0) + { + return value; + } + else + { + throw new ArgumentException ($"invalid argument value: '{arg}' is out of range. Must be float >= 0."); + } + } +} + +public static class PositiveFloatRange +{ + public static (float, float) Parse (string arg) + { + var parts = arg.Split ('-'); + if (parts.Length == 2 && float.TryParse (parts [0], out float start) && float.TryParse (parts [1], out float end) && start > 0 && start <= end) + { + return (start, end); + } + else + { + throw new ArgumentException ($"invalid range: '{arg}' is not a valid range. Must be start-end. Ex: 0.1-1.0"); + } + } +} + +public static class Ratio +{ + public static float Parse (string arg) + { + if (float.TryParse (arg, out float value) && value >= 0 && value <= 1) + { + return value; + } + else + { + throw new ArgumentException ($"invalid value: '{arg}' is not a float >= 0 and <= 1. Example: 0.5"); + } + } +} + +public enum GradientDirection +{ + Horizontal, + Vertical, + Diagonal, + Radial +} + +public static class GradientDirectionParser +{ + public static GradientDirection Parse (string arg) + { + return arg.ToLower () switch + { + "horizontal" => GradientDirection.Horizontal, + "vertical" => GradientDirection.Vertical, + "diagonal" => GradientDirection.Diagonal, + "radial" => GradientDirection.Radial, + _ => throw new ArgumentException ($"invalid gradient direction: '{arg}' is not a valid gradient direction. Choices are diagonal, horizontal, vertical, or radial."), + }; + } +} + +public static class ColorArg +{ + public static Color Parse (string arg) + { + if (int.TryParse (arg, out int xtermValue) && xtermValue >= 0 && xtermValue <= 255) + { + return new Color (xtermValue); + } + else if (arg.Length == 6 && int.TryParse (arg, NumberStyles.HexNumber, null, out int _)) + { + return new Color (arg); + } + else + { + throw new ArgumentException ($"invalid color value: '{arg}' is not a valid XTerm or RGB color. Must be in range 0-255 or 000000-FFFFFF."); + } + } +} + +public static class Symbol +{ + public static string Parse (string arg) + { + if (arg.Length == 1 && IsAsciiOrUtf8 (arg)) + { + return arg; + } + else + { + throw new ArgumentException ($"invalid symbol: '{arg}' is not a valid symbol. Must be a single ASCII/UTF-8 character."); + } + } + + private static bool IsAsciiOrUtf8 (string s) + { + try + { + Encoding.ASCII.GetBytes (s); + } + catch (EncoderFallbackException) + { + try + { + Encoding.UTF8.GetBytes (s); + } + catch (EncoderFallbackException) + { + return false; + } + } + return true; + } +} + +public static class CanvasDimension +{ + public static int Parse (string arg) + { + if (int.TryParse (arg, out int value) && value >= -1) + { + return value; + } + else + { + throw new ArgumentException ($"invalid value: '{arg}' is not >= -1."); + } + } +} + +public static class TerminalDimensions +{ + public static (int, int) Parse (string arg) + { + var parts = arg.Split (' '); + if (parts.Length == 2 && int.TryParse (parts [0], out int width) && int.TryParse (parts [1], out int height) && width >= 0 && height >= 0) + { + return (width, height); + } + else + { + throw new ArgumentException ($"invalid terminal dimensions: '{arg}' is not a valid terminal dimension. Must be >= 0."); + } + } +} + +public static class Ease +{ + private static readonly Dictionary easingFuncMap = new () + { + {"linear", Easing.Linear}, + {"in_sine", Easing.InSine}, + {"out_sine", Easing.OutSine}, + {"in_out_sine", Easing.InOutSine}, + {"in_quad", Easing.InQuad}, + {"out_quad", Easing.OutQuad}, + {"in_out_quad", Easing.InOutQuad}, + {"in_cubic", Easing.InCubic}, + {"out_cubic", Easing.OutCubic}, + {"in_out_cubic", Easing.InOutCubic}, + {"in_quart", Easing.InQuart}, + {"out_quart", Easing.OutQuart}, + {"in_out_quart", Easing.InOutQuart}, + {"in_quint", Easing.InQuint}, + {"out_quint", Easing.OutQuint}, + {"in_out_quint", Easing.InOutQuint}, + {"in_expo", Easing.InExpo}, + {"out_expo", Easing.OutExpo}, + {"in_out_expo", Easing.InOutExpo}, + {"in_circ", Easing.InCirc}, + {"out_circ", Easing.OutCirc}, + {"in_out_circ", Easing.InOutCirc}, + {"in_back", Easing.InBack}, + {"out_back", Easing.OutBack}, + {"in_out_back", Easing.InOutBack}, + {"in_elastic", Easing.InElastic}, + {"out_elastic", Easing.OutElastic}, + {"in_out_elastic", Easing.InOutElastic}, + {"in_bounce", Easing.InBounce}, + {"out_bounce", Easing.OutBounce}, + {"in_out_bounce", Easing.InOutBounce}, + }; + + public static EasingFunction Parse (string arg) + { + if (easingFuncMap.TryGetValue (arg.ToLower (), out var easingFunc)) + { + return easingFunc; + } + else + { + throw new ArgumentException ($"invalid ease value: '{arg}' is not a valid ease."); + } + } +} diff --git a/Terminal.Gui/TextEffects/BaseEffect.cs b/Terminal.Gui/TextEffects/BaseEffect.cs index 09a9059ba..e10699a9a 100644 --- a/Terminal.Gui/TextEffects/BaseEffect.cs +++ b/Terminal.Gui/TextEffects/BaseEffect.cs @@ -6,10 +6,16 @@ public abstract class BaseEffectIterator where T : EffectConfig, new() protected Terminal Terminal { get; set; } protected List ActiveCharacters { get; set; } = new List (); + protected BaseEffect Effect { get; } + + + public BaseEffectIterator (BaseEffect effect) { + Effect = effect; Config = effect.EffectConfig; Terminal = new Terminal (effect.InputData, effect.TerminalConfig); + } public void Update () diff --git a/Terminal.Gui/TextEffects/Effects/Beams.cs b/Terminal.Gui/TextEffects/Effects/Beams.cs new file mode 100644 index 000000000..b39ef9a91 --- /dev/null +++ b/Terminal.Gui/TextEffects/Effects/Beams.cs @@ -0,0 +1,241 @@ +/*namespace Terminal.Gui.TextEffects.Effects; + +public class BeamsConfig : EffectConfig +{ + public string [] BeamRowSymbols { get; set; } = { "▂", "▁", "_" }; + public string [] BeamColumnSymbols { get; set; } = { "▌", "▍", "▎", "▏" }; + public int BeamDelay { get; set; } = 10; + public (int, int) BeamRowSpeedRange { get; set; } = (10, 40); + public (int, int) BeamColumnSpeedRange { get; set; } = (6, 10); + public Color [] BeamGradientStops { get; set; } = { new Color ("ffffff"), new Color ("00D1FF"), new Color ("8A008A") }; + public int [] BeamGradientSteps { get; set; } = { 2, 8 }; + public int BeamGradientFrames { get; set; } = 2; + public Color [] FinalGradientStops { get; set; } = { new Color ("8A008A"), new Color ("00D1FF"), new Color ("ffffff") }; + public int [] FinalGradientSteps { get; set; } = { 12 }; + public int FinalGradientFrames { get; set; } = 5; + public GradientDirection FinalGradientDirection { get; set; } = GradientDirection.Vertical; + public int FinalWipeSpeed { get; set; } = 1; +} + +public class Beams : BaseEffect +{ + public Beams (string inputData) : base (inputData) + { + } + + protected override BaseEffectIterator CreateIterator () + { + return new BeamsIterator (this); + } +} + + +public class BeamsIterator : BaseEffectIterator +{ + private class Group + { + public List Characters { get; private set; } + public string Direction { get; private set; } + private Terminal Terminal; + private BeamsConfig Config; + private double Speed; + private float NextCharacterCounter; + private List SortedCharacters; + + public Group (List characters, string direction, Terminal terminal, BeamsConfig config) + { + Characters = characters; + Direction = direction; + Terminal = terminal; + Config = config; + Speed = new Random ().Next (config.BeamRowSpeedRange.Item1, config.BeamRowSpeedRange.Item2) * 0.1; + NextCharacterCounter = 0; + SortedCharacters = direction == "row" + ? characters.OrderBy (c => c.InputCoord.Column).ToList () + : characters.OrderBy (c => c.InputCoord.Row).ToList (); + + if (new Random ().Next (0, 2) == 0) + { + SortedCharacters.Reverse (); + } + } + + public void IncrementNextCharacterCounter () + { + NextCharacterCounter += (float)Speed; + } + + public EffectCharacter GetNextCharacter () + { + NextCharacterCounter -= 1; + var nextCharacter = SortedCharacters.First (); + SortedCharacters.RemoveAt (0); + if (nextCharacter.Animation.ActiveScene != null) + { + nextCharacter.Animation.ActiveScene.ResetScene (); + return null; + } + + Terminal.SetCharacterVisibility (nextCharacter, true); + nextCharacter.Animation.ActivateScene (nextCharacter.Animation.QueryScene ("beam_" + Direction)); + return nextCharacter; + } + + public bool Complete () + { + return !SortedCharacters.Any (); + } + } + + private List PendingGroups = new List (); + private Dictionary CharacterFinalColorMap = new Dictionary (); + private List ActiveGroups = new List (); + private int Delay = 0; + private string Phase = "beams"; + private List> FinalWipeGroups; + + public BeamsIterator (Beams effect) : base (effect) + { + Build (); + } + + private void Build () + { + var finalGradient = new Gradient (Effect.Config.FinalGradientStops, Effect.Config.FinalGradientSteps); + var finalGradientMapping = finalGradient.BuildCoordinateColorMapping ( + Effect.Terminal.Canvas.Top, + Effect.Terminal.Canvas.Right, + Effect.Config.FinalGradientDirection + ); + + foreach (var character in Effect.Terminal.GetCharacters (fillChars: true)) + { + CharacterFinalColorMap [character] = finalGradientMapping [character.InputCoord]; + } + + var beamGradient = new Gradient (Effect.Config.BeamGradientStops, Effect.Config.BeamGradientSteps); + var groups = new List (); + + foreach (var row in Effect.Terminal.GetCharactersGrouped (Terminal.CharacterGroup.RowTopToBottom, fillChars: true)) + { + groups.Add (new Group (row, "row", Effect.Terminal, Effect.Config)); + } + + foreach (var column in Effect.Terminal.GetCharactersGrouped (Terminal.CharacterGroup.ColumnLeftToRight, fillChars: true)) + { + groups.Add (new Group (column, "column", Effect.Terminal, Effect.Config)); + } + + foreach (var group in groups) + { + foreach (var character in group.Characters) + { + var beamRowScene = character.Animation.NewScene (id: "beam_row"); + var beamColumnScene = character.Animation.NewScene (id: "beam_column"); + beamRowScene.ApplyGradientToSymbols ( + beamGradient, Effect.Config.BeamRowSymbols, Effect.Config.BeamGradientFrames); + beamColumnScene.ApplyGradientToSymbols ( + beamGradient, Effect.Config.BeamColumnSymbols, Effect.Config.BeamGradientFrames); + + var fadedColor = character.Animation.AdjustColorBrightness (CharacterFinalColorMap [character], 0.3f); + var fadeGradient = new Gradient (CharacterFinalColorMap [character], fadedColor, steps: 10); + beamRowScene.ApplyGradientToSymbols (fadeGradient, character.InputSymbol, 5); + beamColumnScene.ApplyGradientToSymbols (fadeGradient, character.InputSymbol, 5); + + var brightenGradient = new Gradient (fadedColor, CharacterFinalColorMap [character], steps: 10); + var brightenScene = character.Animation.NewScene (id: "brighten"); + brightenScene.ApplyGradientToSymbols ( + brightenGradient, character.InputSymbol, Effect.Config.FinalGradientFrames); + } + } + + PendingGroups = groups; + new Random ().Shuffle (PendingGroups); + } + + public override bool MoveNext () + { + if (Phase != "complete" || ActiveCharacters.Any ()) + { + if (Phase == "beams") + { + if (Delay == 0) + { + if (PendingGroups.Any ()) + { + for (int i = 0; i < new Random ().Next (1, 6); i++) + { + if (PendingGroups.Any ()) + { + ActiveGroups.Add (PendingGroups.First ()); + PendingGroups.RemoveAt (0); + } + } + } + Delay = Effect.Config.BeamDelay; + } + else + { + Delay--; + } + + foreach (var group in ActiveGroups) + { + group.IncrementNextCharacterCounter (); + if ((int)group.NextCharacterCounter > 1) + { + for (int i = 0; i < (int)group.NextCharacterCounter; i++) + { + if (!group.Complete ()) + { + var nextChar = group.GetNextCharacter (); + if (nextChar != null) + { + ActiveCharacters.Add (nextChar); + } + } + } + } + } + + ActiveGroups = ActiveGroups.Where (g => !g.Complete ()).ToList (); + if (!PendingGroups.Any () && !ActiveGroups.Any () && !ActiveCharacters.Any ()) + { + Phase = "final_wipe"; + } + } + else if (Phase == "final_wipe") + { + if (FinalWipeGroups.Any ()) + { + for (int i = 0; i < Effect.Config.FinalWipeSpeed; i++) + { + if (!FinalWipeGroups.Any ()) break; + + var nextGroup = FinalWipeGroups.First (); + FinalWipeGroups.RemoveAt (0); + + foreach (var character in nextGroup) + { + character.Animation.ActivateScene (character.Animation.QueryScene ("brighten")); + Effect.Terminal.SetCharacterVisibility (character, true); + ActiveCharacters.Add (character); + } + } + } + else + { + Phase = "complete"; + } + } + + Update (); + return true; + } + else + { + return false; + } + } +} +*/ \ No newline at end of file diff --git a/Terminal.Gui/TextEffects/Graphics.cs b/Terminal.Gui/TextEffects/Graphics.cs index 73cd02263..03fb2059a 100644 --- a/Terminal.Gui/TextEffects/Graphics.cs +++ b/Terminal.Gui/TextEffects/Graphics.cs @@ -54,51 +54,33 @@ public class Color } } - public class Gradient { public List Spectrum { get; private set; } + private readonly bool _loop; + private readonly List _stops; + private readonly List _steps; + + public enum Direction + { + Vertical, + Horizontal, + Radial, + Diagonal + } - // Constructor now accepts IEnumerable for steps. public Gradient (IEnumerable stops, IEnumerable steps, bool loop = false) { - if (stops == null || !stops.Any () || stops.Count () < 2) - throw new ArgumentException ("At least two color stops are required to create a gradient."); - if (steps == null || !steps.Any ()) - throw new ArgumentException ("Steps are required to define the transitions between colors."); + _stops = stops.ToList (); + if (_stops.Count < 1) + throw new ArgumentException ("At least one color stop must be provided."); - Spectrum = GenerateGradient (stops.ToList (), steps.ToList (), loop); - } + _steps = steps.ToList (); + if (_steps.Any (step => step < 1)) + throw new ArgumentException ("Steps must be greater than 0."); - private List GenerateGradient (List stops, List steps, bool loop) - { - List gradient = new List (); - if (loop) - stops.Add (stops [0]); // Loop the gradient back to the first color. - - for (int i = 0; i < stops.Count - 1; i++) - { - int currentSteps = i < steps.Count ? steps [i] : steps.Last (); - gradient.AddRange (InterpolateColors (stops [i], stops [i + 1], currentSteps)); - } - - return gradient; - } - - private IEnumerable InterpolateColors (Color start, Color end, int steps) - { - for (int step = 0; step <= steps; step++) - { - int r = Interpolate (start.R, end.R, steps, step); - int g = Interpolate (start.G, end.G, steps, step); - int b = Interpolate (start.B, end.B, steps, step); - yield return Color.FromRgb (r, g, b); - } - } - - private int Interpolate (int start, int end, int steps, int currentStep) - { - return start + (int)((end - start) * (double)currentStep / steps); + _loop = loop; + Spectrum = GenerateGradient (_steps); } public Color GetColorAtFraction (double fraction) @@ -108,6 +90,113 @@ public class Gradient int index = (int)(fraction * (Spectrum.Count - 1)); return Spectrum [index]; } -} + private List GenerateGradient (IEnumerable steps) + { + List gradient = new List (); + if (_stops.Count == 1) + { + for (int i = 0; i < steps.Sum (); i++) + { + gradient.Add (_stops [0]); + } + return gradient; + } + if (_loop) + { + _stops.Add (_stops [0]); + } + + var colorPairs = _stops.Zip (_stops.Skip (1), (start, end) => new { start, end }); + var stepsList = _steps.ToList (); + + foreach (var (colorPair, thesteps) in colorPairs.Zip (stepsList, (pair, step) => (pair, step))) + { + gradient.AddRange (InterpolateColors (colorPair.start, colorPair.end, thesteps)); + } + + return gradient; + } + + private IEnumerable InterpolateColors (Color start, Color end, int steps) + { + for (int step = 0; step <= steps; step++) + { + double fraction = (double)step / steps; + int r = (int)(start.R + fraction * (end.R - start.R)); + int g = (int)(start.G + fraction * (end.G - start.G)); + int b = (int)(start.B + fraction * (end.B - start.B)); + yield return Color.FromRgb (r, g, b); + } + } + + public Dictionary BuildCoordinateColorMapping (int maxRow, int maxColumn, Direction direction) + { + var gradientMapping = new Dictionary (); + + switch (direction) + { + case Direction.Vertical: + for (int row = 0; row <= maxRow; row++) + { + double fraction = maxRow == 0 ? 1.0 : (double)row / maxRow; + Color color = GetColorAtFraction (fraction); + for (int col = 0; col <= maxColumn; col++) + { + gradientMapping [new Coord (col, row)] = color; + } + } + break; + + case Direction.Horizontal: + for (int col = 0; col <= maxColumn; col++) + { + double fraction = maxColumn == 0 ? 1.0 : (double)col / maxColumn; + Color color = GetColorAtFraction (fraction); + for (int row = 0; row <= maxRow; row++) + { + gradientMapping [new Coord (col, row)] = color; + } + } + break; + + case Direction.Radial: + for (int row = 0; row <= maxRow; row++) + { + for (int col = 0; col <= maxColumn; col++) + { + double distanceFromCenter = FindNormalizedDistanceFromCenter (maxRow, maxColumn, new Coord (col, row)); + Color color = GetColorAtFraction (distanceFromCenter); + gradientMapping [new Coord (col, row)] = color; + } + } + break; + + case Direction.Diagonal: + for (int row = 0; row <= maxRow; row++) + { + for (int col = 0; col <= maxColumn; col++) + { + double fraction = ((double)row * 2 + col) / ((maxRow * 2) + maxColumn); + Color color = GetColorAtFraction (fraction); + gradientMapping [new Coord (col, row)] = color; + } + } + break; + } + + return gradientMapping; + } + + private double FindNormalizedDistanceFromCenter (int maxRow, int maxColumn, Coord coord) + { + double centerX = maxColumn / 2.0; + double centerY = maxRow / 2.0; + double dx = coord.Column - centerX; + double dy = coord.Row - centerY; + double distance = Math.Sqrt (dx * dx + dy * dy); + double maxDistance = Math.Sqrt (centerX * centerX + centerY * centerY); + return distance / maxDistance; + } +} \ No newline at end of file diff --git a/Terminal.Gui/TextEffects/Motion.cs b/Terminal.Gui/TextEffects/Motion.cs index e2431cd49..9ed544f5e 100644 --- a/Terminal.Gui/TextEffects/Motion.cs +++ b/Terminal.Gui/TextEffects/Motion.cs @@ -1,5 +1,4 @@ namespace Terminal.Gui.TextEffects; - public class Coord { public int Column { get; set; } @@ -12,6 +11,34 @@ public class Coord } public override string ToString () => $"({Column}, {Row})"; + + public override bool Equals (object obj) + { + if (obj is Coord other) + { + return Column == other.Column && Row == other.Row; + } + return false; + } + + public override int GetHashCode () + { + return HashCode.Combine (Column, Row); + } + + public static bool operator == (Coord left, Coord right) + { + if (left is null) + { + return right is null; + } + return left.Equals (right); + } + + public static bool operator != (Coord left, Coord right) + { + return !(left == right); + } } public class Waypoint diff --git a/UICatalog/Scenarios/TextEffectsScenario.cs b/UICatalog/Scenarios/TextEffectsScenario.cs index 2652c5249..3cdd90eee 100644 --- a/UICatalog/Scenarios/TextEffectsScenario.cs +++ b/UICatalog/Scenarios/TextEffectsScenario.cs @@ -84,6 +84,53 @@ internal class TextEffectsExampleView : View resized = false; } + DrawTopLineGradient (viewport); + DrawRadialGradient (viewport); + + _ball?.Draw (); + } + + private void DrawRadialGradient (Rectangle viewport) + { + // Define the colors of the gradient stops + var stops = new List + { + Color.FromRgb(255, 0, 0), // Red + Color.FromRgb(0, 255, 0), // Green + Color.FromRgb(238, 130, 238) // Violet + }; + + // Define the number of steps between each color + var steps = new List { 10, 10 }; // 10 steps between Red -> Green, and Green -> Blue + + // Create the gradient + var radialGradient = new Gradient (stops, steps, loop: false); + + // Define the size of the rectangle + int maxRow = 20; + int maxColumn = 40; + + // Build the coordinate-color mapping for a radial gradient + var gradientMapping = radialGradient.BuildCoordinateColorMapping (maxRow, maxColumn, Gradient.Direction.Radial); + + // Print the gradient + for (int row = 0; row <= maxRow; row++) + { + for (int col = 0; col <= maxColumn; col++) + { + var coord = new Coord (col, row); + var color = gradientMapping [coord]; + + SetColor (color); + + AddRune (col+2, row+3, new Rune ('█')); + } + } + } + + private void DrawTopLineGradient (Rectangle viewport) + { + // Define the colors of the rainbow var stops = new List { @@ -115,17 +162,21 @@ internal class TextEffectsExampleView : View double fraction = (double)x / (viewport.Width - 1); Color color = rainbowGradient.GetColorAtFraction (fraction); - // Assuming AddRune is a method you have for drawing at specific positions - Application.Driver.SetAttribute ( - new Attribute ( - new Terminal.Gui.Color (color.R, color.G, color.B), - new Terminal.Gui.Color (color.R, color.G, color.B) - )); // Setting color based on RGB + SetColor (color); AddRune (x, 0, new Rune ('█')); } + } + + private void SetColor (Color color) + { + // Assuming AddRune is a method you have for drawing at specific positions + Application.Driver.SetAttribute ( + new Attribute ( + new Terminal.Gui.Color (color.R, color.G, color.B), + new Terminal.Gui.Color (color.R, color.G, color.B) + )); // Setting color based on RGB - _ball?.Draw (); } public class Ball