Skip to content

Commit

Permalink
Merge pull request gui-cs#3586 from tznind/gradients
Browse files Browse the repository at this point in the history
Gradients - From Terminal Text Effects
  • Loading branch information
tig authored Jul 9, 2024
2 parents 9c19221 + 097a800 commit 4d86227
Show file tree
Hide file tree
Showing 21 changed files with 1,366 additions and 162 deletions.
41 changes: 41 additions & 0 deletions Terminal.Gui/Drawing/FillPair.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace Terminal.Gui;

/// <summary>
/// Describes a pair of <see cref="IFill"/> which cooperate in creating
/// <see cref="Attribute"/>. One gives foreground color while other gives background.
/// </summary>
public class FillPair
{
/// <summary>
/// Creates a new instance using the provided fills for foreground and background
/// color when assembling <see cref="Attribute"/>.
/// </summary>
/// <param name="fore"></param>
/// <param name="back"></param>
public FillPair (IFill fore, IFill back)
{
Foreground = fore;
Background = back;
}

/// <summary>
/// The fill which provides point based foreground color.
/// </summary>
public IFill Foreground { get; init; }

/// <summary>
/// The fill which provides point based background color.
/// </summary>
public IFill Background { get; init; }

/// <summary>
/// Returns the color pair (foreground+background) to use when rendering
/// a rune at the given <paramref name="point"/>.
/// </summary>
/// <param name="point"></param>
/// <returns></returns>
public Attribute GetAttribute (Point point)
{
return new (Foreground.GetColor (point), Background.GetColor (point));
}
}
255 changes: 255 additions & 0 deletions Terminal.Gui/Drawing/Gradient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// This code is a C# port from python library Terminal Text Effects https://github.com/ChrisBuilds/terminaltexteffects/

namespace Terminal.Gui;

/// <summary>
/// Describes the pattern that a <see cref="Gradient"/> results in e.g. <see cref="Vertical"/>,
/// <see cref="Horizontal"/> etc
/// </summary>
public enum GradientDirection
{
/// <summary>
/// Color varies along Y axis but is constant on X axis.
/// </summary>
Vertical,

/// <summary>
/// Color varies along X axis but is constant on Y axis.
/// </summary>
Horizontal,

/// <summary>
/// Color varies by distance from center (i.e. in circular ripples)
/// </summary>
Radial,

/// <summary>
/// Color varies by X and Y axis (i.e. a slanted gradient)
/// </summary>
Diagonal
}

/// <summary>
/// Describes a <see cref="Spectrum"/> of colors that can be combined
/// to make a color gradient. Use <see cref="BuildCoordinateColorMapping"/>
/// to create into gradient fill area maps.
/// </summary>
public class Gradient
{
/// <summary>
/// The discrete colors that will make up the <see cref="Gradient"/>.
/// </summary>
public List<Color> Spectrum { get; }

private readonly bool _loop;
private readonly List<Color> _stops;
private readonly List<int> _steps;

/// <summary>
/// Creates a new instance of the <see cref="Gradient"/> class which hosts a <see cref="Spectrum"/>
/// of colors including all <paramref name="stops"/> and <paramref name="steps"/> interpolated colors
/// between each corresponding pair.
/// </summary>
/// <param name="stops">The colors to use in the spectrum (N)</param>
/// <param name="steps">
/// The number of colors to generate between each pair (must be N-1 numbers).
/// If only one step is passed then it is assumed to be the same distance for all pairs.
/// </param>
/// <param name="loop">True to duplicate the first stop and step so that the gradient repeats itself</param>
/// <exception cref="ArgumentException"></exception>
public Gradient (IEnumerable<Color> stops, IEnumerable<int> steps, bool loop = false)
{
_stops = stops.ToList ();

if (_stops.Count < 1)
{
throw new ArgumentException ("At least one color stop must be provided.");
}

_steps = steps.ToList ();

// If multiple colors and only 1 step assume same distance applies to all steps
if (_stops.Count > 2 && _steps.Count == 1)
{
_steps = Enumerable.Repeat (_steps.Single (), _stops.Count () - 1).ToList ();
}

if (_steps.Any (step => step < 1))
{
throw new ArgumentException ("Steps must be greater than 0.");
}

if (_steps.Count != _stops.Count - 1)
{
throw new ArgumentException ("Number of steps must be N-1");
}

_loop = loop;
Spectrum = GenerateGradient (_steps);
}

/// <summary>
/// Returns the color to use at the given part of the spectrum
/// </summary>
/// <param name="fraction">
/// Proportion of the way through the spectrum, must be between
/// 0 and 1 (inclusive). Returns the last color if <paramref name="fraction"/> is
/// <see cref="double.NaN"/>.
/// </param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public Color GetColorAtFraction (double fraction)
{
if (double.IsNaN (fraction))
{
return Spectrum.Last ();
}

if (fraction is < 0 or > 1)
{
throw new ArgumentOutOfRangeException (nameof (fraction), @"Fraction must be between 0 and 1.");
}

var index = (int)(fraction * (Spectrum.Count - 1));

return Spectrum [index];
}

private List<Color> GenerateGradient (IEnumerable<int> steps)
{
List<Color> gradient = new ();

if (_stops.Count == 1)
{
for (var i = 0; i < steps.Sum (); i++)
{
gradient.Add (_stops [0]);
}

return gradient;
}

List<Color> stopsToUse = _stops.ToList ();
List<int> stepsToUse = _steps.ToList ();

if (_loop)
{
stopsToUse.Add (_stops [0]);
stepsToUse.Add (_steps.First ());
}

var colorPairs = stopsToUse.Zip (stopsToUse.Skip (1), (start, end) => new { start, end });
List<int> stepsList = stepsToUse;

foreach ((var colorPair, int thesteps) in colorPairs.Zip (stepsList, (pair, step) => (pair, step)))
{
gradient.AddRange (InterpolateColors (colorPair.start, colorPair.end, thesteps));
}

return gradient;
}

private static IEnumerable<Color> InterpolateColors (Color start, Color end, int steps)
{
for (var step = 0; step < steps; step++)
{
double fraction = (double)step / steps;
var r = (int)(start.R + fraction * (end.R - start.R));
var g = (int)(start.G + fraction * (end.G - start.G));
var b = (int)(start.B + fraction * (end.B - start.B));

yield return new (r, g, b);
}

yield return end; // Ensure the last color is included
}

/// <summary>
/// <para>
/// Creates a mapping starting at 0,0 and going to <paramref name="maxRow"/> and <paramref name="maxColumn"/>
/// (inclusively) using the supplied <paramref name="direction"/>.
/// </para>
/// <para>
/// Note that this method is inclusive i.e. passing 1/1 results in 4 mapped coordinates.
/// </para>
/// </summary>
/// <param name="maxRow"></param>
/// <param name="maxColumn"></param>
/// <param name="direction"></param>
/// <returns></returns>
public Dictionary<Point, Color> BuildCoordinateColorMapping (int maxRow, int maxColumn, GradientDirection direction)
{
Dictionary<Point, Color> gradientMapping = new ();

switch (direction)
{
case GradientDirection.Vertical:
for (var row = 0; row <= maxRow; row++)
{
double fraction = maxRow == 0 ? 1.0 : (double)row / maxRow;
Color color = GetColorAtFraction (fraction);

for (var col = 0; col <= maxColumn; col++)
{
gradientMapping [new (col, row)] = color;
}
}

break;

case GradientDirection.Horizontal:
for (var col = 0; col <= maxColumn; col++)
{
double fraction = maxColumn == 0 ? 1.0 : (double)col / maxColumn;
Color color = GetColorAtFraction (fraction);

for (var row = 0; row <= maxRow; row++)
{
gradientMapping [new (col, row)] = color;
}
}

break;

case GradientDirection.Radial:
for (var row = 0; row <= maxRow; row++)
{
for (var col = 0; col <= maxColumn; col++)
{
double distanceFromCenter = FindNormalizedDistanceFromCenter (maxRow, maxColumn, new (col, row));
Color color = GetColorAtFraction (distanceFromCenter);
gradientMapping [new (col, row)] = color;
}
}

break;

case GradientDirection.Diagonal:
for (var row = 0; row <= maxRow; row++)
{
for (var col = 0; col <= maxColumn; col++)
{
double fraction = ((double)row * 2 + col) / (maxRow * 2 + maxColumn);
Color color = GetColorAtFraction (fraction);
gradientMapping [new (col, row)] = color;
}
}

break;
}

return gradientMapping;
}

private static double FindNormalizedDistanceFromCenter (int maxRow, int maxColumn, Point coord)
{
double centerX = maxColumn / 2.0;
double centerY = maxRow / 2.0;
double dx = coord.X - centerX;
double dy = coord.Y - centerY;
double distance = Math.Sqrt (dx * dx + dy * dy);
double maxDistance = Math.Sqrt (centerX * centerX + centerY * centerY);

return distance / maxDistance;
}
}
42 changes: 42 additions & 0 deletions Terminal.Gui/Drawing/GradientFill.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace Terminal.Gui;

/// <summary>
/// Implementation of <see cref="IFill"/> that uses a color gradient (including
/// radial, diagonal etc.).
/// </summary>
public class GradientFill : IFill
{
private readonly Dictionary<Point, Color> _map;

/// <summary>
/// Creates a new instance of the <see cref="GradientFill"/> class that can return
/// color for any point in the given <paramref name="area"/> using the provided
/// <paramref name="gradient"/> and <paramref name="direction"/>.
/// </summary>
/// <param name="area"></param>
/// <param name="gradient"></param>
/// <param name="direction"></param>
public GradientFill (Rectangle area, Gradient gradient, GradientDirection direction)
{
_map = gradient.BuildCoordinateColorMapping (area.Height - 1, area.Width - 1, direction)
.ToDictionary (
kvp => new Point (kvp.Key.X + area.X, kvp.Key.Y + area.Y),
kvp => kvp.Value);
}

/// <summary>
/// Returns the color to use for the given <paramref name="point"/> or Black if it
/// lies outside the prepared gradient area (see constructor).
/// </summary>
/// <param name="point"></param>
/// <returns></returns>
public Color GetColor (Point point)
{
if (_map.TryGetValue (point, out Color color))
{
return color;
}

return new (0, 0); // Default to black if point not found
}
}
14 changes: 14 additions & 0 deletions Terminal.Gui/Drawing/IFill.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Terminal.Gui;

/// <summary>
/// Describes an area fill (e.g. solid color or gradient).
/// </summary>
public interface IFill
{
/// <summary>
/// Returns the color that should be used at the given point
/// </summary>
/// <param name="point"></param>
/// <returns></returns>
Color GetColor (Point point);
}
Loading

0 comments on commit 4d86227

Please sign in to comment.