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

@@ -7,6 +7,114 @@
#ifndef GHOSTTY_VT_COLOR_H
#define GHOSTTY_VT_COLOR_H
/** @defgroup color Color Utilities
*
* Color parsing, palette generation, color math, and X11 color name
* utilities shared by libghostty-vt.
*
* These APIs expose Ghostty's color semantics directly to embedders. Use
* them when an application needs to parse the same color strings as Ghostty
* config and theme files, generate the same 256-color palette used by the
* terminal, list supported X11 color names, or make UI decisions from
* luminance and contrast values.
*
* ## Parsing Colors
*
* ghostty_color_parse() accepts the flexible syntax used by Ghostty for
* terminal colors:
*
* - X11 color names, matched ASCII case-insensitively.
* - 3- or 6-digit hex colors, with or without a leading `#`.
* - 9- or 12-digit hex colors, with a leading `#`.
* - XParseColor-style `rgb:<red>/<green>/<blue>` values.
* - XParseColor-style `rgbi:<red>/<green>/<blue>` values.
*
* Leading and trailing spaces and tabs are ignored. Use
* ghostty_color_parse_x11() when only X11 names should be accepted.
*
* @code{.c}
* GhosttyColorRgb color;
*
* if (ghostty_color_parse(
* "ForestGreen",
* sizeof("ForestGreen") - 1,
* &color) != GHOSTTY_SUCCESS) {
* // Handle invalid color input.
* }
*
* ghostty_color_parse("#abc", sizeof("#abc") - 1, &color);
* ghostty_color_parse("rgb:12/34/56", sizeof("rgb:12/34/56") - 1, &color);
* @endcode
*
* ## Palette Entries
*
* ghostty_color_parse_palette_entry() parses a single Ghostty palette
* override in `INDEX=COLOR` form. The index may be decimal or use a `0x`,
* `0o`, or `0b` prefix. The color side uses ghostty_color_parse().
*
* @code{.c}
* GhosttyColorRgb palette[256];
* ghostty_color_palette_default(palette);
*
* uint8_t index;
* GhosttyColorRgb rgb;
*
* if (ghostty_color_parse_palette_entry(
* "0x10=#282c34",
* sizeof("0x10=#282c34") - 1,
* &index,
* &rgb) == GHOSTTY_SUCCESS) {
* palette[index] = rgb;
* }
* @endcode
*
* ## Palette Generation
*
* ghostty_color_palette_generate() derives the 216-color cube and grayscale
* ramp from a base palette, background, and foreground. Set bits in
* GhosttyColorPaletteMask preserve specific indices from the base palette.
* The output may alias the base input.
*
* @code{.c}
* GhosttyColorRgb palette[256];
* ghostty_color_palette_default(palette);
*
* GhosttyColorPaletteMask skip = {0};
* GHOSTTY_COLOR_PALETTE_MASK_SET(&skip, 16);
*
* GhosttyColorRgb background = {40, 44, 52};
* GhosttyColorRgb foreground = {220, 223, 228};
*
* ghostty_color_palette_generate(
* palette,
* &skip,
* background,
* foreground,
* true,
* palette);
* @endcode
*
* ## X11 Color Names
*
* The X11 name table is static program-lifetime memory. Entries are in
* rgb.txt order and are terminated by an entry with `name == NULL`.
* ghostty_color_x11_name_count() returns the number of non-terminator
* entries.
*
* @code{.c}
* const GhosttyColorX11Entry* names = ghostty_color_x11_names();
* size_t count = ghostty_color_x11_name_count();
*
* for (size_t i = 0; i < count; i++) {
* // names[i].name and names[i].color are valid here.
* }
* @endcode
*
* @{
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
@@ -17,7 +125,7 @@ extern "C" {
/**
* RGB color value.
*
* @ingroup sgr
* @ingroup color
*/
typedef struct {
uint8_t r; /**< Red component (0-255) */
@@ -28,49 +136,128 @@ typedef struct {
/**
* Palette color index (0-255).
*
* @ingroup sgr
* @ingroup color
*/
typedef uint8_t GhosttyColorPaletteIndex;
/** @addtogroup sgr
* @{
/**
* A 256-bit mask of palette indices.
*
* Index i is set iff `(bits[i >> 6] >> (i & 63)) & 1` is 1.
* The mask is typically initialized to zero and then populated with
* GHOSTTY_COLOR_PALETTE_MASK_SET().
*
* @code{.c}
* GhosttyColorPaletteMask mask = {0};
* GHOSTTY_COLOR_PALETTE_MASK_SET(&mask, 20);
* if (GHOSTTY_COLOR_PALETTE_MASK_IS_SET(&mask, 20)) {
* // Index 20 will be preserved.
* }
* @endcode
*
* @ingroup color
*/
typedef struct {
uint64_t bits[4];
} GhosttyColorPaletteMask;
/** Black color (0) @ingroup sgr */
/**
* An entry in Ghostty's X11 color name table.
*
* @ingroup color
*/
typedef struct {
/** Null-terminated color name. NULL marks the end of the table. */
const char* name;
/** The RGB value of the color. */
GhosttyColorRgb color;
} GhosttyColorX11Entry;
/**
* Return the storage word for a palette mask index.
*
* @param index The palette index (0-255)
*
* @ingroup color
*/
#define GHOSTTY_COLOR_PALETTE_MASK_WORD(index) ((index) >> 6)
/**
* Return the storage bit for a palette mask index.
*
* @param index The palette index (0-255)
*
* @ingroup color
*/
#define GHOSTTY_COLOR_PALETTE_MASK_BIT(index) (UINT64_C(1) << ((index) & 63))
/**
* Set a palette mask index.
*
* @param mask Pointer to a GhosttyColorPaletteMask
* @param index The palette index (0-255)
*
* @ingroup color
*/
#define GHOSTTY_COLOR_PALETTE_MASK_SET(mask, index) \
((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] |= GHOSTTY_COLOR_PALETTE_MASK_BIT(index))
/**
* Clear a palette mask index.
*
* @param mask Pointer to a GhosttyColorPaletteMask
* @param index The palette index (0-255)
*
* @ingroup color
*/
#define GHOSTTY_COLOR_PALETTE_MASK_UNSET(mask, index) \
((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] &= ~GHOSTTY_COLOR_PALETTE_MASK_BIT(index))
/**
* Test whether a palette mask index is set.
*
* @param mask Pointer to a GhosttyColorPaletteMask
* @param index The palette index (0-255)
* @return true if the palette index is set, false otherwise
*
* @ingroup color
*/
#define GHOSTTY_COLOR_PALETTE_MASK_IS_SET(mask, index) \
(((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] & GHOSTTY_COLOR_PALETTE_MASK_BIT(index)) != 0)
/** Black color (0) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BLACK 0
/** Red color (1) @ingroup sgr */
/** Red color (1) @ingroup color */
#define GHOSTTY_COLOR_NAMED_RED 1
/** Green color (2) @ingroup sgr */
/** Green color (2) @ingroup color */
#define GHOSTTY_COLOR_NAMED_GREEN 2
/** Yellow color (3) @ingroup sgr */
/** Yellow color (3) @ingroup color */
#define GHOSTTY_COLOR_NAMED_YELLOW 3
/** Blue color (4) @ingroup sgr */
/** Blue color (4) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BLUE 4
/** Magenta color (5) @ingroup sgr */
/** Magenta color (5) @ingroup color */
#define GHOSTTY_COLOR_NAMED_MAGENTA 5
/** Cyan color (6) @ingroup sgr */
/** Cyan color (6) @ingroup color */
#define GHOSTTY_COLOR_NAMED_CYAN 6
/** White color (7) @ingroup sgr */
/** White color (7) @ingroup color */
#define GHOSTTY_COLOR_NAMED_WHITE 7
/** Bright black color (8) @ingroup sgr */
/** Bright black color (8) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8
/** Bright red color (9) @ingroup sgr */
/** Bright red color (9) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9
/** Bright green color (10) @ingroup sgr */
/** Bright green color (10) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10
/** Bright yellow color (11) @ingroup sgr */
/** Bright yellow color (11) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11
/** Bright blue color (12) @ingroup sgr */
/** Bright blue color (12) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12
/** Bright magenta color (13) @ingroup sgr */
/** Bright magenta color (13) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13
/** Bright cyan color (14) @ingroup sgr */
/** Bright cyan color (14) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14
/** Bright white color (15) @ingroup sgr */
/** Bright white color (15) @ingroup color */
#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15
/** @} */
/**
* Get the RGB color components.
*
@@ -83,15 +270,209 @@ typedef uint8_t GhosttyColorPaletteIndex;
* @param g Pointer to store the green component (0-255)
* @param b Pointer to store the blue component (0-255)
*
* @ingroup sgr
* @ingroup color
*/
GHOSTTY_API void ghostty_color_rgb_get(GhosttyColorRgb color,
uint8_t* r,
uint8_t* g,
uint8_t* b);
/**
* Parse an X11 color name.
*
* The color name is resolved from Ghostty's embedded rgb.txt table.
* Leading and trailing spaces and tabs are trimmed, and matching is
* ASCII case-insensitive. Hex values are not accepted by this function.
*
* @param name The color name bytes (must not be NULL)
* @param len The length of @p name in bytes
* @param[out] out The parsed RGB color
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if no color
* matches or @p name is NULL
*
* @ingroup color
*/
GHOSTTY_API GhosttyResult ghostty_color_parse_x11(
const char* name,
size_t len,
GhosttyColorRgb* out);
/**
* Parse a flexible Ghostty color value.
*
* Accepts Ghostty's terminal color syntax: X11 color names, hex colors
* in 3-, 6-, 9-, or 12-digit form (the leading # is optional for 3- and
* 6-digit values), and rgb:<red>/<green>/<blue> or
* rgbi:<red>/<green>/<blue> specifications. Leading and trailing spaces
* and tabs are trimmed.
*
* @param value The color value bytes (must not be NULL)
* @param len The length of @p value in bytes
* @param[out] out The parsed RGB color
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if parsing
* fails or @p value is NULL
*
* @ingroup color
*/
GHOSTTY_API GhosttyResult ghostty_color_parse(
const char* value,
size_t len,
GhosttyColorRgb* out);
/**
* Parse a Ghostty palette entry.
*
* Accepts Ghostty palette config syntax: N=COLOR. N is a palette index
* from 0 to 255 in decimal or in 0x, 0o, or 0b-prefixed form. Spaces and
* tabs around N and COLOR are ignored. COLOR accepts the same syntax as
* ghostty_color_parse().
*
* @param value The palette entry bytes (must not be NULL)
* @param len The length of @p value in bytes
* @param[out] out_index The parsed palette index
* @param[out] out_rgb The parsed RGB color
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE on any
* failure, including index overflow
*
* @ingroup color
*/
GHOSTTY_API GhosttyResult ghostty_color_parse_palette_entry(
const char* value,
size_t len,
uint8_t* out_index,
GhosttyColorRgb* out_rgb);
/**
* Get Ghostty's built-in default 256-color palette.
*
* Writes exactly 256 entries: Ghostty's base16 defaults, the xterm
* 6x6x6 color cube, and the grayscale ramp.
*
* @param[out] out The output palette, an array of exactly 256
* GhosttyColorRgb values
*
* @ingroup color
*/
GHOSTTY_API void ghostty_color_palette_default(GhosttyColorRgb* out);
/**
* Generate a 256-color palette from base colors.
*
* The base palette supplies indices 0-15, which are always preserved.
* If @p base is NULL, Ghostty's default palette is used. If @p skip is
* NULL, no extra indices are skipped. Set bits in @p skip preserve those
* indices from @p base. The 216-color cube at indices 16-231 is generated
* with trilinear CIELAB interpolation, and the grayscale ramp at indices
* 232-255 is interpolated from the background to the foreground.
*
* For light themes, @p harmonious controls whether the generated palette
* keeps the background-to-foreground orientation. When false, Ghostty
* swaps the light background and dark foreground so the cube and ramp run
* dark-to-light. The output palette may be the same pointer as @p base.
*
* @param base The base palette, an array of exactly 256 GhosttyColorRgb
* values, or NULL to use Ghostty's default palette
* @param skip The palette indices to preserve from @p base, or NULL for
* an empty mask
* @param bg The terminal background color
* @param fg The terminal foreground color
* @param harmonious Whether light themes keep background-to-foreground
* orientation
* @param[out] out The output palette, an array of exactly 256
* GhosttyColorRgb values
*
* @ingroup color
*/
GHOSTTY_API void ghostty_color_palette_generate(
const GhosttyColorRgb* base,
const GhosttyColorPaletteMask* skip,
GhosttyColorRgb bg,
GhosttyColorRgb fg,
bool harmonious,
GhosttyColorRgb* out);
/**
* Calculate W3C relative luminance for an RGB color.
*
* Returns a normalized value from 0.0 for black to 1.0 for white.
* See https://www.w3.org/TR/WCAG20/#relativeluminancedef.
*
* @param color The RGB color
* @return Relative luminance in the range 0.0 to 1.0
*
* @ingroup color
*/
GHOSTTY_API double ghostty_color_luminance(GhosttyColorRgb color);
/**
* Calculate perceived luminance for an RGB color.
*
* Returns a normalized value from 0.0 for black to 1.0 for white.
* Ghostty treats a background color as light when this exceeds 0.5.
* This is not the metric used internally by
* ghostty_color_palette_generate(), which uses CIELAB lightness.
*
* @param color The RGB color
* @return Perceived luminance in the range 0.0 to 1.0
*
* @ingroup color
*/
GHOSTTY_API double ghostty_color_perceived_luminance(GhosttyColorRgb color);
/**
* Calculate the WCAG contrast ratio between two RGB colors.
*
* The contrast ratio is symmetric and ranges from 1.0 for identical
* colors to 21.0 for black and white.
*
* @param a The first RGB color
* @param b The second RGB color
* @return WCAG contrast ratio in the range 1.0 to 21.0
*
* @ingroup color
*/
GHOSTTY_API double ghostty_color_contrast(GhosttyColorRgb a, GhosttyColorRgb b);
/**
* Get Ghostty's X11 color name table.
*
* The returned pointer references static memory valid for the program
* lifetime and is never NULL. Entries are in rgb.txt order and are
* terminated by an entry with name == NULL. Aliases are separate entries,
* such as "medium spring green" and "MediumSpringGreen". Names are the
* exact supported spellings from rgb.txt; ghostty_color_parse_x11() also
* matches them case-insensitively.
*
* @code{.c}
* for (const GhosttyColorX11Entry* e = ghostty_color_x11_names();
* e->name != NULL;
* e++) {
* // e->name and e->color are valid here.
* }
* @endcode
*
* @return Pointer to the first X11 color entry
*
* @ingroup color
*/
GHOSTTY_API const GhosttyColorX11Entry* ghostty_color_x11_names(void);
/**
* Get the number of X11 color name entries.
*
* The returned count excludes the NULL terminator and is provided so
* bindings can preallocate storage before reading ghostty_color_x11_names().
*
* @return Number of X11 color name entries
*
* @ingroup color
*/
GHOSTTY_API size_t ghostty_color_x11_name_count(void);
#ifdef __cplusplus
}
#endif
/** @} */
#endif /* GHOSTTY_VT_COLOR_H */

View File

@@ -19,8 +19,11 @@
* The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`)
* and returns individual text attributes like bold, italic, colors, etc.
* It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed,
* and handles various color formats including 8-color, 16-color, 256-color,
* X11 named colors, and RGB in multiple formats.
* and handles SGR color attributes including 8-color, 16-color, 256-color,
* direct RGB, underline color, and reset forms. Color values are returned
* using the shared @ref color types; applications that need to parse Ghostty
* config/theme color strings, generate palettes, inspect X11 color names, or
* calculate luminance and contrast should use the @ref color APIs directly.
*
* ## Basic Usage
*

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);
}