|
| 1 | +using System; |
| 2 | +using System.Runtime.CompilerServices; |
| 3 | + |
| 4 | +namespace Tedd.TUI.Archive; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// 32-bit RGBA color used throughout the Tedd.TUI rendering pipeline. Internally |
| 8 | +/// packed as <c>0xAARRGGBB</c> in a single <see cref="uint"/> so equality and hash |
| 9 | +/// codes collapse to a single integer compare. Supplies an implicit conversion from |
| 10 | +/// <see cref="ConsoleColor"/> so legacy code (and the 16-color renderer fallback) |
| 11 | +/// keeps working unchanged. |
| 12 | +/// </summary> |
| 13 | +/// <remarks> |
| 14 | +/// <para>The static palette properties (<see cref="Red"/>, <see cref="DarkBlue"/>, ...) |
| 15 | +/// mirror the values previously encoded in <c>RgbColorPalette</c> and |
| 16 | +/// <c>tuiInterop.colors</c>, so visual output stays identical when running against the |
| 17 | +/// legacy 16-color console fallback.</para> |
| 18 | +/// <para>Color composition uses the Porter-Duff "over" operator via <see cref="Blend"/>. |
| 19 | +/// Renderers that target 16-color hosts use <see cref="ToNearestConsoleColor"/> to quantize.</para> |
| 20 | +/// </remarks> |
| 21 | +public readonly struct TuiColorArchive : IEquatable<TuiColorArchive> |
| 22 | +{ |
| 23 | + private readonly uint _packed; |
| 24 | + |
| 25 | + /// <summary>Packed ARGB value (0xAARRGGBB).</summary> |
| 26 | + public uint Packed |
| 27 | + { |
| 28 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 29 | + get => _packed; |
| 30 | + } |
| 31 | + |
| 32 | + public byte A |
| 33 | + { |
| 34 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 35 | + get => (byte)((_packed >> 24) & 0xFF); |
| 36 | + } |
| 37 | + |
| 38 | + public byte R |
| 39 | + { |
| 40 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 41 | + get => (byte)((_packed >> 16) & 0xFF); |
| 42 | + } |
| 43 | + |
| 44 | + public byte G |
| 45 | + { |
| 46 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 47 | + get => (byte)((_packed >> 8) & 0xFF); |
| 48 | + } |
| 49 | + |
| 50 | + public byte B |
| 51 | + { |
| 52 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 53 | + get => (byte)(_packed & 0xFF); |
| 54 | + } |
| 55 | + |
| 56 | + /// <summary>True when the color is fully opaque (A == 255).</summary> |
| 57 | + public bool IsOpaque |
| 58 | + { |
| 59 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 60 | + get => (_packed & 0xFF000000u) == 0xFF000000u; |
| 61 | + } |
| 62 | + |
| 63 | + /// <summary>True when the color is fully transparent (A == 0).</summary> |
| 64 | + public bool IsTransparent |
| 65 | + { |
| 66 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 67 | + get => (_packed & 0xFF000000u) == 0u; |
| 68 | + } |
| 69 | + |
| 70 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 71 | + public TuiColorArchive(byte r, byte g, byte b, byte a = 255) |
| 72 | + { |
| 73 | + _packed = ((uint)a << 24) | ((uint)r << 16) | ((uint)g << 8) | b; |
| 74 | + } |
| 75 | + |
| 76 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 77 | + private TuiColorArchive(uint packed) |
| 78 | + { |
| 79 | + _packed = packed; |
| 80 | + } |
| 81 | + |
| 82 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 83 | + public static TuiColorArchive FromRgb(byte r, byte g, byte b, byte a = 255) => new TuiColorArchive(r, g, b, a); |
| 84 | + |
| 85 | + /// <summary>Builds a color from a packed 0xAARRGGBB unsigned int.</summary> |
| 86 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 87 | + public static TuiColorArchive FromArgb(uint argb) => new TuiColorArchive(argb); |
| 88 | + |
| 89 | + /// <summary> |
| 90 | + /// Parses a CSS-style color string. Accepts <c>#RRGGBB</c>, <c>#RRGGBBAA</c>, |
| 91 | + /// <c>#RGB</c>, <c>rgb(r,g,b)</c>, <c>rgba(r,g,b,a)</c>, or any of the |
| 92 | + /// 16 <see cref="ConsoleColor"/> names (case-insensitive). |
| 93 | + /// </summary> |
| 94 | + public static TuiColorArchive FromHex(string text) |
| 95 | + { |
| 96 | + if (string.IsNullOrWhiteSpace(text)) |
| 97 | + throw new ArgumentException("Color string is empty", nameof(text)); |
| 98 | + |
| 99 | + ReadOnlySpan<char> s = text.AsSpan().Trim(); |
| 100 | + |
| 101 | + if (s[0] == '#') |
| 102 | + { |
| 103 | + var hex = s.Slice(1); |
| 104 | + switch (hex.Length) |
| 105 | + { |
| 106 | + case 3: |
| 107 | + { |
| 108 | + byte r = ParseHexNibble(hex[0]); |
| 109 | + byte g = ParseHexNibble(hex[1]); |
| 110 | + byte b = ParseHexNibble(hex[2]); |
| 111 | + return new TuiColorArchive((byte)(r | (r << 4)), (byte)(g | (g << 4)), (byte)(b | (b << 4))); |
| 112 | + } |
| 113 | + case 6: |
| 114 | + return new TuiColorArchive(ParseHexByte(hex[0], hex[1]), ParseHexByte(hex[2], hex[3]), ParseHexByte(hex[4], hex[5])); |
| 115 | + case 8: |
| 116 | + return new TuiColorArchive( |
| 117 | + ParseHexByte(hex[0], hex[1]), |
| 118 | + ParseHexByte(hex[2], hex[3]), |
| 119 | + ParseHexByte(hex[4], hex[5]), |
| 120 | + ParseHexByte(hex[6], hex[7])); |
| 121 | + default: |
| 122 | + throw new FormatException($"Invalid hex color '{text}'. Expected #RGB, #RRGGBB, or #RRGGBBAA."); |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + if (s.Length > 4 && (s[0] == 'r' || s[0] == 'R')) |
| 127 | + { |
| 128 | + return ParseFunctional(text); |
| 129 | + } |
| 130 | + |
| 131 | + if (Enum.TryParse<ConsoleColor>(text, ignoreCase: true, out var cc)) |
| 132 | + return FromConsole(cc); |
| 133 | + |
| 134 | + throw new FormatException($"Unrecognized color string '{text}'."); |
| 135 | + } |
| 136 | + |
| 137 | + private static TuiColorArchive ParseFunctional(string text) |
| 138 | + { |
| 139 | + int open = text.IndexOf('('); |
| 140 | + int close = text.IndexOf(')'); |
| 141 | + if (open < 0 || close < 0 || close <= open) |
| 142 | + throw new FormatException($"Malformed color '{text}'."); |
| 143 | + |
| 144 | + bool hasAlpha = text.AsSpan(0, open).Trim().Equals("rgba", StringComparison.OrdinalIgnoreCase); |
| 145 | + var inside = text.Substring(open + 1, close - open - 1); |
| 146 | + var parts = inside.Split(','); |
| 147 | + |
| 148 | + if (hasAlpha && parts.Length != 4) |
| 149 | + throw new FormatException($"rgba() requires 4 components: '{text}'."); |
| 150 | + if (!hasAlpha && parts.Length != 3) |
| 151 | + throw new FormatException($"rgb() requires 3 components: '{text}'."); |
| 152 | + |
| 153 | + byte r = ParseColorComponent(parts[0]); |
| 154 | + byte g = ParseColorComponent(parts[1]); |
| 155 | + byte b = ParseColorComponent(parts[2]); |
| 156 | + byte a = hasAlpha ? ParseAlphaComponent(parts[3]) : (byte)255; |
| 157 | + return new TuiColorArchive(r, g, b, a); |
| 158 | + } |
| 159 | + |
| 160 | + private static byte ParseColorComponent(string component) |
| 161 | + { |
| 162 | + var trimmed = component.Trim(); |
| 163 | + if (byte.TryParse(trimmed, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var v)) |
| 164 | + return v; |
| 165 | + throw new FormatException($"Invalid color component '{component}'."); |
| 166 | + } |
| 167 | + |
| 168 | + private static byte ParseAlphaComponent(string component) |
| 169 | + { |
| 170 | + var trimmed = component.Trim(); |
| 171 | + if (trimmed.IndexOf('.') >= 0) |
| 172 | + { |
| 173 | + if (double.TryParse(trimmed, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d)) |
| 174 | + return (byte)Math.Clamp((int)Math.Round(d * 255.0), 0, 255); |
| 175 | + throw new FormatException($"Invalid alpha '{component}'."); |
| 176 | + } |
| 177 | + |
| 178 | + if (byte.TryParse(trimmed, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var b)) |
| 179 | + return b; |
| 180 | + throw new FormatException($"Invalid alpha '{component}'."); |
| 181 | + } |
| 182 | + |
| 183 | + private static byte ParseHexNibble(char c) |
| 184 | + { |
| 185 | + if (c >= '0' && c <= '9') return (byte)(c - '0'); |
| 186 | + if (c >= 'a' && c <= 'f') return (byte)(c - 'a' + 10); |
| 187 | + if (c >= 'A' && c <= 'F') return (byte)(c - 'A' + 10); |
| 188 | + throw new FormatException($"Invalid hex digit '{c}'."); |
| 189 | + } |
| 190 | + |
| 191 | + private static byte ParseHexByte(char hi, char lo) => (byte)((ParseHexNibble(hi) << 4) | ParseHexNibble(lo)); |
| 192 | + |
| 193 | + /// <summary> |
| 194 | + /// Returns the canonical RGB triple for a <see cref="ConsoleColor"/>. Values mirror |
| 195 | + /// the existing 16-color palette used by the Blazor surface and ASCII renderers. |
| 196 | + /// </summary> |
| 197 | + public static TuiColorArchive FromConsole(ConsoleColor color) |
| 198 | + { |
| 199 | + int idx = (int)color; |
| 200 | + if ((uint)idx >= (uint)PaletteR.Length) return Transparent; |
| 201 | + return new TuiColorArchive(PaletteR[idx], PaletteG[idx], PaletteB[idx]); |
| 202 | + } |
| 203 | + |
| 204 | + /// <summary> |
| 205 | + /// Implicit conversion from the legacy <see cref="ConsoleColor"/> enum so that |
| 206 | + /// existing code (and the rendered XAML <c>Foreground="Red"</c> shorthand) keeps |
| 207 | + /// working unchanged. |
| 208 | + /// </summary> |
| 209 | + public static implicit operator TuiColorArchive(ConsoleColor color) => FromConsole(color); |
| 210 | + |
| 211 | + /// <summary> |
| 212 | + /// Quantizes this color to the nearest of the 16 <see cref="ConsoleColor"/> entries |
| 213 | + /// in squared Euclidean RGB distance. Used by the legacy 16-color renderer fallback. |
| 214 | + /// </summary> |
| 215 | + public ConsoleColor ToNearestConsoleColor() |
| 216 | + { |
| 217 | + byte r = R; |
| 218 | + byte g = G; |
| 219 | + byte b = B; |
| 220 | + int bestIndex = 0; |
| 221 | + int bestDist = int.MaxValue; |
| 222 | + for (int i = 0; i < PaletteR.Length; i++) |
| 223 | + { |
| 224 | + int dr = r - PaletteR[i]; |
| 225 | + int dg = g - PaletteG[i]; |
| 226 | + int db = b - PaletteB[i]; |
| 227 | + int dist = dr * dr + dg * dg + db * db; |
| 228 | + if (dist < bestDist) |
| 229 | + { |
| 230 | + bestDist = dist; |
| 231 | + bestIndex = i; |
| 232 | + if (dist == 0) break; |
| 233 | + } |
| 234 | + } |
| 235 | + return (ConsoleColor)bestIndex; |
| 236 | + } |
| 237 | + |
| 238 | + /// <summary> |
| 239 | + /// Porter-Duff "source over destination" composition. The receiver is the source |
| 240 | + /// (top), <paramref name="under"/> is the destination (already on the surface). |
| 241 | + /// Returns a fully opaque color when both inputs are opaque. |
| 242 | + /// </summary> |
| 243 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 244 | + public TuiColorArchive Blend(TuiColorArchive under) |
| 245 | + { |
| 246 | + uint srcA = (_packed >> 24) & 0xFF; |
| 247 | + if (srcA == 255) return this; |
| 248 | + if (srcA == 0) return under; |
| 249 | + |
| 250 | + uint dstA = (under._packed >> 24) & 0xFF; |
| 251 | + uint outA = srcA + ((dstA * (255 - srcA) + 127) / 255); |
| 252 | + if (outA == 0) return Transparent; |
| 253 | + |
| 254 | + uint srcR = (_packed >> 16) & 0xFF; |
| 255 | + uint srcG = (_packed >> 8) & 0xFF; |
| 256 | + uint srcB = _packed & 0xFF; |
| 257 | + uint dstR = (under._packed >> 16) & 0xFF; |
| 258 | + uint dstG = (under._packed >> 8) & 0xFF; |
| 259 | + uint dstB = under._packed & 0xFF; |
| 260 | + |
| 261 | + uint invSa = 255 - srcA; |
| 262 | + uint outR = (srcR * srcA + dstR * dstA * invSa / 255 + outA / 2) / outA; |
| 263 | + uint outG = (srcG * srcA + dstG * dstA * invSa / 255 + outA / 2) / outA; |
| 264 | + uint outB = (srcB * srcA + dstB * dstA * invSa / 255 + outA / 2) / outA; |
| 265 | + |
| 266 | + return new TuiColorArchive((byte)outR, (byte)outG, (byte)outB, (byte)outA); |
| 267 | + } |
| 268 | + |
| 269 | + /// <summary> |
| 270 | + /// Returns this color with the alpha channel replaced by <paramref name="alpha"/>. |
| 271 | + /// </summary> |
| 272 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 273 | + public TuiColorArchive WithAlpha(byte alpha) => new TuiColorArchive(R, G, B, alpha); |
| 274 | + |
| 275 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 276 | + public bool Equals(TuiColorArchive other) => _packed == other._packed; |
| 277 | + public override bool Equals(object? obj) => obj is TuiColorArchive c && Equals(c); |
| 278 | + public override int GetHashCode() => (int)_packed; |
| 279 | + |
| 280 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 281 | + public static bool operator ==(TuiColorArchive a, TuiColorArchive b) => a._packed == b._packed; |
| 282 | + [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| 283 | + public static bool operator !=(TuiColorArchive a, TuiColorArchive b) => a._packed != b._packed; |
| 284 | + |
| 285 | + public override string ToString() => A == 255 |
| 286 | + ? $"#{R:X2}{G:X2}{B:X2}" |
| 287 | + : $"#{R:X2}{G:X2}{B:X2}{A:X2}"; |
| 288 | + |
| 289 | + // 16-color palette (mirrors RgbColorPalette + tuiInterop.colors). |
| 290 | + private static readonly byte[] PaletteR = new byte[16] |
| 291 | + { |
| 292 | + 0x00, 0x00, 0x00, 0x00, 0x8B, 0x8B, 0xBD, 0xC0, |
| 293 | + 0x80, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF |
| 294 | + }; |
| 295 | + private static readonly byte[] PaletteG = new byte[16] |
| 296 | + { |
| 297 | + 0x00, 0x00, 0x64, 0x8B, 0x00, 0x00, 0xB7, 0xC0, |
| 298 | + 0x80, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF |
| 299 | + }; |
| 300 | + private static readonly byte[] PaletteB = new byte[16] |
| 301 | + { |
| 302 | + 0x00, 0x8B, 0x00, 0x8B, 0x00, 0x8B, 0x6B, 0xC0, |
| 303 | + 0x80, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF |
| 304 | + }; |
| 305 | + |
| 306 | + // Named 16-color palette accessors mirroring ConsoleColor for ergonomic parity. |
| 307 | + public static TuiColorArchive Black { get; } = FromConsole(ConsoleColor.Black); |
| 308 | + public static TuiColorArchive DarkBlue { get; } = FromConsole(ConsoleColor.DarkBlue); |
| 309 | + public static TuiColorArchive DarkGreen { get; } = FromConsole(ConsoleColor.DarkGreen); |
| 310 | + public static TuiColorArchive DarkCyan { get; } = FromConsole(ConsoleColor.DarkCyan); |
| 311 | + public static TuiColorArchive DarkRed { get; } = FromConsole(ConsoleColor.DarkRed); |
| 312 | + public static TuiColorArchive DarkMagenta { get; } = FromConsole(ConsoleColor.DarkMagenta); |
| 313 | + public static TuiColorArchive DarkYellow { get; } = FromConsole(ConsoleColor.DarkYellow); |
| 314 | + public static TuiColorArchive Gray { get; } = FromConsole(ConsoleColor.Gray); |
| 315 | + public static TuiColorArchive DarkGray { get; } = FromConsole(ConsoleColor.DarkGray); |
| 316 | + public static TuiColorArchive Blue { get; } = FromConsole(ConsoleColor.Blue); |
| 317 | + public static TuiColorArchive Green { get; } = FromConsole(ConsoleColor.Green); |
| 318 | + public static TuiColorArchive Cyan { get; } = FromConsole(ConsoleColor.Cyan); |
| 319 | + public static TuiColorArchive Red { get; } = FromConsole(ConsoleColor.Red); |
| 320 | + public static TuiColorArchive Magenta { get; } = FromConsole(ConsoleColor.Magenta); |
| 321 | + public static TuiColorArchive Yellow { get; } = FromConsole(ConsoleColor.Yellow); |
| 322 | + public static TuiColorArchive White { get; } = FromConsole(ConsoleColor.White); |
| 323 | + |
| 324 | + /// <summary>Fully transparent (alpha=0). Useful as a "no overlay" sentinel.</summary> |
| 325 | + public static TuiColorArchive Transparent { get; } = new TuiColorArchive(0u); |
| 326 | +} |
0 commit comments