Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583const std = @import("std");const Writer = std.Io.Writer;const Allocator = std.mem.Allocator;
pub const Color = union(enum) { none, ansi: u4, ansi256: u8, rgb: RGB,
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 RGB = struct { r: u8, g: u8, b: u8, };
pub const ParseError = error{InvalidHex};
pub fn parseHex(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 = r * 17, .g = g * 17, .b = 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 = r, .g = g, .b = b } }; }, else => return 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.toRgb() 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; }
pub fn toRgb(color: Color) ?RGB { return switch (color) { .none => null, .rgb => |rgb| rgb, .ansi => |index| ansi_palette[index], .ansi256 => |index| ansi256ToRgb(index), }; }
pub fn writeSgr(color: Color, writer: *Writer, target: Target) Writer.Error!void { switch (color) { .none => try writer.print("\x1b[{d}m", .{@backingInt(target) + 9}), .ansi => |index| { if (index < 8) { try writer.print("\x1b[{d}m", .{@as(u8, @backingInt(target)) + index}); } else { try writer.print("\x1b[{d}m", .{@as(u8, @backingInt(target)) + 60 + (index - 8)}); } }, .ansi256 => |index| try writer.print("\x1b[{d};5;{d}m", .{ @backingInt(target) + 8, index }), .rgb => |rgb| try writer.print("\x1b[{d};2;{d};{d};{d}m", .{ @backingInt(target) + 8, rgb.r, rgb.g, rgb.b }), } }
/// 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) { .none => try writer.writeAll("\x1b[59m"), .ansi => |index| try writer.print("\x1b[58:5:{d}m", .{index}), .ansi256 => |index| try writer.print("\x1b[58:5:{d}m", .{index}), .rgb => |rgb| try writer.print("\x1b[58:2::{d}:{d}:{d}m", .{ rgb.r, rgb.g, rgb.b }), } }
pub 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.toRgb() orelse return .none; const mult = 1.0 - std.math.clamp(percent, 0.0, 1.0); return .{ .rgb = .{ .r = byteTrunc(@as(f64, @floatFromInt(rgb.r)) * mult), .g = byteTrunc(@as(f64, @floatFromInt(rgb.g)) * mult), .b = byteTrunc(@as(f64, @floatFromInt(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.toRgb() orelse return .none; const add = 255.0 * std.math.clamp(percent, 0.0, 1.0); return .{ .rgb = .{ .r = byteTrunc(@as(f64, @floatFromInt(rgb.r)) + add), .g = byteTrunc(@as(f64, @floatFromInt(rgb.g)) + add), .b = byteTrunc(@as(f64, @floatFromInt(rgb.b)) + add), } }; }
/// Returns the complementary color — 180° away on the HSV color wheel. /// Mirrors lipgloss `Complementary`. pub fn complementary(color: Color) Color { const rgb = color.toRgb() orelse return .none; var hsv = rgbToHsv(rgb); hsv.h += 180.0; if (hsv.h >= 360.0) hsv.h -= 360.0; return .{ .rgb = hsvToRgb(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 segments pub 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].toRgb() orelse return .none; const to = stops[seg_idx + 1].toRgb() 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 };}
// --- CIELAB blending internals ---
const Lab = struct { l: f64, a: f64, b: f64 };
// D65 reference white.const xn = 0.95047;const yn = 1.0;const zn = 1.08883;
fn srgbToLinear(c: u8) f64 { const v = @as(f64, @floatFromInt(c)) / 255.0; return if (v <= 0.04045) v / 12.92 else std.math.pow(f64, (v + 0.055) / 1.055, 2.4);}
fn linearToByte(v: f64) u8 { const c = std.math.clamp(v, 0.0, 1.0); const s = if (c <= 0.0031308) c * 12.92 else 1.055 * std.math.pow(f64, c, 1.0 / 2.4) - 0.055; return byteRound(std.math.clamp(s, 0.0, 1.0) * 255.0);}
fn labF(t: f64) f64 { const d = 6.0 / 29.0; return if (t > d * d * d) std.math.pow(f64, t, 1.0 / 3.0) else t / (3.0 * d * d) + 4.0 / 29.0;}
fn labFInv(t: f64) f64 { const d = 6.0 / 29.0; return if (t > d) t * t * t else 3.0 * d * d * (t - 4.0 / 29.0);}
fn rgbToLab(c: Color.RGB) Lab { const r = srgbToLinear(c.r); const g = srgbToLinear(c.g); const b = srgbToLinear(c.b); const x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b; const y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b; const z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b; const fx = labF(x / xn); const fy = labF(y / yn); const fz = labF(z / zn); return .{ .l = 116.0 * fy - 16.0, .a = 500.0 * (fx - fy), .b = 200.0 * (fy - fz), };}
fn labToRgb(lab: Lab) Color.RGB { const fy = (lab.l + 16.0) / 116.0; const fx = fy + lab.a / 500.0; const fz = fy - lab.b / 200.0; const x = xn * labFInv(fx); const y = yn * labFInv(fy); const z = zn * labFInv(fz); const r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z; const g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z; const b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z; return .{ .r = linearToByte(r), .g = linearToByte(g), .b = linearToByte(b) };}
fn lerp(a: Color.RGB, b: Color.RGB, t: f64) Color { const la = rgbToLab(a); const lb = rgbToLab(b); return .{ .rgb = labToRgb(.{ .l = la.l + (lb.l - la.l) * t, .a = la.a + (lb.a - la.a) * t, .b = la.b + (lb.b - la.b) * t, }) };}
// --- HSV (for complementary) ---
const Hsv = struct { h: f64, s: f64, v: f64 };
fn rgbToHsv(c: Color.RGB) Hsv { const r = @as(f64, @floatFromInt(c.r)) / 255.0; const g = @as(f64, @floatFromInt(c.g)) / 255.0; const b = @as(f64, @floatFromInt(c.b)) / 255.0; const mx = @max(r, @max(g, b)); const mn = @min(r, @min(g, b)); const d = mx - mn;
var h: f64 = 0; if (d != 0) { if (mx == r) { h = 60.0 * @mod((g - b) / d, 6.0); } else if (mx == g) { h = 60.0 * ((b - r) / d + 2.0); } else { h = 60.0 * ((r - g) / d + 4.0); } } if (h < 0) h += 360.0;
return .{ .h = h, .s = if (mx == 0) 0 else d / mx, .v = mx, };}
fn hsvToRgb(hsv: Hsv) Color.RGB { const c = hsv.v * hsv.s; const hp = hsv.h / 60.0; const x = c * (1.0 - @abs(@mod(hp, 2.0) - 1.0)); const m = hsv.v - c;
var r: f64 = 0; var g: f64 = 0; var b: f64 = 0; if (hp < 1.0) { r = c; g = x; } else if (hp < 2.0) { r = x; g = c; } else if (hp < 3.0) { g = c; b = x; } else if (hp < 4.0) { g = x; b = c; } else if (hp < 5.0) { r = x; b = c; } else { r = c; b = x; }
return .{ .r = byteRound((r + m) * 255.0), .g = byteRound((g + m) * 255.0), .b = byteRound((b + m) * 255.0), };}
fn byteRound(f: f64) u8 { return @intFromFloat(@round(std.math.clamp(f, 0.0, 255.0)));}
fn byteTrunc(f: f64) u8 { return @intFromFloat(std.math.clamp(f, 0.0, 255.0));}
fn nibble(char: u8) ?u8 { return switch (char) { '0'...'9' => char - '0', 'a'...'f' => char - 'a' + 10, 'A'...'F' => char - 'A' + 10, else => null, };}
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;}
const ansi_palette = [16]Color.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};
fn ansi256ToRgb(index: u8) Color.RGB { if (index < 16) return ansi_palette[index]; if (index >= 232) { const gray: u8 = @intCast(8 + @as(u16, index - 232) * 10); return .{ .r = gray, .g = gray, .b = gray }; } const cube = index - 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, };}
test "parseHex 6-char" { const color: Color = try .parseHex("#FF8800"); try std.testing.expectEqual(@as(u8, 0xff), color.rgb.r); try std.testing.expectEqual(@as(u8, 0x88), color.rgb.g); try std.testing.expectEqual(@as(u8, 0x00), color.rgb.b);}
test "parseHex 3-char" { const color: Color = try .parseHex("#f80"); try std.testing.expectEqual(@as(u8, 0xff), color.rgb.r); try std.testing.expectEqual(@as(u8, 0x88), color.rgb.g); try std.testing.expectEqual(@as(u8, 0x00), color.rgb.b);}
test "parseHex without #" { const color: Color = try .parseHex("aabbcc"); try std.testing.expectEqual( Color.RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, color.rgb, );}
test "parseHex invalid" { try std.testing.expectError(error.InvalidHex, Color.parseHex("zzz")); try std.testing.expectError(error.InvalidHex, Color.parseHex("#1234"));}
test "writeSgr ansi" { var buf: [32]u8 = undefined; var writer: Writer = .fixed(&buf); try Color.red.writeSgr(&writer, .fg); try std.testing.expectEqualStrings("\x1b[31m", writer.buffered());}
test "writeSgr ansi bright bg" { var buf: [32]u8 = undefined; var writer: Writer = .fixed(&buf); try Color.bright_blue.writeSgr(&writer, .bg); try std.testing.expectEqualStrings("\x1b[104m", writer.buffered());}
test "writeSgr rgb" { var buf: [32]u8 = undefined; var writer: Writer = .fixed(&buf); const color: Color = .{ .rgb = .{ .r = 255, .g = 128, .b = 0 } }; try color.writeSgr(&writer, .fg); try std.testing.expectEqualStrings("\x1b[38;2;255;128;0m", writer.buffered());}
test "isDark" { try std.testing.expect((Color{ .rgb = .{ .r = 0, .g = 0, .b = 0 } }).isDark()); try std.testing.expect(!(Color{ .rgb = .{ .r = 255, .g = 255, .b = 255 } }).isDark()); try std.testing.expect(!(Color{ .rgb = .{ .r = 200, .g = 200, .b = 200 } }).isDark()); try std.testing.expect(@as(Color, .none).isDark());}
test "darken" { const c = (Color{ .rgb = .{ .r = 200, .g = 100, .b = 50 } }).darken(0.5); try std.testing.expectEqual(Color.RGB{ .r = 100, .g = 50, .b = 25 }, c.rgb);}
test "lighten" { const c = (Color{ .rgb = .{ .r = 0, .g = 100, .b = 200 } }).lighten(0.2); // add = 255 * 0.2 = 51. try std.testing.expectEqual(Color.RGB{ .r = 51, .g = 151, .b = 251 }, c.rgb);}
test "complementary of red is cyan" { const c = (Color{ .rgb = .{ .r = 255, .g = 0, .b = 0 } }).complementary(); try std.testing.expectEqual(Color.RGB{ .r = 0, .g = 255, .b = 255 }, c.rgb);}
test "lightDark picks by background" { try std.testing.expectEqual(Color.blue, lightDark(true).pick(.red, .blue)); try std.testing.expectEqual(Color.red, lightDark(false).pick(.red, .blue));}
test "blendAt endpoints" { const stops = [_]Color{ .{ .rgb = .{ .r = 0, .g = 0, .b = 0 } }, .{ .rgb = .{ .r = 255, .g = 255, .b = 255 } }, }; const first = Color.blendAt(&stops, 5, 0); try std.testing.expectEqual(@as(u8, 0), first.rgb.r); const last = Color.blendAt(&stops, 5, 4); try std.testing.expectEqual(@as(u8, 255), last.rgb.r);}
test "blendAt midpoint is a perceptual mid-gray" { const stops = [_]Color{ .{ .rgb = .{ .r = 0, .g = 0, .b = 0 } }, .{ .rgb = .{ .r = 200, .g = 200, .b = 200 } }, }; const mid = Color.blendAt(&stops, 5, 2); // CIELAB midpoint of a black→gray ramp stays neutral and mid-range. try std.testing.expect(mid.rgb.r == mid.rgb.g and mid.rgb.g == mid.rgb.b); try std.testing.expect(mid.rgb.r > 60 and mid.rgb.r < 140);}
test "blendAt single stop" { const stops = [_]Color{Color.red}; try std.testing.expectEqual(Color.red, Color.blendAt(&stops, 10, 3));}
test "blendAt empty stops" { const stops: []const Color = &.{}; try std.testing.expectEqual(Color.none, Color.blendAt(stops, 10, 0));}
test "blend1D" { const stops = [_]Color{ .{ .rgb = .{ .r = 0, .g = 0, .b = 0 } }, .{ .rgb = .{ .r = 255, .g = 255, .b = 255 } }, }; const grad = try Color.blend1D(std.testing.allocator, 3, &stops); defer std.testing.allocator.free(grad); try std.testing.expectEqual(@as(usize, 3), grad.len); try std.testing.expectEqual(@as(u8, 0), grad[0].rgb.r); try std.testing.expectEqual(@as(u8, 255), grad[2].rgb.r); try std.testing.expect(grad[1].rgb.r > 0 and grad[1].rgb.r < 255);}
test "blend2D" { const stops = [_]Color{ .{ .rgb = .{ .r = 0, .g = 0, .b = 0 } }, .{ .rgb = .{ .r = 255, .g = 255, .b = 255 } }, }; const grid = try Color.blend2D(std.testing.allocator, 3, 2, 0, &stops); defer std.testing.allocator.free(grid); try std.testing.expectEqual(@as(usize, 6), grid.len); // 0° gradient runs left-to-right: left edge darker than right edge. try std.testing.expect(grid[0].rgb.r < grid[2].rgb.r);}