From 2970e9a2a81d992c8c9a90e785cb65926b8172b3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Jul 2026 12:56:26 -0700 Subject: [PATCH] lib-vt: many more color utility APIs 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. --- include/ghostty/vt/color.h | 427 ++++++++++++++++++++++++++-- include/ghostty/vt/sgr.h | 7 +- src/config/Config.zig | 80 +----- src/lib_vt.zig | 10 + src/terminal/c/color.zig | 559 +++++++++++++++++++++++++++++++++++++ src/terminal/c/main.zig | 10 + src/terminal/c/types.zig | 3 + src/terminal/color.zig | 168 ++++++++--- src/terminal/x11_color.zig | 56 +++- 9 files changed, 1189 insertions(+), 131 deletions(-) diff --git a/include/ghostty/vt/color.h b/include/ghostty/vt/color.h index 9dc21864e..5dfdac990 100644 --- a/include/ghostty/vt/color.h +++ b/include/ghostty/vt/color.h @@ -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://` values. + * - XParseColor-style `rgbi://` 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 +#include #include #include @@ -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:// or + * rgbi:// 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 */ diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h index 8eec11dc9..757c5aeda 100644 --- a/include/ghostty/vt/sgr.h +++ b/include/ghostty/vt/sgr.h @@ -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 * diff --git a/src/config/Config.zig b/src/config/Config.zig index 8d3f39344..fa491e49c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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. diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 5af267f55..f1703e833 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -207,6 +207,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" }); diff --git a/src/terminal/c/color.zig b/src/terminal/c/color.zig index 3d244a19e..23d72055b 100644 --- a/src/terminal/c/color.zig +++ b/src/terminal/c/color.zig @@ -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())); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ba04209c7..24c8a899a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -57,6 +57,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 focus_encode = focus.encode; diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index de043a038..6e9cf806e 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -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) }, diff --git a/src/terminal/color.zig b/src/terminal/color.zig index cb1511c35..ac6a74570 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -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:// @@ -554,38 +631,42 @@ pub const RGB = packed struct(u24) { /// where , , and 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" { diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 97f3a7ce4..7239a47dd 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -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); +}