Core/Gradient.cs
using System;
using System.Globalization;
using System.Text;
using Sandbox;
using Sandbox.UI;
namespace Goo;
public enum GradientKind
{
Linear,
Radial,
Conic,
}
public enum LinearGradientDirection
{
Angle,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
public enum RadialGradientShape
{
Ellipse,
Circle,
}
public enum RadialGradientExtent
{
ClosestSide,
ClosestCorner,
FarthestSide,
FarthestCorner,
}
public readonly struct GradientStop : IEquatable<GradientStop>
{
public Color Color { get; }
public Length? Position { get; }
public GradientStop(Color color, Length? position = null)
{
UiCssSerializer.ValidateColor(color, nameof(color));
if (position.HasValue)
UiCssSerializer.ValidateLength(position.Value, nameof(position));
Color = color;
Position = position;
}
public static GradientStop Percent(Color color, float percent)
=> new(color, new Length { Value = percent, Unit = LengthUnit.Percentage });
public static GradientStop Pixels(Color color, float pixels)
=> new(color, new Length { Value = pixels, Unit = LengthUnit.Pixels });
public static implicit operator GradientStop(Color color) => new(color);
public bool Equals(GradientStop other)
=> Color == other.Color && Position == other.Position;
public override bool Equals(object? obj) => obj is GradientStop other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Color, Position);
public static bool operator ==(GradientStop left, GradientStop right) => left.Equals(right);
public static bool operator !=(GradientStop left, GradientStop right) => !left.Equals(right);
}
public readonly struct Gradient : IEquatable<Gradient>
{
const int MaxStops = 8;
readonly GradientStop[]? _stops;
public GradientKind Kind { get; }
public float AngleDegrees { get; }
public LinearGradientDirection LinearDirection { get; }
public RadialGradientShape RadialShape { get; }
public RadialGradientExtent RadialExtent { get; }
public Length CenterX { get; }
public Length CenterY { get; }
public int Count => _stops?.Length ?? 0;
public bool IsEmpty => Count == 0;
public GradientStop this[int index] => _stops![index];
Gradient(
GradientKind kind,
float angleDegrees,
LinearGradientDirection linearDirection,
RadialGradientShape radialShape,
RadialGradientExtent radialExtent,
Length centerX,
Length centerY,
GradientStop[] stops)
{
Kind = kind;
AngleDegrees = angleDegrees;
LinearDirection = linearDirection;
RadialShape = radialShape;
RadialExtent = radialExtent;
CenterX = centerX;
CenterY = centerY;
_stops = stops;
}
public static Gradient Linear(params GradientStop[] stops)
=> Linear(180f, stops);
public static Gradient Linear(float angleDegrees, params GradientStop[] stops)
=> Create(GradientKind.Linear, angleDegrees, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops);
public static Gradient LinearTo(LinearGradientDirection direction, params GradientStop[] stops)
{
if (direction == LinearGradientDirection.Angle || !Enum.IsDefined(direction))
throw new ArgumentOutOfRangeException(nameof(direction));
return Create(GradientKind.Linear, 180f, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops, direction);
}
public static Gradient Radial(params GradientStop[] stops)
=> Create(GradientKind.Radial, 0f, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops);
public static Gradient RadialCircle(params GradientStop[] stops)
=> Create(GradientKind.Radial, 0f, RadialGradientShape.Circle, RadialGradientExtent.FarthestCorner, stops);
public static Gradient Conic(params GradientStop[] stops)
=> Conic(0f, stops);
public static Gradient Conic(float angleDegrees, params GradientStop[] stops)
=> Create(GradientKind.Conic, angleDegrees, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops);
public Gradient At(Length? x, Length? y)
{
if (Kind == GradientKind.Linear)
throw new InvalidOperationException("Linear gradients do not have a center.");
if (!x.HasValue || !y.HasValue)
throw new ArgumentException("Gradient centers require both coordinates.");
UiCssSerializer.ValidateLength(x.Value, nameof(x));
UiCssSerializer.ValidateLength(y.Value, nameof(y));
return Copy(centerX: x.Value, centerY: y.Value);
}
public Gradient At(Vector2 unitPosition)
=> At(
new Length { Value = unitPosition.x * 100f, Unit = LengthUnit.Percentage },
new Length { Value = unitPosition.y * 100f, Unit = LengthUnit.Percentage });
public Gradient WithRadialShape(RadialGradientShape shape)
{
RequireRadial();
return Copy(radialShape: shape);
}
public Gradient WithExtent(RadialGradientExtent extent)
{
RequireRadial();
return Copy(radialExtent: extent);
}
static Gradient Create(
GradientKind kind,
float angleDegrees,
RadialGradientShape radialShape,
RadialGradientExtent radialExtent,
GradientStop[]? stops,
LinearGradientDirection linearDirection = LinearGradientDirection.Angle)
{
if (!float.IsFinite(angleDegrees))
throw new ArgumentOutOfRangeException(nameof(angleDegrees));
if (stops is null || stops.Length < 2 || stops.Length > MaxStops)
throw new ArgumentException($"Gradients require 2 to {MaxStops} stops.", nameof(stops));
var copy = new GradientStop[stops.Length];
Array.Copy(stops, copy, stops.Length);
var center = new Length { Value = 50f, Unit = LengthUnit.Percentage };
return new Gradient(kind, angleDegrees, linearDirection, radialShape, radialExtent, center, center, copy);
}
Gradient Copy(
RadialGradientShape? radialShape = null,
RadialGradientExtent? radialExtent = null,
Length? centerX = null,
Length? centerY = null)
=> new(
Kind,
AngleDegrees,
LinearDirection,
radialShape ?? RadialShape,
radialExtent ?? RadialExtent,
centerX ?? CenterX,
centerY ?? CenterY,
_stops!);
void RequireRadial()
{
if (Kind != GradientKind.Radial)
throw new InvalidOperationException("This option applies only to radial gradients.");
}
internal string ToCss()
{
if (IsEmpty) return "none";
var css = new StringBuilder();
switch (Kind)
{
case GradientKind.Linear:
css.Append("linear-gradient(");
if (LinearDirection == LinearGradientDirection.Angle)
css.Append(UiCssSerializer.Number(AngleDegrees)).Append("deg, ");
else
css.Append(LinearDirection switch
{
LinearGradientDirection.TopLeft => "to top left, ",
LinearGradientDirection.TopRight => "to top right, ",
LinearGradientDirection.BottomLeft => "to bottom left, ",
LinearGradientDirection.BottomRight => "to bottom right, ",
_ => throw new InvalidOperationException("Invalid linear gradient direction."),
});
break;
case GradientKind.Radial:
css.Append("radial-gradient(")
.Append(RadialShape == RadialGradientShape.Circle ? "circle " : "ellipse ")
.Append(RadialExtent switch
{
RadialGradientExtent.ClosestSide => "closest-side",
RadialGradientExtent.ClosestCorner => "closest-corner",
RadialGradientExtent.FarthestSide => "farthest-side",
_ => "farthest-corner",
})
.Append(" at ").Append(UiCssSerializer.Length(CenterX))
.Append(' ').Append(UiCssSerializer.Length(CenterY)).Append(", ");
break;
case GradientKind.Conic:
css.Append("conic-gradient(from ").Append(UiCssSerializer.Number(AngleDegrees))
.Append("deg at ").Append(UiCssSerializer.Length(CenterX))
.Append(' ').Append(UiCssSerializer.Length(CenterY)).Append(", ");
break;
default:
return "none";
}
for (int i = 0; i < Count; i++)
{
if (i > 0) css.Append(", ");
var stop = this[i];
css.Append(UiCssSerializer.Color(stop.Color));
if (stop.Position.HasValue)
css.Append(' ').Append(UiCssSerializer.Length(stop.Position.Value));
}
return css.Append(')').ToString();
}
public bool Equals(Gradient other)
{
if (IsEmpty || other.IsEmpty) return IsEmpty == other.IsEmpty;
if (Kind != other.Kind || AngleDegrees != other.AngleDegrees || LinearDirection != other.LinearDirection
|| RadialShape != other.RadialShape || RadialExtent != other.RadialExtent
|| CenterX != other.CenterX || CenterY != other.CenterY || Count != other.Count)
return false;
for (int i = 0; i < Count; i++)
if (this[i] != other[i]) return false;
return true;
}
public override bool Equals(object? obj) => obj is Gradient other && Equals(other);
public override int GetHashCode()
{
if (IsEmpty) return 0;
var hash = new HashCode();
hash.Add(Kind);
hash.Add(AngleDegrees);
hash.Add(LinearDirection);
hash.Add(RadialShape);
hash.Add(RadialExtent);
hash.Add(CenterX);
hash.Add(CenterY);
for (int i = 0; i < Count; i++) hash.Add(this[i]);
return hash.ToHashCode();
}
public static bool operator ==(Gradient left, Gradient right) => left.Equals(right);
public static bool operator !=(Gradient left, Gradient right) => !left.Equals(right);
}
internal static class UiCssSerializer
{
internal static string Number(float value)
=> value.ToString("R", CultureInfo.InvariantCulture);
internal static string Length(Length value)
=> Number(value.Value) + (value.Unit == LengthUnit.Percentage ? "%" : "px");
internal static string Color(Color value)
=> $"rgba({Number(value.r * 255f)},{Number(value.g * 255f)},{Number(value.b * 255f)},{Number(value.a)})";
internal static void ValidateLength(Length value, string paramName)
{
if (!float.IsFinite(value.Value) || value.Unit is not (LengthUnit.Pixels or LengthUnit.Percentage))
throw new ArgumentOutOfRangeException(paramName, "Only finite pixel and percentage lengths are supported.");
}
internal static void ValidateColor(Color value, string paramName)
{
if (!float.IsFinite(value.r) || !float.IsFinite(value.g) || !float.IsFinite(value.b) || !float.IsFinite(value.a)
|| !float.IsFinite(value.r * 255f) || !float.IsFinite(value.g * 255f) || !float.IsFinite(value.b * 255f))
throw new ArgumentOutOfRangeException(paramName, "Color channels must be finite.");
}
}