Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713const std = @import("std");const RGB = @import("colors/RGB.zig");const LAB = @import("colors/LAB.zig");const HSV = @import("colors/HSV.zig");const utils = @import("colors/utils.zig");
const t = std.testing;const Writer = std.Io.Writer;const Allocator = std.mem.Allocator;
const Color = @This();
internal: Internal,
const Internal = union(enum) { none, ansi: u4, ansi256: u8, rgb: RGB, lab: LAB, hsv: HSV,};
pub const black: Color = .ansi(0);pub const red: Color = .ansi(1);pub const green: Color = .ansi(2);pub const yellow: Color = .ansi(3);pub const blue: Color = .ansi(4);pub const magenta: Color = .ansi(5);pub const cyan: Color = .ansi(6);pub const white: Color = .ansi(7);pub const bright_black: Color = .ansi(8);pub const bright_red: Color = .ansi(9);pub const bright_green: Color = .ansi(10);pub const bright_yellow: Color = .ansi(11);pub const bright_blue: Color = .ansi(12);pub const bright_magenta: Color = .ansi(13);pub const bright_cyan: Color = .ansi(14);pub const bright_white: Color = .ansi(15);pub const none: Color = .{ .internal = .{ .none = {} } };pub const reset: Color = .none; //what is the code for reset?pub const follows: ?Color = null;
pub fn ansi(code: u4) Color { return .{ .internal = .{ .ansi = code } };}
pub fn ansi256(code: u8) Color { return .{ .internal = .{ .ansi256 = code } };}
pub fn rgb(r: u8, g: u8, b: u8) Color { return .{ .internal = .{ .rgb = .{ .r = r, .g = g, .b = b } } };}
pub fn lab(l: f64, a: f64, b: f64) Color { return .{ .internal = .{ .lab = .{ .l = l, .a = a, .b = b } } };}
pub fn hsv(h: f64, s: f64, v: f64) Color { return .{ .internal = .{ .hsv = .{ .h = h, .s = s, .v = v } } };}
pub fn hex(string: []const u8) ParseError!Color { const s = if (string.len > 0 and string[0] == '#') string[1..] else string; switch (s.len) { 3 => { const r = nibble(s[0]) orelse return error.InvalidHex; const g = nibble(s[1]) orelse return error.InvalidHex; const b = nibble(s[2]) orelse return error.InvalidHex; return .rgb(r * 17, g * 17, b * 17); }, 6 => { const r = byte(s[0..2]) orelse return error.InvalidHex; const g = byte(s[2..4]) orelse return error.InvalidHex; const b = byte(s[4..6]) orelse return error.InvalidHex; return .rgb(r, g, b); }, else => return error.InvalidHex, }}
pub fn convertTo(color: Color, target: enum { ansi, ansi256, rgb, hsv, lab }) Color { if (color.internal == .none) return color; return .{ .internal = switch (target) { .ansi => .{ .ansi = switch (color.internal) { .ansi => |code| code, .ansi256, .rgb, .hsv, .lab => rgbToAnsi(toRgb(color.internal)), .none => unreachable, } }, .ansi256 => .{ .ansi256 = switch (color.internal) { // 0..15 are valid in both encodings, so preserve rather than // round-trip through RGB and risk perturbing the code. .ansi, .ansi256 => |this| this, .rgb, .hsv, .lab => rgbToAnsi256(toRgb(color.internal)), .none => unreachable, }, }, .hsv => .{ .hsv = switch (color.internal) { .ansi, .ansi256 => |code| ansi256ToRgb(code).toHsv(), .hsv => |this| this, .lab => |from| from.toRgb().toHsv(), .rgb => |from| from.toHsv(), .none => unreachable, } }, .lab => .{ .lab = switch (color.internal) { .ansi, .ansi256 => |code| ansi256ToRgb(code).toLab(), .hsv => |from| from.toRgb().toLab(), .lab => |this| this, .rgb => |from| from.toLab(), .none => unreachable, } }, .rgb => .{ .rgb = switch (color.internal) { .ansi, .ansi256 => |code| ansi256ToRgb(code), .hsv => |from| from.toRgb(), .lab => |from| from.toRgb(), .rgb => |this| this, .none => unreachable, } }, }, };}
pub fn getAnsi(color: Color) ?u4 { if (color.internal == .none) return null; return color.convertTo(.ansi).internal.ansi;}
pub fn getAnsi256(color: Color) ?u8 { if (color.internal == .none) return null; return color.convertTo(.ansi256).internal.ansi256;}
pub fn getRgb(color: Color) ?RGB { if (color.internal == .none) return null; return color.convertTo(.rgb).internal.rgb;}
pub fn getLab(color: Color) ?LAB { if (color.internal == .none) return null; return color.convertTo(.lab).internal.lab;}
pub fn getHsv(color: Color) ?HSV { if (color.internal == .none) return null; return color.convertTo(.hsv).internal.hsv;}
/// Resolves any non-`.none` variant to RGB. RGB is the hub every other/// representation converts through, so the lossier palette targets can share/// one entry point.fn toRgb(internal: Internal) RGB { return switch (internal) { .ansi, .ansi256 => |code| ansi256ToRgb(code), .rgb => |this| this, .lab => |from| from.toRgb(), .hsv => |from| from.toRgb(), .none => unreachable, };}
fn rgbDist(a: RGB, b: RGB) f64 { const dr = @as(f64, @floatFromInt(a.r)) - @as(f64, @floatFromInt(b.r)); const dg = @as(f64, @floatFromInt(a.g)) - @as(f64, @floatFromInt(b.g)); const db = @as(f64, @floatFromInt(a.b)) - @as(f64, @floatFromInt(b.b)); return dr * dr + dg * dg + db * db;}
/// The six values each channel of the 6x6x6 xterm cube can take.const cube_levels = [6]u8{ 0, 95, 135, 175, 215, 255 };
fn cubeRgbAt(r: u8, g: u8, b: u8) RGB { return .{ .r = if (r == 0) 0 else 55 + r * 40, .g = if (g == 0) 0 else 55 + g * 40, .b = if (b == 0) 0 else 55 + b * 40, };}
fn nearestCubeLevel(v: u8) u8 { var best: u8 = 0; var best_d = std.math.inf(f64); for (cube_levels, 0..) |level, i| { const d = @as(f64, @floatFromInt(level)) - @as(f64, @floatFromInt(v)); const sq = d * d; if (sq < best_d) { best_d = sq; best = @intCast(i); } } return best;}
/// Downgrades to the nearest xterm-256 code. Exact palette hits are preferred so/// basic colors stay on their low codes instead of drifting into the cube.fn rgbToAnsi256(color: RGB) u8 { for (ansi_palette, 0..) |entry, i| { if (entry.r == color.r and entry.g == color.g and entry.b == color.b) return @intCast(i); } const r = nearestCubeLevel(color.r); const g = nearestCubeLevel(color.g); const b = nearestCubeLevel(color.b); const cube_d = rgbDist(color, cubeRgbAt(r, g, b));
// Nearest entry on the 24-step gray ramp. var gray_i: u8 = 0; var gray_d = std.math.inf(f64); for (0..24) |i| { const level: u8 = @intCast(8 + i * 10); const d = rgbDist(color, .{ .r = level, .g = level, .b = level }); if (d < gray_d) { gray_d = d; gray_i = @intCast(i); } } return if (gray_d < cube_d) 232 + gray_i else 16 + r * 36 + g * 6 + b;}
/// Downgrades to the nearest of the 16 base colors.fn rgbToAnsi(color: RGB) u4 { var best: u4 = 0; var best_d = std.math.inf(f64); for (ansi_palette, 0..) |entry, i| { const d = rgbDist(color, entry); if (d < best_d) { best_d = d; best = @intCast(i); } } return best;}
fn ansi256ToRgb(code: u8) RGB { if (code < 16) return ansi_palette[code]; if (code >= 232) { const gray: u8 = @intCast(8 + @as(u16, code - 232) * 10); return .{ .r = gray, .g = gray, .b = gray }; } const cube = code - 16; const r = cube / 36; const g = (cube / 6) % 6; const b = cube % 6; return .{ .r = if (r == 0) 0 else 55 + r * 40, .g = if (g == 0) 0 else 55 + g * 40, .b = if (b == 0) 0 else 55 + b * 40, };}
pub const ParseError = error{InvalidHex};
/// Returns whether the color is dark, using HSL lightness < 0.5 — the same/// test as lipgloss `isDarkColor`. Colors that cannot be resolved to RGB/// (i.e. `.none`) are treated as dark, matching lipgloss.pub fn isDark(color: Color) bool { const _rgb = color.getRgb() orelse return true; const mx = @max(_rgb.r, @max(_rgb.g, _rgb.b)); const mn = @min(_rgb.r, @min(_rgb.g, _rgb.b)); // HSL lightness = ((max + min) / 2) / 255; l < 0.5 ⇔ max + min < 255. return @as(u16, mx) + mn < 255;}
fn byte(pair: []const u8) ?u8 { const hi = nibble(pair[0]) orelse return null; const lo = nibble(pair[1]) orelse return null; return hi * 16 + lo;}
fn nibble(char: u8) ?u8 { return switch (char) { '0'...'9' => char - '0', 'a'...'f' => char - 'a' + 10, 'A'...'F' => char - 'A' + 10, else => null, };}
const ansi_palette = [16]RGB{ .{ .r = 0, .g = 0, .b = 0 }, // 0 black .{ .r = 170, .g = 0, .b = 0 }, // 1 red .{ .r = 0, .g = 170, .b = 0 }, // 2 green .{ .r = 170, .g = 85, .b = 0 }, // 3 yellow .{ .r = 0, .g = 0, .b = 170 }, // 4 blue .{ .r = 170, .g = 0, .b = 170 }, // 5 magenta .{ .r = 0, .g = 170, .b = 170 }, // 6 cyan .{ .r = 170, .g = 170, .b = 170 }, // 7 white .{ .r = 85, .g = 85, .b = 85 }, // 8 bright black .{ .r = 255, .g = 85, .b = 85 }, // 9 bright red .{ .r = 85, .g = 255, .b = 85 }, // 10 bright green .{ .r = 255, .g = 255, .b = 85 }, // 11 bright yellow .{ .r = 85, .g = 85, .b = 255 }, // 12 bright blue .{ .r = 255, .g = 85, .b = 255 }, // 13 bright magenta .{ .r = 85, .g = 255, .b = 255 }, // 14 bright cyan .{ .r = 255, .g = 255, .b = 255 }, // 15 bright white};
pub fn writeSgr(color: Color, writer: *Writer, target: Target) Writer.Error!void { switch (color.internal) { .none => try writer.print("\x1b[{d}m", .{@intFromEnum(target) + 9}), .ansi => |index| { if (index < 8) { try writer.print("\x1b[{[color]d}m", .{ .color = @as(u8, @intFromEnum(target)) + index, }); } else { try writer.print("\x1b[{[color]d}m", .{ .color = @as(u8, @intFromEnum(target)) + 60 + (index - 8), }); } }, .ansi256 => |index| try writer.print("\x1b[{[target]d};5;{[color]d}m", .{ .target = @intFromEnum(target) + 8, .color = index, }), .rgb => |_rgb| try writer.print("\x1b[{[target]d};2;{[red]d};{[green]d};{[blue]d}m", .{ .target = @intFromEnum(target) + 8, .red = _rgb.r, .green = _rgb.g, .blue = _rgb.b, }), .hsv => return color.convertTo(.rgb).writeSgr(writer, target), .lab => return color.convertTo(.rgb).writeSgr(writer, target), }}
/// Writes the SGR sequence setting this color as the underline color/// (SGR 58, colon-delimited form). `.none` resets to the default/// underline color (SGR 59).pub fn writeUnderlineSgr(color: Color, writer: *Writer) Writer.Error!void { switch (color.internal) { .none => try writer.writeAll("\x1b[59m"), .ansi, .ansi256 => |code| try writer.print("\x1b[58:5:{d}m", .{code}), .rgb => |_rgb| try writer.print("\x1b[58:2::{[red]d}:{[green]d}:{[blue]d}m", .{ .red = _rgb.r, .green = _rgb.g, .blue = _rgb.b, }), .hsv, .lab => try color.convertTo(.rgb).writeUnderlineSgr(writer), }}
const Target = enum(u8) { fg = 30, bg = 40,};
/// Darkens the color by `percent` (0–1, clamped). Mirrors lipgloss/// `Darken`. The result is always an `.rgb` color (or `.none`).pub fn darken(color: Color, percent: f64) Color { const _rgb = color.getRgb() orelse return .none; const mult = 1.0 - std.math.clamp(percent, 0.0, 1.0); return .rgb( utils.byteTrunc(_rgb.r * mult), utils.byteTrunc(_rgb.g * mult), utils.byteTrunc(_rgb.b * mult), );}
/// Lightens the color by `percent` (0–1, clamped). Mirrors lipgloss/// `Lighten`. The result is always an `.rgb` color (or `.none`).pub fn lighten(color: Color, percent: f64) Color { const _rgb = color.getRgb() orelse return .none; const add = 255.0 * std.math.clamp(percent, 0.0, 1.0); return .rgb( utils.byteTrunc(_rgb.r + add), utils.byteTrunc(_rgb.g + add), utils.byteTrunc(_rgb.b + add), );}
/// Returns the complementary color — 180° away on the HSV color wheel./// Mirrors lipgloss `Complementary`.pub fn complementary(color: Color) Color { const active = std.meta.activeTag(color.internal); _ = active; var _hsv = color.getHsv() orelse return .none; _hsv.h += 180.0; if (_hsv.h >= 360.0) _hsv.h -= 360.0; // TODO: convert back to active union return .{ .internal = .{ .hsv = _hsv } };}
/// Returns the color at position `idx` of a 1D blend of `stops` over/// `steps` positions. Interpolates in the CIELAB color space, matching/// lipgloss `Blend1D`.////// - 0 stops or 0 steps → `.none`/// - 1 stop → that stop/// - 2+ stops → segments are sized `steps/(stops.len-1)` with remainders/// distributed to the first segmentspub fn blendAt(stops: []const Color, steps: usize, idx: usize) Color { if (stops.len == 0 or steps == 0) return .none; if (stops.len == 1) return stops[0];
const num_segments = stops.len - 1; const default_size = steps / num_segments; const remaining = steps % num_segments;
var segment_start: usize = 0; var seg_idx: usize = 0; while (seg_idx < num_segments) : (seg_idx += 1) { const size = default_size + @as(usize, if (seg_idx < remaining) 1 else 0); if (idx < segment_start + size) { const from = stops[seg_idx].getRgb() orelse return .none; const to = stops[seg_idx + 1].getRgb() orelse return .none; const local = idx - segment_start; const t_: f64 = if (size > 1) @as(f64, @floatFromInt(local)) / @as(f64, @floatFromInt(size - 1)) else 0; return lerp(from, to, t_); } segment_start += size; } return stops[stops.len - 1];}
/// Blends `stops` into a `steps`-long gradient in the CIELAB color space./// Mirrors lipgloss `Blend1D`. The caller owns the returned slice.pub fn blend1D(allocator: Allocator, steps: usize, stops: []const Color) Allocator.Error![]Color { const out = try allocator.alloc(Color, steps); for (out, 0..) |*c, i| c.* = blendAt(stops, steps, i); return out;}
/// Blends `stops` into a `width`×`height` gradient, returned row-major./// `angle_deg` rotates the gradient (0° = left-to-right). Mirrors lipgloss/// `Blend2D`. The caller owns the returned slice; with no stops it is/// filled with `.none`.pub fn blend2D( allocator: Allocator, width: usize, height: usize, angle_deg: f64, stops: []const Color,) Allocator.Error![]Color { const w = @max(width, 1); const h = @max(height, 1);
var angle = @mod(angle_deg, 360.0); if (angle < 0) angle += 360.0;
const out = try allocator.alloc(Color, w * h); if (stops.len == 0) { @memset(out, .none); return out; } if (stops.len == 1) { @memset(out, stops[0]); return out; }
const diagonal = try blend1D(allocator, @max(w, h), stops); defer allocator.free(diagonal);
const cx = @as(f64, @floatFromInt(w - 1)) / 2.0; const cy = @as(f64, @floatFromInt(h - 1)) / 2.0; const rad = angle * std.math.pi / 180.0; const cos_a = @cos(rad); const sin_a = @sin(rad); const diag_len = @sqrt(@as(f64, @floatFromInt(w * w + h * h))); const grad_len = @as(f64, @floatFromInt(diagonal.len - 1));
var y: usize = 0; while (y < h) : (y += 1) { const dy = @as(f64, @floatFromInt(y)) - cy; var x: usize = 0; while (x < w) : (x += 1) { const dx = @as(f64, @floatFromInt(x)) - cx; const rot_x = dx * cos_a - dy * sin_a; const pos = std.math.clamp((rot_x + diag_len / 2.0) / diag_len, 0.0, 1.0); var idx: usize = @intFromFloat(pos * grad_len); if (idx >= diagonal.len) idx = diagonal.len - 1; out[y * w + x] = diagonal[idx]; } } return out;}
/// Chooses between a light-background and dark-background color. Mirrors/// lipgloss `LightDark`: build one with [lightDark], then call `pick`.pub const LightDark = struct { is_dark: bool,
pub fn pick(self: LightDark, light: Color, dark: Color) Color { return if (self.is_dark) dark else light; }};
/// Returns a [LightDark] chooser for a terminal with the given background.pub fn lightDark(is_dark: bool) LightDark { return .{ .is_dark = is_dark };}
fn lerp(a: RGB, b: RGB, t_: f64) Color { const la = a.toLab(); const lb = b.toLab(); return .lab( la.l + (lb.l - la.l) * t_, la.a + (lb.a - la.a) * t_, la.b + (lb.b - la.b) * t_, );}
test "parseHex 6-char" { const color = try hex("#FF8800"); const _rgb = color.getRgb().?; try t.expectEqual(0xff, _rgb.r); try t.expectEqual(0x88, _rgb.g); try t.expectEqual(0x00, _rgb.b);}
test "parseHex 3-char" { const color = try hex("#f80"); const _rgb = color.getRgb().?; try t.expectEqual(0xff, _rgb.r); try t.expectEqual(0x88, _rgb.g); try t.expectEqual(0x00, _rgb.b);}
test "parseHex without #" { const color = try hex("aabbcc"); try t.expectEqual( rgb(0xaa, 0xbb, 0xcc).getRgb().?, color.getRgb().?, );}
test "parseHex invalid" { try t.expectError(error.InvalidHex, hex("zzz")); try t.expectError(error.InvalidHex, hex("#1234"));}
test "writeSgr ansi" { var buf: [32]u8 = undefined; var writer: Writer = .fixed(&buf); try red.writeSgr(&writer, .fg); try t.expectEqualStrings("\x1b[31m", writer.buffered());}
test "writeSgr ansi bright bg" { var buf: [32]u8 = undefined; var writer: Writer = .fixed(&buf); try bright_blue.writeSgr(&writer, .bg); try t.expectEqualStrings("\x1b[104m", writer.buffered());}
test "writeSgr rgb" { var buf: [32]u8 = undefined; var writer: Writer = .fixed(&buf); const color = rgb(255, 128, 0); try color.writeSgr(&writer, .fg); try t.expectEqualStrings("\x1b[38;2;255;128;0m", writer.buffered());}
test "isDark" { try t.expect(rgb(0, 0, 0).isDark()); try t.expect(!rgb(255, 255, 255).isDark()); try t.expect(!rgb(200, 200, 200).isDark()); try t.expect(none.isDark());}
test "darken" { const color = rgb(200, 100, 50).darken(0.5).getRgb().?; try t.expectEqual(rgb(100, 50, 25).getRgb().?, color);}
test "lighten" { const color = rgb(0, 100, 200).lighten(0.2).getRgb().?; // add = 255 * 0.2 = 51. try t.expectEqual(rgb(51, 151, 251).getRgb().?, color);}
test "complementary of red is cyan" { const color = rgb(255, 0, 0).complementary().getRgb().?; try t.expectEqual(rgb(0, 255, 255).getRgb().?, color);}
test "lightDark picks by background" { try t.expectEqual(blue, lightDark(true).pick(.red, .blue)); try t.expectEqual(red, lightDark(false).pick(.red, .blue));}
test "blendAt endpoints" { const stops = [_]Color{ .rgb(0, 0, 0), .rgb(255, 255, 255), }; const first = blendAt(&stops, 5, 0).getRgb().?; try t.expectEqual(0, first.r); const last = blendAt(&stops, 5, 4).getRgb().?; try t.expectEqual(255, last.r);}
test "blendAt midpoint is a perceptual mid-gray" { const stops = [_]Color{ .rgb(0, 0, 0), .rgb(200, 200, 200), }; const mid = blendAt(&stops, 5, 2); // CIELAB midpoint of a black→gray ramp stays neutral and mid-range. const _rgb = mid.getRgb().?; try t.expect(_rgb.r == _rgb.g and _rgb.g == _rgb.b); try t.expect(_rgb.r > 60 and _rgb.r < 140);}
test "blendAt single stop" { const stops = [_]Color{.red}; try t.expectEqual(red, blendAt(&stops, 10, 3));}
test "blendAt empty stops" { const stops: []const Color = &.{}; try t.expectEqual(none, blendAt(stops, 10, 0));}
test "blend1D" { const stops = [_]Color{ .rgb(0, 0, 0), .rgb(255, 255, 255), }; const grad = try blend1D(t.allocator, 3, &stops); defer t.allocator.free(grad); try t.expectEqual(3, grad.len); try t.expectEqual(0, grad[0].getRgb().?.r); try t.expectEqual(255, grad[2].getRgb().?.r); try t.expect(grad[1].getRgb().?.r > 0 and grad[1].getRgb().?.r < 255);}
test "blend2D" { const stops = [_]Color{ .rgb(0, 0, 0), .rgb(255, 255, 255), }; const grid = try blend2D(t.allocator, 3, 2, 0, &stops); defer t.allocator.free(grid); try t.expectEqual(6, grid.len); // 0° gradient runs left-to-right: left edge darkehan right edge. try t.expect(grid[0].getRgb().?.r < grid[2].getRgb().?.r);}
test "convertTo ansi256 round-trips every xterm-256 code" { var code: u16 = 0; while (code < 256) : (code += 1) { const c = Color.ansi256(@intCast(code)); // Self-target is always lossless. try t.expectEqual(@as(u8, @intCast(code)), c.convertTo(.ansi256).internal.ansi256);
// Going via RGB must decode back to the same color. The code itself may // differ where two codes are the same color: cube 16 is black, so it // collapses onto the palette's black at 0. const back = c.convertTo(.rgb).convertTo(.ansi256).internal.ansi256; const want = ansi256ToRgb(@intCast(code)); const got = ansi256ToRgb(back); try t.expectEqual(want.r, got.r); try t.expectEqual(want.g, got.g); try t.expectEqual(want.b, got.b);
// And the result must be stable under a second round-trip. try t.expectEqual(back, Color.rgb(got.r, got.g, got.b).convertTo(.ansi256).internal.ansi256); }}
test "convertTo ansi round-trips the base 16" { var code: u16 = 0; while (code < 16) : (code += 1) { const c = Color.ansi(@intCast(code)); try t.expectEqual(@as(u4, @intCast(code)), c.convertTo(.ansi).internal.ansi); }}
test "cube quantization lands on exact cube codes" { try t.expectEqual(@as(u8, 196), Color.rgb(255, 0, 0).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 21), Color.rgb(0, 0, 255).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 46), Color.rgb(0, 255, 0).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 226), Color.rgb(255, 255, 0).convertTo(.ansi256).internal.ansi256);}
test "gray ramp wins for achromatic colors" { try t.expectEqual(@as(u8, 0), Color.rgb(0, 0, 0).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 15), Color.rgb(255, 255, 255).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 244), Color.rgb(128, 128, 128).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 232), Color.rgb(8, 8, 8).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 255), Color.rgb(238, 238, 238).convertTo(.ansi256).internal.ansi256);}
test "hsv and lab route through rgb to the palette" { try t.expectEqual(@as(u8, 196), Color.hsv(0, 1, 1).convertTo(.ansi256).internal.ansi256); try t.expectEqual(@as(u8, 46), Color.hsv(120, 1, 1).convertTo(.ansi256).internal.ansi256); // sRGB red in Lab; nearest base entry is red(1), not bright red(9), // because this palette's red is 170 rather than 205. try t.expectEqual(@as(u4, 1), Color.lab(53, 80, 67).convertTo(.ansi).internal.ansi); try t.expectEqual(@as(u8, 196), Color.lab(53, 80, 67).convertTo(.ansi256).internal.ansi256);}
test "ansi self-targets never round-trip through rgb" { try t.expect(Color.ansi(4).convertTo(.ansi256).internal.ansi256 == 4); try t.expect(Color.ansi256(208).convertTo(.ansi256).internal.ansi256 == 208);}
test "none passes through every target" { try t.expect(Color.follows == null); inline for (.{ Color.none, Color.reset }) |c| { try t.expect(c.internal == .none); try t.expect(c.convertTo(.rgb).internal == .none); try t.expect(c.convertTo(.ansi256).internal == .none); try t.expect(c.convertTo(.hsv).internal == .none); try t.expect(c.convertTo(.lab).internal == .none); }}