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 49045edb4..1a5bf6162 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); 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 b41e5a815..ec0f4691b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; 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); +}