lib-vt: many more color utility APIs (#13206)

Embedders that render theme editors, palette pickers, or custom settings
UI need to use the same color semantics as Ghostty.

This moves the shared parsing paths into terminal/color and exposes them
through libghostty-vt. Config color and palette parsing now delegate to
the same helpers, so CLI/config behavior and the C ABI stay in lockstep.

From C:

    GhosttyColorRgb rgb;
    ghostty_color_parse("ForestGreen", 11, &rgb);

    uint8_t index;
    ghostty_color_parse_palette_entry(
        "0x10=#282c34", 12, &index, &rgb);

    const GhosttyColorX11Entry* names =
        ghostty_color_x11_names();

The exported color API is:

    ghostty_color_parse
    ghostty_color_parse_x11
    ghostty_color_parse_palette_entry
    ghostty_color_palette_default
    ghostty_color_palette_generate
    ghostty_color_luminance
    ghostty_color_perceived_luminance
    ghostty_color_contrast
    ghostty_color_x11_names
    ghostty_color_x11_name_count

The X11 name table is parsed once at comptime into null-terminated
entries in rgb.txt order. The existing case-insensitive map keeps the
same behavior for RGB.parse and +list-colors, while bindings can walk a
static table without allocations.

This doesn't add any more binary size since all of this was already used
by terminal internals.
This commit is contained in:
Mitchell Hashimoto
2026-07-05 13:15:17 -07:00
committed by GitHub
9 changed files with 1189 additions and 131 deletions

View File

@@ -51,7 +51,6 @@ const terminal = struct {
const color = @import("../terminal/color.zig");
const selection_codepoints = @import("../terminal/selection_codepoints.zig");
const style = @import("../terminal/style.zig");
const x11_color = @import("../terminal/x11_color.zig");
};
const log = std.log.scoped(.config);
@@ -5441,16 +5440,8 @@ pub const Color = struct {
pub fn parseCLI(input_: ?[]const u8) !Color {
const input = input_ orelse return error.ValueRequired;
// Trim any whitespace before processing
const trimmed = std.mem.trim(u8, input, " \t");
if (terminal.x11_color.map.get(trimmed)) |rgb| return .{
.r = rgb.r,
.g = rgb.g,
.b = rgb.b,
};
return fromHex(trimmed);
const rgb: terminal.color.RGB = terminal.color.RGB.parse(input) catch return error.InvalidValue;
return .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
}
/// Deep copy of the struct. Required by Config.
@@ -5481,48 +5472,15 @@ pub const Color = struct {
) catch error.OutOfMemory;
}
/// fromHex parses a color from a hex value such as #RRGGBB. The "#"
/// is optional.
pub fn fromHex(input: []const u8) !Color {
// Trim the beginning '#' if it exists
const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input;
if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue;
// Expand short hex values to full hex values
const rgb: []const u8 = if (trimmed.len == 3) &.{
trimmed[0], trimmed[0],
trimmed[1], trimmed[1],
trimmed[2], trimmed[2],
} else trimmed;
// Parse the colors two at a time.
var result: Color = undefined;
comptime var i: usize = 0;
inline while (i < 6) : (i += 2) {
const v: u8 =
((try std.fmt.charToDigit(rgb[i], 16)) * 16) +
try std.fmt.charToDigit(rgb[i + 1], 16);
@field(result, switch (i) {
0 => "r",
2 => "g",
4 => "b",
else => unreachable,
}) = v;
}
return result;
}
test "fromHex" {
test "parseCLI hex" {
const testing = std.testing;
try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.fromHex("#000000"));
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C"));
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C"));
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF"));
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF"));
try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345"));
try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.parseCLI("#000000"));
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.parseCLI("#0A0B0C"));
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.parseCLI("0A0B0C"));
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.parseCLI("FFFFFF"));
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.parseCLI("FFF"));
try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.parseCLI("#345"));
}
test "parseCLI from name" {
@@ -5868,20 +5826,12 @@ pub const Palette = struct {
input: ?[]const u8,
) !void {
const value = input orelse return error.ValueRequired;
const eqlIdx = std.mem.indexOf(u8, value, "=") orelse
return error.InvalidValue;
// Parse the key part (trim whitespace)
const key = try std.fmt.parseInt(
u8,
std.mem.trim(u8, value[0..eqlIdx], " \t"),
0,
);
// Parse the color part (Color.parseCLI will handle whitespace)
const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
self.mask.set(key);
const entry = terminal.color.parsePaletteEntry(value) catch |err| switch (err) {
error.Overflow => return error.Overflow,
error.InvalidFormat => return error.InvalidValue,
};
self.value[entry.index] = entry.color;
self.mask.set(entry.index);
}
/// Deep copy of the struct. Required by Config.

View File

@@ -208,6 +208,16 @@ comptime {
@export(&c.row_get, .{ .name = "ghostty_row_get" });
@export(&c.row_get_multi, .{ .name = "ghostty_row_get_multi" });
@export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" });
@export(&c.color_contrast, .{ .name = "ghostty_color_contrast" });
@export(&c.color_luminance, .{ .name = "ghostty_color_luminance" });
@export(&c.color_parse, .{ .name = "ghostty_color_parse" });
@export(&c.color_parse_palette_entry, .{ .name = "ghostty_color_parse_palette_entry" });
@export(&c.color_parse_x11, .{ .name = "ghostty_color_parse_x11" });
@export(&c.color_palette_default, .{ .name = "ghostty_color_palette_default" });
@export(&c.color_palette_generate, .{ .name = "ghostty_color_palette_generate" });
@export(&c.color_perceived_luminance, .{ .name = "ghostty_color_perceived_luminance" });
@export(&c.color_x11_name_count, .{ .name = "ghostty_color_x11_name_count" });
@export(&c.color_x11_names, .{ .name = "ghostty_color_x11_names" });
@export(&c.sgr_new, .{ .name = "ghostty_sgr_new" });
@export(&c.sgr_free, .{ .name = "ghostty_sgr_free" });
@export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" });

View File

@@ -1,5 +1,43 @@
const std = @import("std");
const lib = @import("../lib.zig");
const color = @import("../color.zig");
const x11_color = @import("../x11_color.zig");
const Result = @import("result.zig").Result;
/// C: GhosttyColorPaletteMask
pub const PaletteMask = extern struct {
bits: [4]u64,
/// Convert to the Zig PaletteMask (std.StaticBitSet(256)).
pub fn toZig(self: *const PaletteMask) color.PaletteMask {
var result = color.PaletteMask.initEmpty();
for (0..256) |i| {
if (((self.bits[i >> 6] >> @as(u6, @intCast(i & 63))) & 1) != 0) {
result.set(i);
}
}
return result;
}
};
/// C: GhosttyColorX11Entry
pub const X11Entry = extern struct {
/// Null-terminated color name; null marks the end of the table.
name: ?[*:0]const u8,
color: color.RGB.C,
};
/// Comptime-built static table, terminated by a null-name entry.
const x11_entries: [x11_color.entries.len + 1]X11Entry = entries: {
@setEvalBranchQuota(10_000);
var result: [x11_color.entries.len + 1]X11Entry = undefined;
for (x11_color.entries, 0..) |entry, i| result[i] = .{
.name = entry.name.ptr,
.color = entry.color.cval(),
};
result[x11_color.entries.len] = .{ .name = null, .color = .{ .r = 0, .g = 0, .b = 0 } };
break :entries result;
};
pub fn rgb_get(
c: color.RGB.C,
@@ -11,3 +49,524 @@ pub fn rgb_get(
g.* = c.g;
b.* = c.b;
}
pub fn parse_x11(
name_: ?[*]const u8,
len: usize,
out: *color.RGB.C,
) callconv(lib.calling_conv) Result {
const name = (name_ orelse return .invalid_value)[0..len];
const trimmed = std.mem.trim(u8, name, " \t");
const rgb = x11_color.map.get(trimmed) orelse return .invalid_value;
out.* = rgb.cval();
return .success;
}
pub fn parse(
value_: ?[*]const u8,
len: usize,
out: *color.RGB.C,
) callconv(lib.calling_conv) Result {
const value = (value_ orelse return .invalid_value)[0..len];
const rgb = color.RGB.parse(value) catch return .invalid_value;
out.* = rgb.cval();
return .success;
}
pub fn palette_default(out: *color.PaletteC) callconv(lib.calling_conv) void {
out.* = color.paletteCval(&color.default);
}
/// Generate a 256-color palette. The output may alias the base input.
pub fn palette_generate(
base_: ?*const color.PaletteC,
skip_: ?*const PaletteMask,
bg: color.RGB.C,
fg: color.RGB.C,
harmonious: bool,
out: *color.PaletteC,
) callconv(lib.calling_conv) void {
const base: color.Palette = if (base_) |base|
color.paletteZval(base)
else
color.default;
const skip: color.PaletteMask = if (skip_) |skip|
skip.toZig()
else
.initEmpty();
const result = color.generate256Color(
base,
skip,
.fromC(bg),
.fromC(fg),
harmonious,
);
out.* = color.paletteCval(&result);
}
pub fn x11_names() callconv(lib.calling_conv) [*]const X11Entry {
return x11_entries[0..].ptr;
}
pub fn x11_name_count() callconv(lib.calling_conv) usize {
return x11_color.entries.len;
}
pub fn parse_palette_entry(
value_: ?[*]const u8,
len: usize,
out_index: *u8,
out_rgb: *color.RGB.C,
) callconv(lib.calling_conv) Result {
const value = (value_ orelse return .invalid_value)[0..len];
const entry = color.parsePaletteEntry(value) catch return .invalid_value;
out_index.* = entry.index;
out_rgb.* = entry.color.cval();
return .success;
}
pub fn luminance(c: color.RGB.C) callconv(lib.calling_conv) f64 {
return color.RGB.fromC(c).luminance();
}
pub fn perceived_luminance(c: color.RGB.C) callconv(lib.calling_conv) f64 {
return color.RGB.fromC(c).perceivedLuminance();
}
pub fn contrast(a: color.RGB.C, b: color.RGB.C) callconv(lib.calling_conv) f64 {
return color.RGB.fromC(a).contrast(.fromC(b));
}
fn expectRgb(expected: color.RGB, actual: color.RGB.C) !void {
try std.testing.expectEqual(expected, color.RGB.fromC(actual));
}
fn expectPaletteC(expected: *const color.PaletteC, actual: *const color.PaletteC) !void {
for (expected.*, actual.*) |expected_rgb, actual_rgb| {
try expectRgb(color.RGB.fromC(expected_rgb), actual_rgb);
}
}
fn generatePalette(
base_: ?*const color.PaletteC,
skip_: ?*const PaletteMask,
bg: color.RGB,
fg: color.RGB,
harmonious: bool,
) color.PaletteC {
var out: color.PaletteC = undefined;
palette_generate(base_, skip_, bg.cval(), fg.cval(), harmonious, &out);
return out;
}
fn setMaskBit(mask: *PaletteMask, idx: usize) void {
mask.bits[idx >> 6] |= @as(u64, 1) << @as(u6, @intCast(idx & 63));
}
test "color: parse_x11 valid" {
const testing = std.testing;
const cases = [_]struct {
input: []const u8,
expected: color.RGB,
}{
.{ .input = "white", .expected = .{ .r = 255, .g = 255, .b = 255 } },
.{ .input = "medium spring green", .expected = .{ .r = 0, .g = 250, .b = 154 } },
.{ .input = "ForestGreen", .expected = .{ .r = 34, .g = 139, .b = 34 } },
.{ .input = "FoReStGReen", .expected = .{ .r = 34, .g = 139, .b = 34 } },
.{ .input = " Forest Green ", .expected = .{ .r = 34, .g = 139, .b = 34 } },
.{ .input = "\tblack\t", .expected = .{ .r = 0, .g = 0, .b = 0 } },
};
for (cases) |case| {
var out: color.RGB.C = undefined;
try testing.expectEqual(.success, parse_x11(case.input.ptr, case.input.len, &out));
try expectRgb(case.expected, out);
}
}
test "color: parse_x11 invalid" {
const testing = std.testing;
const cases = [_][]const u8{
"nosuchcolor",
"",
" ",
"#ffffff",
};
for (cases) |case| {
var out: color.RGB.C = undefined;
try testing.expectEqual(.invalid_value, parse_x11(case.ptr, case.len, &out));
}
var out: color.RGB.C = undefined;
try testing.expectEqual(.invalid_value, parse_x11(null, 0, &out));
}
test "color: parse valid" {
const testing = std.testing;
const cases = [_]struct {
input: []const u8,
expected: color.RGB,
}{
.{ .input = "black", .expected = .{ .r = 0, .g = 0, .b = 0 } },
.{ .input = "#AABBCC", .expected = .{ .r = 170, .g = 187, .b = 204 } },
.{ .input = "0A0B0C", .expected = .{ .r = 10, .g = 11, .b = 12 } },
.{ .input = "FFF", .expected = .{ .r = 255, .g = 255, .b = 255 } },
.{ .input = "#345", .expected = .{ .r = 51, .g = 68, .b = 85 } },
.{ .input = "rgb:1/2/3", .expected = .{ .r = 17, .g = 34, .b = 51 } },
.{ .input = " black ", .expected = .{ .r = 0, .g = 0, .b = 0 } },
.{ .input = " #AABBCC ", .expected = .{ .r = 170, .g = 187, .b = 204 } },
};
for (cases) |case| {
var out: color.RGB.C = undefined;
try testing.expectEqual(.success, parse(case.input.ptr, case.input.len, &out));
try expectRgb(case.expected, out);
}
}
test "color: parse invalid" {
const testing = std.testing;
const cases = [_][]const u8{
"",
"notacolor",
"#12345",
};
for (cases) |case| {
var out: color.RGB.C = undefined;
try testing.expectEqual(.invalid_value, parse(case.ptr, case.len, &out));
}
var out: color.RGB.C = undefined;
try testing.expectEqual(.invalid_value, parse(null, 0, &out));
}
test "color: parse_palette_entry valid" {
const testing = std.testing;
const cases = [_]struct {
input: []const u8,
index: u8,
expected: color.RGB,
}{
.{ .input = "0=#AABBCC", .index = 0, .expected = .{ .r = 170, .g = 187, .b = 204 } },
.{ .input = "0xF=#ABCDEF", .index = 15, .expected = .{ .r = 171, .g = 205, .b = 239 } },
.{ .input = "0b1=#014589", .index = 1, .expected = .{ .r = 1, .g = 69, .b = 137 } },
.{ .input = "0o7=#234567", .index = 7, .expected = .{ .r = 35, .g = 69, .b = 103 } },
.{ .input = " 1= #DDEEFF ", .index = 1, .expected = .{ .r = 221, .g = 238, .b = 255 } },
.{ .input = "1=black", .index = 1, .expected = .{ .r = 0, .g = 0, .b = 0 } },
};
for (cases) |case| {
var index: u8 = undefined;
var rgb: color.RGB.C = undefined;
try testing.expectEqual(.success, parse_palette_entry(
case.input.ptr,
case.input.len,
&index,
&rgb,
));
try testing.expectEqual(case.index, index);
try expectRgb(case.expected, rgb);
}
}
test "color: parse_palette_entry invalid" {
const testing = std.testing;
const cases = [_][]const u8{
"256=#AABBCC",
"a",
"",
"1=notacolor",
};
for (cases) |case| {
var index: u8 = undefined;
var rgb: color.RGB.C = undefined;
try testing.expectEqual(.invalid_value, parse_palette_entry(
case.ptr,
case.len,
&index,
&rgb,
));
}
var index: u8 = undefined;
var rgb: color.RGB.C = undefined;
try testing.expectEqual(.invalid_value, parse_palette_entry(null, 0, &index, &rgb));
}
test "color: palette_default" {
var out: color.PaletteC = undefined;
palette_default(&out);
const expected = color.paletteCval(&color.default);
try expectPaletteC(&expected, &out);
try expectRgb(.{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, out[0]);
try expectRgb(.{ .r = 0, .g = 0, .b = 0 }, out[16]);
try expectRgb(.{ .r = 255, .g = 255, .b = 255 }, out[231]);
try expectRgb(.{ .r = 8, .g = 8, .b = 8 }, out[232]);
try expectRgb(.{ .r = 238, .g = 238, .b = 238 }, out[255]);
}
test "color: palette_generate base16 preserved" {
const testing = std.testing;
const base = color.paletteCval(&color.default);
const palette = generatePalette(
&base,
null,
.{ .r = 0, .g = 0, .b = 0 },
.{ .r = 255, .g = 255, .b = 255 },
false,
);
for (0..16) |i| {
try testing.expectEqual(color.RGB.fromC(base[i]), color.RGB.fromC(palette[i]));
}
}
test "color: palette_generate cube corners" {
const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generatePalette(null, null, bg, fg, false);
try expectRgb(bg, palette[16]);
try expectRgb(fg, palette[231]);
}
test "color: palette_generate light theme harmonious=false" {
const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
const normal = generatePalette(null, null, white, black, false);
try expectRgb(black, normal[16]);
try expectRgb(white, normal[231]);
const harmonious = generatePalette(null, null, white, black, true);
try expectRgb(white, harmonious[16]);
try expectRgb(black, harmonious[231]);
}
test "color: palette_generate grayscale monotonic" {
const testing = std.testing;
const palette = generatePalette(
null,
null,
.{ .r = 0, .g = 0, .b = 0 },
.{ .r = 255, .g = 255, .b = 255 },
false,
);
var prev_lum: f64 = 0.0;
for (232..256) |i| {
const lum = color.RGB.fromC(palette[i]).luminance();
try testing.expect(lum >= prev_lum);
prev_lum = lum;
}
}
test "color: palette_generate skip mask" {
const testing = std.testing;
const base = color.paletteCval(&color.default);
var skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
setMaskBit(&skip, 20);
setMaskBit(&skip, 100);
setMaskBit(&skip, 240);
const palette = generatePalette(
&base,
&skip,
.{ .r = 0, .g = 0, .b = 0 },
.{ .r = 255, .g = 255, .b = 255 },
false,
);
try testing.expectEqual(color.RGB.fromC(base[20]), color.RGB.fromC(palette[20]));
try testing.expectEqual(color.RGB.fromC(base[100]), color.RGB.fromC(palette[100]));
try testing.expectEqual(color.RGB.fromC(base[240]), color.RGB.fromC(palette[240]));
try testing.expect(!color.RGB.fromC(palette[21]).eql(color.RGB.fromC(base[21])));
}
test "color: palette_generate dark harmonious no-op" {
const testing = std.testing;
const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
const normal = generatePalette(null, null, bg, fg, false);
const harmonious = generatePalette(null, null, bg, fg, true);
for (16..256) |i| {
try testing.expectEqual(color.RGB.fromC(normal[i]), color.RGB.fromC(harmonious[i]));
}
}
test "color: palette_generate light harmonious ramp" {
const testing = std.testing;
const bg = color.RGB{ .r = 255, .g = 255, .b = 255 };
const fg = color.RGB{ .r = 0, .g = 0, .b = 0 };
{
const palette = generatePalette(null, null, bg, fg, false);
var prev_lum: f64 = 0.0;
for (232..256) |i| {
const lum = color.RGB.fromC(palette[i]).luminance();
try testing.expect(lum >= prev_lum);
prev_lum = lum;
}
}
{
const palette = generatePalette(null, null, bg, fg, true);
var prev_lum: f64 = 1.0;
for (232..256) |i| {
const lum = color.RGB.fromC(palette[i]).luminance();
try testing.expect(lum <= prev_lum);
prev_lum = lum;
}
}
}
test "color: palette_generate NULL base uses default" {
const base = color.paletteCval(&color.default);
const bg = color.RGB{ .r = 5, .g = 10, .b = 15 };
const fg = color.RGB{ .r = 245, .g = 250, .b = 255 };
const null_base = generatePalette(null, null, bg, fg, false);
const explicit = generatePalette(&base, null, bg, fg, false);
try expectPaletteC(&explicit, &null_base);
}
test "color: palette_generate NULL skip" {
const base = color.paletteCval(&color.default);
const bg = color.RGB{ .r = 5, .g = 10, .b = 15 };
const fg = color.RGB{ .r = 245, .g = 250, .b = 255 };
const skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
const null_skip = generatePalette(&base, null, bg, fg, false);
const explicit = generatePalette(&base, &skip, bg, fg, false);
try expectPaletteC(&explicit, &null_skip);
}
test "color: palette_generate matches generate256Color" {
const base_c = color.paletteCval(&color.default);
const bg = color.RGB{ .r = 16, .g = 32, .b = 48 };
const fg = color.RGB{ .r = 240, .g = 224, .b = 208 };
var skip_c: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
setMaskBit(&skip_c, 20);
setMaskBit(&skip_c, 100);
setMaskBit(&skip_c, 240);
const actual = generatePalette(&base_c, &skip_c, bg, fg, true);
const expected_z = color.generate256Color(
color.paletteZval(&base_c),
skip_c.toZig(),
bg,
fg,
true,
);
const expected = color.paletteCval(&expected_z);
try expectPaletteC(&expected, &actual);
}
test "color: palette_generate in-place" {
var base = color.paletteCval(&color.default);
const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
const expected = generatePalette(&base, null, bg, fg, false);
palette_generate(&base, null, bg.cval(), fg.cval(), false, &base);
try expectPaletteC(&expected, &base);
}
test "color: luminance" {
const testing = std.testing;
const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
try testing.expectApproxEqAbs(@as(f64, 0.0), luminance(black.cval()), 0.000001);
try testing.expectApproxEqAbs(@as(f64, 1.0), luminance(white.cval()), 0.000001);
const samples = [_]color.RGB{
.{ .r = 40, .g = 44, .b = 52 },
.{ .r = 171, .g = 205, .b = 239 },
.{ .r = 12, .g = 34, .b = 56 },
};
for (samples) |sample| {
try testing.expectApproxEqAbs(sample.luminance(), luminance(sample.cval()), 0.000001);
}
}
test "color: perceived_luminance" {
const testing = std.testing;
const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
const dark = color.RGB{ .r = 0x28, .g = 0x2C, .b = 0x34 };
try testing.expectApproxEqAbs(@as(f64, 0.0), perceived_luminance(black.cval()), 0.000001);
try testing.expectApproxEqAbs(@as(f64, 1.0), perceived_luminance(white.cval()), 0.000001);
try testing.expect(perceived_luminance(dark.cval()) < 0.5);
try testing.expect(perceived_luminance(white.cval()) > 0.5);
const samples = [_]color.RGB{
dark,
.{ .r = 171, .g = 205, .b = 239 },
.{ .r = 12, .g = 34, .b = 56 },
};
for (samples) |sample| {
try testing.expectApproxEqAbs(
sample.perceivedLuminance(),
perceived_luminance(sample.cval()),
0.000001,
);
}
}
test "color: contrast" {
const testing = std.testing;
const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
const gray = color.RGB{ .r = 128, .g = 128, .b = 128 };
try testing.expectApproxEqAbs(@as(f64, 21.0), contrast(white.cval(), black.cval()), 0.000001);
try testing.expectApproxEqAbs(
contrast(black.cval(), white.cval()),
contrast(white.cval(), black.cval()),
0.000001,
);
try testing.expectApproxEqAbs(@as(f64, 1.0), contrast(gray.cval(), gray.cval()), 0.000001);
}
test "color: x11_names" {
const testing = std.testing;
const names = x11_names();
const count = x11_name_count();
try testing.expectEqual(x11_color.entries.len, count);
try testing.expect(count > 700);
var walked: usize = 0;
while (names[walked].name) |name| : (walked += 1) {
const name_slice = std.mem.span(name);
const expected = x11_color.map.get(name_slice).?;
try testing.expectEqual(expected, color.RGB.fromC(names[walked].color));
}
try testing.expectEqual(count, walked);
try testing.expectEqual(@as(?[*:0]const u8, null), names[count].name);
}
test "color: x11_names static" {
try std.testing.expectEqual(@intFromPtr(x11_names()), @intFromPtr(x11_names()));
}

View File

@@ -58,6 +58,16 @@ pub const osc_command_type = osc.commandType;
pub const osc_command_data = osc.commandData;
pub const color_rgb_get = color.rgb_get;
pub const color_contrast = color.contrast;
pub const color_luminance = color.luminance;
pub const color_parse = color.parse;
pub const color_parse_palette_entry = color.parse_palette_entry;
pub const color_parse_x11 = color.parse_x11;
pub const color_palette_default = color.palette_default;
pub const color_palette_generate = color.palette_generate;
pub const color_perceived_luminance = color.perceived_luminance;
pub const color_x11_name_count = color.x11_name_count;
pub const color_x11_names = color.x11_names;
pub const color_scheme_report_encode = color_scheme.report_encode;

View File

@@ -7,6 +7,7 @@
const std = @import("std");
const lib = @import("../lib.zig");
const color = @import("../color.zig");
const color_c = @import("color.zig");
const mouse_event = @import("mouse_event.zig");
const point = @import("../point.zig");
const size_report = @import("size_report.zig");
@@ -38,7 +39,9 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
break :structs .initComptime(.{
.{ "GhosttyBuffer", StructInfo.init(lib.Buffer) },
.{ "GhosttyCodepoints", StructInfo.init(Codepoints) },
.{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) },
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
.{ "GhosttyColorX11Entry", StructInfo.init(color_c.X11Entry) },
.{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
.{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },

View File

@@ -47,6 +47,81 @@ pub const default: Palette = default: {
/// Palette is the 256 color palette.
pub const Palette = [256]RGB;
/// A parsed palette entry from Ghostty's config "N=COLOR" syntax.
pub const PaletteEntry = struct {
index: u8,
color: RGB,
};
/// Parse a palette entry in Ghostty config syntax: "N=COLOR" where N is
/// a palette index 0-255 (decimal, or 0x/0o/0b-prefixed per Zig's
/// parseInt base-0 rules) and COLOR is anything RGB.parse accepts.
/// Whitespace (spaces/tabs) around N and COLOR is ignored.
pub fn parsePaletteEntry(value: []const u8) error{ InvalidFormat, Overflow }!PaletteEntry {
const eql_idx = std.mem.indexOfScalar(u8, value, '=') orelse
return error.InvalidFormat;
const index = std.fmt.parseInt(
u8,
std.mem.trim(u8, value[0..eql_idx], " \t"),
0,
) catch |err| switch (err) {
error.Overflow => return error.Overflow,
error.InvalidCharacter => return error.InvalidFormat,
};
const rgb = try RGB.parse(value[eql_idx + 1 ..]);
return .{ .index = index, .color = rgb };
}
test "parsePaletteEntry" {
const testing = std.testing;
{
const entry = try parsePaletteEntry("0=#AABBCC");
try testing.expectEqual(@as(u8, 0), entry.index);
try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color);
}
{
const entry = try parsePaletteEntry("0b1=#014589");
try testing.expectEqual(@as(u8, 1), entry.index);
try testing.expectEqual(RGB{ .r = 1, .g = 69, .b = 137 }, entry.color);
}
{
const entry = try parsePaletteEntry("0o7=#234567");
try testing.expectEqual(@as(u8, 7), entry.index);
try testing.expectEqual(RGB{ .r = 35, .g = 69, .b = 103 }, entry.color);
}
{
const entry = try parsePaletteEntry("0xF=#ABCDEF");
try testing.expectEqual(@as(u8, 15), entry.index);
try testing.expectEqual(RGB{ .r = 171, .g = 205, .b = 239 }, entry.color);
}
{
const entry = try parsePaletteEntry("0 = #AABBCC");
try testing.expectEqual(@as(u8, 0), entry.index);
try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color);
}
{
const entry = try parsePaletteEntry(" 1= #DDEEFF ");
try testing.expectEqual(@as(u8, 1), entry.index);
try testing.expectEqual(RGB{ .r = 221, .g = 238, .b = 255 }, entry.color);
}
{
const entry = try parsePaletteEntry(" 2 = #123456 ");
try testing.expectEqual(@as(u8, 2), entry.index);
try testing.expectEqual(RGB{ .r = 18, .g = 52, .b = 86 }, entry.color);
}
{
const entry = try parsePaletteEntry("1=black");
try testing.expectEqual(@as(u8, 1), entry.index);
try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, entry.color);
}
try testing.expectError(error.InvalidFormat, parsePaletteEntry(" "));
try testing.expectError(error.InvalidFormat, parsePaletteEntry("a"));
try testing.expectError(error.Overflow, parsePaletteEntry("256=#AABBCC"));
try testing.expectError(error.InvalidFormat, parsePaletteEntry("1=notacolor"));
}
/// C-compatible palette type using the extern RGB struct.
pub const PaletteC = [256]RGB.C;
@@ -541,6 +616,8 @@ pub const RGB = packed struct(u24) {
/// Parse a color specification.
///
/// Leading and trailing spaces and tabs are ignored.
///
/// Any of the following forms are accepted:
///
/// 1. rgb:<red>/<green>/<blue>
@@ -554,38 +631,42 @@ pub const RGB = packed struct(u24) {
/// where <red>, <green>, and <blue> are floating point values between
/// 0.0 and 1.0 (inclusive).
///
/// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb
/// 3. #rgb, #rrggbb, rgb, rrggbb, #rrrgggbbb, #rrrrggggbbbb
///
/// where `r`, `g`, and `b` are a single hexadecimal digit.
/// These specify a color with 4, 8, 12, and 16 bits of precision
/// per color channel.
/// where `r`, `g`, and `b` are hexadecimal digits. The forms with
/// a leading # specify a color with 4, 8, 12, and 16 bits of
/// precision per color channel. The forms without a leading # are
/// accepted for compatibility with Ghostty config/theme color values.
///
/// 4. X11 color names
pub fn parse(value: []const u8) error{InvalidFormat}!RGB {
if (value.len == 0) {
const input = std.mem.trim(u8, value, " \t");
if (input.len == 0) {
@branchHint(.cold);
return error.InvalidFormat;
}
if (value[0] == '#') {
switch (value.len) {
if (input[0] == '#') {
switch (input.len) {
4 => return RGB{
.r = try RGB.fromHex(value[1..2]),
.g = try RGB.fromHex(value[2..3]),
.b = try RGB.fromHex(value[3..4]),
.r = try RGB.fromHex(input[1..2]),
.g = try RGB.fromHex(input[2..3]),
.b = try RGB.fromHex(input[3..4]),
},
7 => return RGB{
.r = try RGB.fromHex(value[1..3]),
.g = try RGB.fromHex(value[3..5]),
.b = try RGB.fromHex(value[5..7]),
.r = try RGB.fromHex(input[1..3]),
.g = try RGB.fromHex(input[3..5]),
.b = try RGB.fromHex(input[5..7]),
},
10 => return RGB{
.r = try RGB.fromHex(value[1..4]),
.g = try RGB.fromHex(value[4..7]),
.b = try RGB.fromHex(value[7..10]),
.r = try RGB.fromHex(input[1..4]),
.g = try RGB.fromHex(input[4..7]),
.b = try RGB.fromHex(input[7..10]),
},
13 => return RGB{
.r = try RGB.fromHex(value[1..5]),
.g = try RGB.fromHex(value[5..9]),
.b = try RGB.fromHex(value[9..13]),
.r = try RGB.fromHex(input[1..5]),
.g = try RGB.fromHex(input[5..9]),
.b = try RGB.fromHex(input[9..13]),
},
else => {
@@ -595,24 +676,36 @@ pub const RGB = packed struct(u24) {
}
}
// Check for X11 named colors. We allow whitespace around the edges
// of the color because Kitty allows whitespace. This is not part of
// any spec I could find.
if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb;
// Check for X11 named colors. We allow whitespace around the edges.
if (x11_color.map.get(input)) |rgb| return rgb;
if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) {
switch (input.len) {
3 => return RGB{
.r = try RGB.fromHex(input[0..1]),
.g = try RGB.fromHex(input[1..2]),
.b = try RGB.fromHex(input[2..3]),
},
6 => return RGB{
.r = try RGB.fromHex(input[0..2]),
.g = try RGB.fromHex(input[2..4]),
.b = try RGB.fromHex(input[4..6]),
},
else => {},
}
if (input.len < "rgb:a/a/a".len or !std.mem.eql(u8, input[0..3], "rgb")) {
@branchHint(.cold);
return error.InvalidFormat;
}
var i: usize = 3;
const use_intensity = if (value[i] == 'i') blk: {
const use_intensity = if (input[i] == 'i') blk: {
i += 1;
break :blk true;
} else false;
if (value[i] != ':') {
if (input[i] != ':') {
@branchHint(.cold);
return error.InvalidFormat;
}
@@ -620,8 +713,8 @@ pub const RGB = packed struct(u24) {
i += 1;
const r = r: {
const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end|
value[i..end]
const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end|
input[i..end]
else {
@branchHint(.cold);
return error.InvalidFormat;
@@ -636,8 +729,8 @@ pub const RGB = packed struct(u24) {
};
const g = g: {
const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end|
value[i..end]
const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end|
input[i..end]
else {
@branchHint(.cold);
return error.InvalidFormat;
@@ -652,9 +745,9 @@ pub const RGB = packed struct(u24) {
};
const b = if (use_intensity)
try RGB.fromIntensity(value[i..])
try RGB.fromIntensity(input[i..])
else
try RGB.fromHex(value[i..]);
try RGB.fromHex(input[i..]);
return RGB{
.r = r,
@@ -780,6 +873,11 @@ test "RGB.parse" {
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fffffffff"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffffffffff"));
try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010"));
try testing.expectEqual(RGB{ .r = 10, .g = 11, .b = 12 }, try RGB.parse("0A0B0C"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFFFFF"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFF"));
try testing.expectEqual(RGB{ .r = 51, .g = 68, .b = 85 }, try RGB.parse("#345"));
try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, try RGB.parse(" #AABBCC "));
try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black"));
try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("red"));
@@ -790,8 +888,11 @@ test "RGB.parse" {
try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, try RGB.parse("LawnGreen"));
try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, try RGB.parse("medium spring green"));
try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse(" Forest Green "));
try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse("\tForestGreen\t"));
// Invalid format
try testing.expectError(error.InvalidFormat, RGB.parse(""));
try testing.expectError(error.InvalidFormat, RGB.parse(" "));
try testing.expectError(error.InvalidFormat, RGB.parse("rgb;"));
try testing.expectError(error.InvalidFormat, RGB.parse("rgb:"));
try testing.expectError(error.InvalidFormat, RGB.parse(":a/a/a"));
@@ -807,6 +908,9 @@ test "RGB.parse" {
try testing.expectError(error.InvalidFormat, RGB.parse("#ffff"));
try testing.expectError(error.InvalidFormat, RGB.parse("#fffff"));
try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg"));
try testing.expectError(error.InvalidFormat, RGB.parse("#12345"));
try testing.expectError(error.InvalidFormat, RGB.parse("12345"));
try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor"));
}
test "DynamicPalette: init" {

View File

@@ -2,6 +2,20 @@ const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const RGB = @import("color.zig").RGB;
/// A single X11 color entry.
pub const Entry = struct {
/// Color name. Null-terminated so it can be exposed through the C API
/// without runtime allocation.
name: [:0]const u8,
color: RGB,
};
/// All X11 colors in rgb.txt file order.
///
/// This is kept separate from `map` because the C API needs stable file order
/// and null-terminated names. `ColorMap` keys do not provide that contract.
pub const entries: []const Entry = entriesArray();
/// The map of all available X11 colors.
pub const map = colorMap();
@@ -10,15 +24,10 @@ pub const ColorMap = std.StaticStringMapWithEql(
std.static_string_map.eqlAsciiIgnoreCase,
);
fn colorMap() ColorMap {
@setEvalBranchQuota(500_000);
const KV = struct { []const u8, RGB };
// The length of our data is the number of lines in the rgb file.
fn entriesArray() []const Entry {
@setEvalBranchQuota(1_000_000);
const len = std.mem.count(u8, data, "\n");
var kvs: [len]KV = undefined;
var result: [len]Entry = undefined;
// Parse the line. This is not very robust parsing, because we expect
// a very exact format for rgb.txt. However, this is all done at comptime
// so if our data is bad, we should hopefully get an error here or one
@@ -37,11 +46,29 @@ fn colorMap() ColorMap {
const g = try std.fmt.parseInt(u8, std.mem.trim(u8, line[4..7], " "), 10);
const b = try std.fmt.parseInt(u8, std.mem.trim(u8, line[8..11], " "), 10);
const name = std.mem.trim(u8, line[12..], " \t");
kvs[i] = .{ name, .{ .r = r, .g = g, .b = b } };
var name_z: [name.len:0]u8 = undefined;
@memcpy(name_z[0..name.len], name);
name_z[name.len] = 0;
const final_name = name_z;
result[i] = .{
.name = final_name[0..name.len :0],
.color = .{ .r = r, .g = g, .b = b },
};
i += 1;
}
assert(i == len);
const final = result;
return &final;
}
fn colorMap() ColorMap {
@setEvalBranchQuota(1_000_000);
const KV = struct { []const u8, RGB };
var kvs: [entries.len]KV = undefined;
for (entries, 0..) |entry, i| kvs[i] = .{ entry.name, entry.color };
return .initComptime(kvs);
}
@@ -67,3 +94,14 @@ test {
try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("mediumspringgreen"));
try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("forestgreen"));
}
test "entries" {
const testing = std.testing;
try testing.expect(entries.len > 700);
for (entries) |entry| {
try testing.expectEqual(entry.color, map.get(entry.name).?);
try testing.expectEqual(@as(u8, 0), entry.name.ptr[entry.name.len]);
}
try testing.expectEqualStrings("snow", entries[0].name);
}