Something went wrong. Try again.
My version of https://github.com/vercel-labs/fx
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213//! Built-in color themes plus theme.json loading.//!//! `fx_dark` and `fx_light` are the historical fx palettes, preserved byte for//! byte so the default look never changes. Both core presentation and ui//! rendering resolve their themed values from here. User themes load from//! `~/.fx/themes/<name>.json` (selected with FX_THEME=<name>) in either the//! native fx slot schema or the VS Code theme schema (`colors` +//! `tokenColors`), so editor themes like GitHub Dark apply//! directly. Hex colors resolve to truecolor escapes when the terminal//! supports them, otherwise they quantize to the xterm-256 palette.
const std = @import("std");const debug_trace = @import("debug_trace.zig");const io_mod = @import("io.zig");
pub const Rgb = struct { r: u8, g: u8, b: u8 };
pub const SyntaxPalette = struct { /// `syntax: false` in a native theme turns syntax highlighting off; the /// style slots keep their defaults but the highlighter passes text through. enabled: bool = true, keyword_style: []const u8, string_style: []const u8, number_style: []const u8, comment_style: []const u8, /// Command words (the grammar's variable.function). function_style: []const u8, /// `$VAR`, `${VAR}`, and `~` references. variable_style: []const u8, /// `&&`, `|`, redirects, glob stars. operator_style: []const u8,};
pub const Theme = struct { name: []const u8, light: bool,
divider_style: []const u8, hint_style: []const u8, statusline_style: []const u8, tag_style: []const u8, subtitle_style: []const u8, system_notice_label_style: []const u8, system_notice_text_style: []const u8, dim_style: []const u8, warning_style: []const u8, green_style: []const u8, red_style: []const u8, diff_added_style: []const u8, diff_removed_style: []const u8, // The line number and +/- sign carry the only color in an otherwise // monochrome diff: green for additions (#30A46C), red for deletions // (#E5484D). Truecolor when the terminal supports it, 256-color fallback // otherwise. Both variants ship in every theme because capability is a // terminal property, not a theme property. diff_added_marker_truecolor: []const u8, diff_removed_marker_truecolor: []const u8, diff_added_marker_fallback: []const u8, diff_removed_marker_fallback: []const u8, approval_button_active_style: []const u8, approval_button_inactive_style: []const u8, selected_completion_style: []const u8, permission_auto_style: []const u8, user_card_marker_style: []const u8, user_card_accent_style: []const u8, inline_code_open: []const u8, task_completed_open: []const u8, tool_stdout_style: []const u8, tool_stderr_style: []const u8, link_style: []const u8, syntax: SyntaxPalette,};
pub const fx_dark: Theme = .{ .name = "fx-dark", .light = false,
.divider_style = "\x1b[38;5;240m", .hint_style = "\x1b[38;5;255m", .statusline_style = "\x1b[38;5;245m", .tag_style = "\x1b[1;38;5;255m", .subtitle_style = "\x1b[1;38;5;255m", .system_notice_label_style = "\x1b[1;38;5;252m", .system_notice_text_style = "\x1b[38;5;250m", .dim_style = "\x1b[38;5;245m", .warning_style = "\x1b[38;5;252m", .green_style = "\x1b[38;5;252m", .red_style = "\x1b[38;5;252m", .diff_added_style = "\x1b[38;5;252m", .diff_removed_style = "\x1b[38;5;252m", .diff_added_marker_truecolor = "\x1b[38;2;48;164;108m", .diff_removed_marker_truecolor = "\x1b[38;2;229;72;77m", .diff_added_marker_fallback = "\x1b[38;5;71m", .diff_removed_marker_fallback = "\x1b[38;5;167m", .approval_button_active_style = "\x1b[48;5;255m\x1b[38;5;235m\x1b[1m", .approval_button_inactive_style = "\x1b[48;5;239m\x1b[38;5;255m", .selected_completion_style = "\x1b[1;38;5;255m", .permission_auto_style = "\x1b[38;5;252m", .user_card_marker_style = "\x1b[38;5;255m", .user_card_accent_style = "\x1b[38;5;252m", .inline_code_open = "\x1b[38;5;245m", .task_completed_open = "\x1b[38;5;252m", .tool_stdout_style = "\x1b[38;5;245m", .tool_stderr_style = "\x1b[38;5;252m", .link_style = "\x1b[38;5;75m", .syntax = .{ .keyword_style = "\x1b[38;5;252m", .string_style = "\x1b[38;5;250m", .number_style = "\x1b[38;5;250m", .comment_style = "\x1b[38;5;245m", .function_style = "\x1b[38;5;252m", .variable_style = "\x1b[38;5;252m", .operator_style = "\x1b[38;5;252m", },};
pub const fx_light: Theme = .{ .name = "fx-light", .light = true,
.divider_style = "\x1b[38;5;250m", .hint_style = "\x1b[38;5;235m", .statusline_style = "\x1b[38;5;241m", .tag_style = "\x1b[1;38;5;235m", .subtitle_style = "\x1b[1;38;5;235m", .system_notice_label_style = "\x1b[1;38;5;238m", .system_notice_text_style = "\x1b[38;5;241m", .dim_style = "\x1b[38;5;247m", .warning_style = "\x1b[38;5;238m", .green_style = "\x1b[38;5;238m", .red_style = "\x1b[38;5;238m", .diff_added_style = "\x1b[38;5;238m", .diff_removed_style = "\x1b[38;5;238m", .diff_added_marker_truecolor = "\x1b[38;2;48;164;108m", .diff_removed_marker_truecolor = "\x1b[38;2;229;72;77m", .diff_added_marker_fallback = "\x1b[38;5;71m", .diff_removed_marker_fallback = "\x1b[38;5;167m", .approval_button_active_style = "\x1b[48;5;236m\x1b[38;5;255m\x1b[1m", .approval_button_inactive_style = "\x1b[48;5;251m\x1b[38;5;237m", .selected_completion_style = "\x1b[1;38;5;235m", .permission_auto_style = "\x1b[38;5;238m", .user_card_marker_style = "\x1b[38;5;235m", .user_card_accent_style = "\x1b[38;5;238m", .inline_code_open = "\x1b[38;5;247m", .link_style = "\x1b[38;5;25m", .task_completed_open = "\x1b[38;5;238m", .tool_stdout_style = "\x1b[38;5;245m", .tool_stderr_style = "\x1b[38;5;252m", .syntax = .{ .keyword_style = "\x1b[38;5;238m", .string_style = "\x1b[38;5;241m", .number_style = "\x1b[38;5;241m", .comment_style = "\x1b[38;5;243m", .function_style = "\x1b[38;5;238m", .variable_style = "\x1b[38;5;238m", .operator_style = "\x1b[38;5;238m", },};
pub fn builtin(light: bool) Theme { return if (light) fx_light else fx_dark;}
/// The theme currently applied to the process. Core producers that cannot/// import ui state read their colors through `current()`; `activate` is called/// by the ui layer whenever a theme is applied.var active_theme: Theme = fx_dark;
pub fn current() Theme { return active_theme;}
pub fn activate(theme: Theme) void { active_theme = theme;}
pub const ThemeChoice = union(enum) { pin_light, pin_dark, custom: []const u8,};
/// Classifies a configured theme value (FX_THEME or the settings "theme"/// key): light/dark pin the builtin variant, anything else names a theme file/// under ~/.fx/themes.pub fn classifyValue(value: []const u8) ?ThemeChoice { if (value.len == 0) return null; if (std.ascii.eqlIgnoreCase(value, "light")) return .pin_light; if (std.ascii.eqlIgnoreCase(value, "dark")) return .pin_dark; return .{ .custom = value };}
/// Where the active theme came from: the configured custom theme file key (so/// live terminal flips can re-resolve it), and whether a light/dark variant is/// pinned by configuration. Recorded once at startup by the app lifecycle./// The name is copied into bounded internal storage: callers never donate/// memory, and the bound matches loadNamed's validation.var source_name_buf: [64]u8 = undefined;var source_name_len: usize = 0;var source_name_set: bool = false;var variant_pinned: bool = false;
pub fn setSource(name: ?[]const u8, pinned: bool) void { source_name_set = false; source_name_len = 0; if (name) |value| { if (value.len <= source_name_buf.len) { @memcpy(source_name_buf[0..value.len], value); source_name_len = value.len; source_name_set = true; } else { debug_trace.logf("theme", "theme_source_name_too_long len={d}", .{value.len}); } } variant_pinned = pinned;}
pub fn sourceName() ?[]const u8 { if (!source_name_set) return null; return source_name_buf[0..source_name_len];}
pub fn variantPinned() bool { return variant_pinned;}
// --- Hex colors and terminal capability resolution ---
pub const HexColor = struct { rgb: Rgb, alpha: u8 };
/// Parses `#rgb`, `#rrggbb`, and `#rrggbbaa` (the forms VS Code themes use).pub fn parseHexColor(bytes: []const u8) ?HexColor { if (bytes.len < 2 or bytes[0] != '#') return null; const hex = bytes[1..]; const n = std.fmt.charToDigit; switch (hex.len) { 3 => { const r = n(hex[0], 16) catch return null; const g = n(hex[1], 16) catch return null; const b = n(hex[2], 16) catch return null; return .{ .rgb = .{ .r = r * 17, .g = g * 17, .b = b * 17 }, .alpha = 0xff }; }, 6, 8 => { var channels: [4]u8 = .{ 0, 0, 0, 0xff }; const count = hex.len / 2; for (0..count) |i| { const hi = n(hex[i * 2], 16) catch return null; const lo = n(hex[i * 2 + 1], 16) catch return null; channels[i] = hi * 16 + lo; } return .{ .rgb = .{ .r = channels[0], .g = channels[1], .b = channels[2] }, .alpha = channels[3] }; }, else => return null, }}
/// Composites a possibly translucent color over a background, the same way an/// editor renders theme colors with an alpha channel.pub fn blendOver(fg: Rgb, alpha: u8, bg: Rgb) Rgb { if (alpha == 0xff) return fg; const a: u32 = alpha; const inv: u32 = 0xff - a; return .{ .r = @intCast((@as(u32, fg.r) * a + @as(u32, bg.r) * inv + 127) / 255), .g = @intCast((@as(u32, fg.g) * a + @as(u32, bg.g) * inv + 127) / 255), .b = @intCast((@as(u32, fg.b) * a + @as(u32, bg.b) * inv + 127) / 255), };}
const cube_levels = [6]u8{ 0, 95, 135, 175, 215, 255 };
fn cubeLevelIndex(v: u8) u8 { var best: u8 = 0; var best_dist: u32 = std.math.maxInt(u32); for (cube_levels, 0..) |level, i| { const d = if (level > v) level - v else v - level; if (d < best_dist) { best_dist = d; best = @intCast(i); } } return best;}
fn colorDistance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) u32 { const dr = @as(i32, r1) - r2; const dg = @as(i32, g1) - g2; const db = @as(i32, b1) - b2; return @intCast(dr * dr + dg * dg + db * db);}
/// Maps an RGB color to the nearest xterm-256 palette index, choosing between/// the 6x6x6 color cube (16-231) and the grayscale ramp (232-255).pub fn rgbToAnsi256(r: u8, g: u8, b: u8) u8 { const ri = cubeLevelIndex(r); const gi = cubeLevelIndex(g); const bi = cubeLevelIndex(b); const cube_dist = colorDistance(r, g, b, cube_levels[ri], cube_levels[gi], cube_levels[bi]);
const avg: u32 = (@as(u32, r) + g + b) / 3; const gray_idx: u32 = if (avg < 8) 0 else @min((avg - 8) / 10, 23); const gray_level: u8 = @intCast(8 + gray_idx * 10); const gray_dist = colorDistance(r, g, b, gray_level, gray_level, gray_level);
if (gray_dist < cube_dist) return @intCast(232 + gray_idx); return 16 + 36 * ri + 6 * gi + bi;}
const SlotSpec = struct { fg: ?Rgb = null, bg: ?Rgb = null, bold: bool = false, italic: bool = false,};
fn writeColorParam(writer: *std.Io.Writer, prefix: []const u8, rgb: Rgb, truecolor: bool) !void { if (truecolor) { try writer.print("{s};2;{d};{d};{d}", .{ prefix, rgb.r, rgb.g, rgb.b }); } else { try writer.print("{s};5;{d}", .{ prefix, rgbToAnsi256(rgb.r, rgb.g, rgb.b) }); }}
/// True when an SGR open sets the given parameter ("1" bold, "3" italic, "48"/// background), parsed from the parameter list rather than substring matched./// Handles slots built from multiple concatenated SGR escapes.pub fn sgrHasParam(open: []const u8, param: []const u8) bool { var rest = open; while (std.mem.find(u8, rest, "\x1b[")) |start| { const after = rest[start + 2 ..]; const end = std.mem.findScalar(u8, after, 'm') orelse return false; var it = std.mem.splitScalar(u8, after[0..end], ';'); var skip: usize = 0; while (it.next()) |part| { if (skip > 0) { skip -= 1; continue; } if (std.mem.eql(u8, part, "38") or std.mem.eql(u8, part, "48")) { if (std.mem.eql(u8, part, param)) return true; // Color introducer: 5;n consumes one parameter, 2;r;g;b three. // Skipping keeps RGB triples from reading as bold/italic. const mode = it.next() orelse break; if (std.mem.eql(u8, mode, "2")) { skip = 3; } else if (std.mem.eql(u8, mode, "5")) { skip = 1; } continue; } if (std.mem.eql(u8, part, param)) return true; } rest = after[end + 1 ..]; } return false;}
/// The closing sequence that fully neutralizes an SGR open: always resets the/// foreground, and resets background, bold, and italic only when the open set/// them. Builtin fg-only slots keep their historical one-escape close.pub fn closingFor(open: []const u8) []const u8 { const has_bg = sgrHasParam(open, "48"); const has_bold = sgrHasParam(open, "1"); const has_italic = sgrHasParam(open, "3"); if (has_bg) { if (has_bold and has_italic) return "\x1b[39m\x1b[49m\x1b[22m\x1b[23m"; if (has_bold) return "\x1b[39m\x1b[49m\x1b[22m"; if (has_italic) return "\x1b[39m\x1b[49m\x1b[23m"; return "\x1b[39m\x1b[49m"; } if (has_bold and has_italic) return "\x1b[39m\x1b[22m\x1b[23m"; if (has_bold) return "\x1b[39m\x1b[22m"; if (has_italic) return "\x1b[39m\x1b[23m"; return "\x1b[39m";}
/// Renders a slot style as one SGR escape. Caller owns the returned slice.fn slotEscapeChecked(alloc: std.mem.Allocator, spec: SlotSpec, truecolor: bool) ParseError![]u8 { return slotEscape(alloc, spec, truecolor) catch |err| switch (err) { error.OutOfMemory => error.OutOfMemory, // Writer.Allocating reports allocation failure as WriteFailed in 0.16. error.WriteFailed => error.OutOfMemory, };}
fn slotEscape(alloc: std.mem.Allocator, spec: SlotSpec, truecolor: bool) ![]u8 { var out: std.Io.Writer.Allocating = .init(alloc); errdefer out.deinit(); const writer = &out.writer; try writer.writeAll("\x1b["); if (spec.bold) try writer.writeAll("1;"); if (spec.italic) try writer.writeAll("3;"); if (spec.fg) |fg| try writeColorParam(writer, "38", fg, truecolor); if (spec.bg) |bg| { if (spec.fg != null or spec.bold or spec.italic) try writer.writeByte(';'); try writeColorParam(writer, "48", bg, truecolor); } try writer.writeByte('m'); return out.toOwnedSlice();}
// --- theme.json parsing ---
pub const max_theme_bytes: usize = 1024 * 1024;
pub const ParseError = error{ InvalidTheme, OutOfMemory };pub const ParseOptions = struct { truecolor: bool = true };
/// Parses a theme.json document, auto-detecting the native fx slot schema and/// the VS Code theme schema. Slots the file does not mention inherit from the/// matching builtin variant, so partial themes compose with the fx look.////// The returned Theme borrows from `alloc`; themes are process-lifetime state,/// so the caller keeps the allocation alive rather than freeing per theme.pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, options: ParseOptions) ParseError!Theme { var parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch return error.InvalidTheme; defer parsed.deinit(); const root = switch (parsed.value) { .object => |object| object, else => return error.InvalidTheme, }; if (looksLikeVsCode(root)) return parseVsCode(alloc, root, options); return parseNative(alloc, root, options);}
fn looksLikeVsCode(root: std.json.ObjectMap) bool { if (root.get("tokenColors")) |token_colors| { if (token_colors == .array) return true; } if (root.get("colors")) |colors| { if (colors == .object) { var it = colors.object.iterator(); while (it.next()) |entry| { if (std.mem.findScalar(u8, entry.key_ptr.*, '.') != null) return true; } } } return false;}
fn jsonString(value: std.json.Value) ?[]const u8 { return switch (value) { .string => |s| s, else => null, };}
/// Maps JSON slot keys to Theme fields: field names minus a trailing/// `_style` or `_open` ("divider", "inline_code"). Unknown keys are ignored/// so newer theme files keep loading on older binaries.fn assignSlotEscape(theme: *Theme, json_key: []const u8, escape: []const u8) void { const fields = @typeInfo(Theme).@"struct".fields; inline for (fields) |field| { if (field.type == []const u8 and !std.mem.eql(u8, field.name, "name")) { const stripped = comptime blk: { if (std.mem.endsWith(u8, field.name, "_style")) break :blk field.name[0 .. field.name.len - "_style".len]; if (std.mem.endsWith(u8, field.name, "_open")) break :blk field.name[0 .. field.name.len - "_open".len]; break :blk field.name; }; if (std.mem.eql(u8, json_key, stripped)) { @field(theme, field.name) = escape; return; } } }}
fn parseSlotSpec(value: std.json.Value) ParseError!SlotSpec { return switch (value) { .string => |s| blk: { const parsed = parseHexColor(s) orelse return error.InvalidTheme; if (parsed.alpha != 0xff) return error.InvalidTheme; break :blk SlotSpec{ .fg = parsed.rgb }; }, .object => |object| blk: { var spec: SlotSpec = .{}; if (object.get("fg")) |fg| { const parsed = parseHexColor(jsonString(fg) orelse return error.InvalidTheme) orelse return error.InvalidTheme; if (parsed.alpha != 0xff) return error.InvalidTheme; spec.fg = parsed.rgb; } if (object.get("bg")) |bg| { const parsed = parseHexColor(jsonString(bg) orelse return error.InvalidTheme) orelse return error.InvalidTheme; if (parsed.alpha != 0xff) return error.InvalidTheme; spec.bg = parsed.rgb; } if (object.get("bold")) |bold| { if (bold != .bool) return error.InvalidTheme; spec.bold = bold.bool; } if (object.get("italic")) |italic| { if (italic != .bool) return error.InvalidTheme; spec.italic = italic.bool; } if (spec.fg == null and spec.bg == null) return error.InvalidTheme; break :blk spec; }, else => error.InvalidTheme, };}
fn applySlot(theme: *Theme, alloc: std.mem.Allocator, json_key: []const u8, spec: SlotSpec, options: ParseOptions) ParseError!void { if (std.mem.eql(u8, json_key, "diff_added_marker") or std.mem.eql(u8, json_key, "diff_removed_marker")) { // Markers resolve both capability forms from one color so the // terminal picks at render time, matching the builtin contract. const fg = spec.fg orelse return error.InvalidTheme; if (spec.bg != null or spec.bold or spec.italic) return error.InvalidTheme; const truecolor = try slotEscapeChecked(alloc, .{ .fg = fg }, true); const fallback = try slotEscapeChecked(alloc, .{ .fg = fg }, false); if (json_key[5] == 'a') { theme.diff_added_marker_truecolor = truecolor; theme.diff_added_marker_fallback = fallback; } else { theme.diff_removed_marker_truecolor = truecolor; theme.diff_removed_marker_fallback = fallback; } return; } assignSlotEscape(theme, json_key, try slotEscapeChecked(alloc, spec, options.truecolor));}
fn parseNative(alloc: std.mem.Allocator, root: std.json.ObjectMap, options: ParseOptions) ParseError!Theme { var light = false; if (root.get("type")) |type_value| { const type_string = jsonString(type_value) orelse return error.InvalidTheme; if (std.ascii.eqlIgnoreCase(type_string, "light")) { light = true; } else if (!std.ascii.eqlIgnoreCase(type_string, "dark")) { return error.InvalidTheme; } } var theme = builtin(light); if (root.get("name")) |name_value| { const name = jsonString(name_value) orelse return error.InvalidTheme; if (name.len == 0) return error.InvalidTheme; theme.name = try alloc.dupe(u8, name); } if (root.get("colors")) |colors_value| { if (colors_value != .object) return error.InvalidTheme; var it = colors_value.object.iterator(); while (it.next()) |entry| { const spec = try parseSlotSpec(entry.value_ptr.*); try applySlot(&theme, alloc, entry.key_ptr.*, spec, options); } } if (root.get("syntax")) |syntax_value| { switch (syntax_value) { .object => |syntax_object| try applySyntax(&theme, alloc, syntax_object, options), .bool => |enabled| theme.syntax.enabled = enabled, else => return error.InvalidTheme, } } return theme;}
fn applySyntax(theme: *Theme, alloc: std.mem.Allocator, syntax: std.json.ObjectMap, options: ParseOptions) ParseError!void { var it = syntax.iterator(); while (it.next()) |entry| { const spec = try parseSlotSpec(entry.value_ptr.*); const resolved = try slotEscapeChecked(alloc, spec, options.truecolor); if (std.mem.eql(u8, entry.key_ptr.*, "keyword")) { theme.syntax.keyword_style = resolved; } else if (std.mem.eql(u8, entry.key_ptr.*, "string")) { theme.syntax.string_style = resolved; } else if (std.mem.eql(u8, entry.key_ptr.*, "number")) { theme.syntax.number_style = resolved; } else if (std.mem.eql(u8, entry.key_ptr.*, "comment")) { theme.syntax.comment_style = resolved; } else if (std.mem.eql(u8, entry.key_ptr.*, "function")) { theme.syntax.function_style = resolved; } else if (std.mem.eql(u8, entry.key_ptr.*, "variable")) { theme.syntax.variable_style = resolved; } else if (std.mem.eql(u8, entry.key_ptr.*, "operator")) { theme.syntax.operator_style = resolved; } }}
// --- VS Code theme schema ---
const VsCodeSlot = struct { key: []const u8, sources: []const []const u8, bg_sources: []const []const u8 = &.{}, bold: bool = false,};
/// Maps editor workbench colors onto fx chrome slots. Unmapped slots inherit/// the builtin variant, which keeps fx's neutral layout under editor themes.const vscode_slot_map = [_]VsCodeSlot{ .{ .key = "divider", .sources = &.{ "editorLineNumber.foreground", "panel.border" } }, .{ .key = "hint", .sources = &.{ "editor.foreground", "foreground" } }, .{ .key = "statusline", .sources = &.{ "statusBar.foreground", "editor.foreground", "foreground" } }, .{ .key = "tag", .sources = &.{ "editor.foreground", "foreground" }, .bold = true }, .{ .key = "subtitle", .sources = &.{ "editor.foreground", "foreground" }, .bold = true }, .{ .key = "system_notice_label", .sources = &.{ "editor.foreground", "foreground" }, .bold = true }, .{ .key = "system_notice_text", .sources = &.{ "editor.foreground", "foreground" } }, .{ .key = "dim", .sources = &.{ "editorLineNumber.foreground", "editor.foreground", "foreground" } }, // No foreground fallbacks: a link that matches body text stops reading as a link. .{ .key = "link", .sources = &.{"textLink.foreground"} }, .{ .key = "warning", .sources = &.{ "editorWarning.foreground", "terminal.ansiYellow" } }, .{ .key = "green", .sources = &.{"terminal.ansiGreen"} }, .{ .key = "red", .sources = &.{"terminal.ansiRed"} }, .{ .key = "diff_added", .sources = &.{ "editorGutter.addedBackground", "terminal.ansiGreen" } }, .{ .key = "diff_removed", .sources = &.{ "editorGutter.deletedBackground", "terminal.ansiRed" } }, .{ .key = "diff_added_marker", .sources = &.{ "editorGutter.addedBackground", "terminal.ansiGreen" } }, .{ .key = "diff_removed_marker", .sources = &.{ "editorGutter.deletedBackground", "terminal.ansiRed" } }, .{ .key = "approval_button_active", .sources = &.{ "button.foreground", "editor.background" }, .bg_sources = &.{ "button.background", "editor.foreground" }, .bold = true }, .{ .key = "approval_button_inactive", .sources = &.{ "editor.foreground", "foreground" }, .bg_sources = &.{"editor.lineHighlightBackground"} }, .{ .key = "selected_completion", .sources = &.{ "editor.foreground", "foreground" }, .bold = true }, .{ .key = "permission_auto", .sources = &.{ "editor.foreground", "foreground" } }, .{ .key = "user_card_marker", .sources = &.{ "editor.foreground", "foreground" } }, .{ .key = "user_card_accent", .sources = &.{ "editor.foreground", "foreground" } }, .{ .key = "inline_code", .sources = &.{ "terminal.ansiCyan", "editor.foreground", "foreground" } }, .{ .key = "task_completed", .sources = &.{ "terminal.ansiGreen", "editor.foreground", "foreground" } }, .{ .key = "tool_stdout", .sources = &.{ "terminal.foreground", "editor.foreground", "foreground" } }, .{ .key = "tool_stderr", .sources = &.{ "editorWarning.foreground", "terminal.foreground", "editor.foreground" } },};
const vscode_syntax_scopes = [_]struct { slot: []const u8, scope: []const u8 }{ .{ .slot = "keyword", .scope = "keyword" }, .{ .slot = "string", .scope = "string" }, .{ .slot = "number", .scope = "constant.numeric" }, .{ .slot = "comment", .scope = "comment" }, .{ .slot = "function", .scope = "entity.name.function" }, .{ .slot = "variable", .scope = "variable" }, .{ .slot = "operator", .scope = "keyword.operator" },};
fn vscodeColor(colors: ?std.json.ObjectMap, sources: []const []const u8, bg: Rgb) ?Rgb { const map = colors orelse return null; for (sources) |key| { if (map.get(key)) |value| { if (jsonString(value)) |s| { if (parseHexColor(s)) |parsed| return blendOver(parsed.rgb, parsed.alpha, bg); } } } return null;}
const ScopeMatch = struct { rgb: Rgb, bold: bool, italic: bool };
/// Finds the best TextMate scope match for one of fx's syntax slots. Exact/// scope beats a dotted prefix match; on a tie the later entry wins, matching/// VS Code's override order. Compound (space-separated) selectors are skipped.fn tokenColorMatch(token_colors: []const std.json.Value, want: []const u8, bg: Rgb) ?ScopeMatch { var best_score: u8 = 0; var best: ?ScopeMatch = null; for (token_colors) |entry| { if (entry != .object) continue; const settings = switch (entry.object.get("settings") orelse continue) { .object => |object| object, else => continue, }; const foreground = blk: { const value = settings.get("foreground") orelse break :blk null; const s = jsonString(value) orelse break :blk null; const parsed = parseHexColor(s) orelse break :blk null; break :blk blendOver(parsed.rgb, parsed.alpha, bg); } orelse continue;
var score: u8 = 0; switch (entry.object.get("scope") orelse continue) { .string => |s| score = scopeAlternativesScore(s, want), .array => |items| { for (items.items) |item| { const s = jsonString(item) orelse continue; score = @max(score, scopeAlternativesScore(s, want)); } }, else => continue, } if (score == 0 or score < best_score) continue;
var bold = false; var italic = false; if (settings.get("fontStyle")) |font_style| { if (jsonString(font_style)) |s| { bold = std.mem.find(u8, s, "bold") != null; italic = std.mem.find(u8, s, "italic") != null; } } best_score = score; best = .{ .rgb = foreground, .bold = bold, .italic = italic }; } return best;}
fn scopeAlternativesScore(scopes: []const u8, want: []const u8) u8 { var best: u8 = 0; var it = std.mem.splitScalar(u8, scopes, ','); while (it.next()) |part| { const alt = std.mem.trim(u8, part, " \t"); if (std.mem.findScalar(u8, alt, ' ') != null) continue; if (std.mem.eql(u8, alt, want)) { best = @max(best, 2); } else if (std.mem.startsWith(u8, alt, want) and alt.len > want.len and alt[want.len] == '.') { best = @max(best, 1); } } return best;}
fn parseVsCode(alloc: std.mem.Allocator, root: std.json.ObjectMap, options: ParseOptions) ParseError!Theme { const colors: ?std.json.ObjectMap = switch (root.get("colors") orelse .null) { .object => |object| object, else => null, };
const declared_bg = vscodeColor(colors, &.{"editor.background"}, .{ .r = 0, .g = 0, .b = 0 }); var light = false; if (root.get("type")) |type_value| { const type_string = jsonString(type_value) orelse return error.InvalidTheme; if (std.ascii.eqlIgnoreCase(type_string, "light") or std.ascii.eqlIgnoreCase(type_string, "hc-light")) { light = true; } else if (std.ascii.eqlIgnoreCase(type_string, "dark") or std.ascii.eqlIgnoreCase(type_string, "hc")) { light = false; } else { return error.InvalidTheme; } } else if (declared_bg) |bg| { const luminance = (@as(u32, bg.r) * 299 + @as(u32, bg.g) * 587 + @as(u32, bg.b) * 114) / 1000; light = luminance >= 128; }
// Alpha-bearing workbench colors composite over the editor background, // matching how the editor itself renders them. const blend_bg = declared_bg orelse if (light) Rgb{ .r = 0xff, .g = 0xff, .b = 0xff } else Rgb{ .r = 0, .g = 0, .b = 0 };
var theme = builtin(light); if (root.get("name")) |name_value| { if (jsonString(name_value)) |name| { if (name.len > 0) theme.name = try alloc.dupe(u8, name); } }
for (vscode_slot_map) |mapping| { const fg = vscodeColor(colors, mapping.sources, blend_bg); const bg = vscodeColor(colors, mapping.bg_sources, blend_bg); if (fg == null and bg == null) continue; const spec: SlotSpec = .{ .fg = fg, .bg = bg, .bold = mapping.bold }; try applySlot(&theme, alloc, mapping.key, spec, options); }
if (root.get("tokenColors")) |token_colors| { if (token_colors == .array) { inline for (vscode_syntax_scopes) |target| { if (tokenColorMatch(token_colors.array.items, target.scope, blend_bg)) |match| { const resolved = try slotEscapeChecked(alloc, .{ .fg = match.rgb, .bold = match.bold, .italic = match.italic }, options.truecolor); @field(theme.syntax, target.slot ++ "_style") = resolved; } } } } return theme;}
// --- Loading from ~/.fx/themes ---
pub const LoadError = error{ InvalidName, ThemeNotFound, InvalidTheme, OutOfMemory };
/// Returns the sibling variant name for the common `-dark` / `-light`/// (or `_dark` / `_light`) file naming convention, so a pinned theme can/// follow the terminal's detected mode: github-dark -> github-light./// Returns null when the name carries no recognizable variant suffix.pub fn siblingName(alloc: std.mem.Allocator, name: []const u8, want_light: bool) !?[]const u8 { const suffixes = [_][]const u8{ "-dark", "-light", "_dark", "_light" }; for (suffixes) |suffix| { if (name.len <= suffix.len) continue; const tail = name[name.len - suffix.len ..]; if (!std.ascii.eqlIgnoreCase(tail, suffix)) continue; const is_light_suffix = std.mem.find(u8, suffix, "light") != null; if (is_light_suffix == want_light) return null; const base = name[0 .. name.len - suffix.len]; // Preserve the suffix capitalization style: GitHub_Light -> GitHub_Dark. const replacement: []const u8 = if (std.ascii.isUpper(tail[1])) (if (want_light) "Light" else "Dark") else (if (want_light) "light" else "dark"); return try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ base, suffix[0..1], replacement }); } return null;}
/// Resolves the named theme for the terminal's detected mode: loads it, swaps/// to a sibling variant file (github-dark <-> github-light) when the variant/// mismatches, and returns null to signal the builtin variant when neither/// file fits. Used both at startup and on live terminal theme notifications.pub fn resolveNamed(alloc: std.mem.Allocator, name: []const u8, terminal_light: bool, options: ParseOptions) LoadError!?Theme { const theme = loadNamed(alloc, name, options) catch |err| { debug_trace.logf("theme", "custom_theme_load_failed name={s} err={s}", .{ name, @errorName(err) }); return null; }; if (theme.light == terminal_light) return theme; if (siblingName(alloc, name, terminal_light) catch null) |sibling| { if (loadNamed(alloc, sibling, options)) |swapped| { if (swapped.light == terminal_light) { debug_trace.logf("theme", "theme_variant_swapped from={s} to={s}", .{ name, sibling }); return swapped; } // A sibling whose declared variant also mismatches is a user file // error; fall through to the builtin rather than trusting it. debug_trace.logf("theme", "theme_sibling_variant_mismatch name={s}", .{sibling}); } else |err| { debug_trace.logf("theme", "theme_sibling_load_failed name={s} err={s}", .{ sibling, @errorName(err) }); } } debug_trace.logf("theme", "theme_variant_fallback name={s} terminal_light={s}", .{ name, if (terminal_light) "true" else "false" }); return null;}
/// Loads `~/.fx/themes/<name>.json` and resolves it for the terminal's color/// capability. The returned Theme is process-lifetime state allocated from/// `alloc`; the caller keeps the allocation alive.pub fn loadNamed(alloc: std.mem.Allocator, name: []const u8, options: ParseOptions) LoadError!Theme { if (name.len == 0 or name.len > 64) return error.InvalidName; for (name) |byte| { const ok = std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '_' or byte == '.'; if (!ok) return error.InvalidName; } if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidName;
const home = io_mod.getenv("HOME") orelse return error.ThemeNotFound; const dir_path = try std.fmt.allocPrint(alloc, "{s}/.fx/themes", .{home}); defer alloc.free(dir_path); var dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), dir_path, .{}) catch return error.ThemeNotFound; defer dir.close(io_mod.getIo());
const file_name = try std.fmt.allocPrint(alloc, "{s}.json", .{name}); defer alloc.free(file_name); var file = io_mod.openExistingRegularFile(dir, file_name, .read_only) catch return error.ThemeNotFound; defer file.close(io_mod.getIo()); const stat = file.stat(io_mod.getIo()) catch return error.ThemeNotFound; if (stat.size > max_theme_bytes) return error.InvalidTheme; const bytes = io_mod.readFileToEnd(alloc, &file, max_theme_bytes) catch return error.ThemeNotFound; defer alloc.free(bytes);
return parse(alloc, bytes, options);}
test "builtin selects the variant matching the light flag" { try std.testing.expectEqualStrings("fx-dark", builtin(false).name); try std.testing.expectEqualStrings("fx-light", builtin(true).name); try std.testing.expect(!builtin(false).light); try std.testing.expect(builtin(true).light);}
test "every theme slot is populated" { const fields = @typeInfo(Theme).@"struct".fields; inline for (fields) |field| { if (field.type == []const u8) { try std.testing.expect(@field(fx_dark, field.name).len > 0); try std.testing.expect(@field(fx_light, field.name).len > 0); } else if (field.type == SyntaxPalette) { const syntax_fields = @typeInfo(SyntaxPalette).@"struct".fields; inline for (syntax_fields) |syntax_field| { if (syntax_field.type != []const u8) continue; try std.testing.expect(@field(fx_dark.syntax, syntax_field.name).len > 0); try std.testing.expect(@field(fx_light.syntax, syntax_field.name).len > 0); } } }}
test "builtin themes pin the historical fx palette bytes" { // Dark defaults. try std.testing.expectEqualStrings("\x1b[38;5;240m", fx_dark.divider_style); try std.testing.expectEqualStrings("\x1b[38;5;255m", fx_dark.hint_style); try std.testing.expectEqualStrings("\x1b[38;5;245m", fx_dark.statusline_style); try std.testing.expectEqualStrings("\x1b[38;5;255m", fx_dark.user_card_marker_style); try std.testing.expectEqualStrings("\x1b[38;5;252m", fx_dark.user_card_accent_style); try std.testing.expectEqualStrings("\x1b[38;5;245m", fx_dark.inline_code_open); try std.testing.expectEqualStrings("\x1b[38;5;252m", fx_dark.task_completed_open); try std.testing.expectEqualStrings("\x1b[38;5;245m", fx_dark.tool_stdout_style); try std.testing.expectEqualStrings("\x1b[38;5;252m", fx_dark.tool_stderr_style); try std.testing.expectEqualStrings("\x1b[38;5;252m", fx_dark.syntax.keyword_style); try std.testing.expectEqualStrings("\x1b[38;5;245m", fx_dark.syntax.comment_style);
// Light defaults. try std.testing.expectEqualStrings("\x1b[38;5;250m", fx_light.divider_style); try std.testing.expectEqualStrings("\x1b[38;5;235m", fx_light.hint_style); try std.testing.expectEqualStrings("\x1b[38;5;241m", fx_light.statusline_style); try std.testing.expectEqualStrings("\x1b[38;5;235m", fx_light.user_card_marker_style); try std.testing.expectEqualStrings("\x1b[38;5;238m", fx_light.user_card_accent_style); try std.testing.expectEqualStrings("\x1b[38;5;247m", fx_light.inline_code_open); try std.testing.expectEqualStrings("\x1b[38;5;238m", fx_light.task_completed_open); // Tool text stays the pre-theme gray in both variants (parity guard). try std.testing.expectEqualStrings("\x1b[38;5;245m", fx_light.tool_stdout_style); try std.testing.expectEqualStrings("\x1b[38;5;252m", fx_light.tool_stderr_style); try std.testing.expectEqualStrings("\x1b[38;5;238m", fx_light.syntax.keyword_style); try std.testing.expectEqualStrings("\x1b[38;5;243m", fx_light.syntax.comment_style);
// Diff markers read the same on light and dark in both capability modes. try std.testing.expectEqualStrings("\x1b[38;2;48;164;108m", fx_dark.diff_added_marker_truecolor); try std.testing.expectEqualStrings("\x1b[38;2;229;72;77m", fx_dark.diff_removed_marker_truecolor); try std.testing.expectEqualStrings("\x1b[38;5;71m", fx_dark.diff_added_marker_fallback); try std.testing.expectEqualStrings("\x1b[38;5;167m", fx_dark.diff_removed_marker_fallback); try std.testing.expectEqualStrings(fx_dark.diff_added_marker_truecolor, fx_light.diff_added_marker_truecolor); try std.testing.expectEqualStrings(fx_dark.diff_removed_marker_truecolor, fx_light.diff_removed_marker_truecolor); try std.testing.expectEqualStrings(fx_dark.diff_added_marker_fallback, fx_light.diff_added_marker_fallback); try std.testing.expectEqualStrings(fx_dark.diff_removed_marker_fallback, fx_light.diff_removed_marker_fallback);}
test "activate switches the current theme seen by core producers" { const previous = current(); defer activate(previous); activate(fx_light); try std.testing.expectEqualStrings("fx-light", current().name); activate(fx_dark); try std.testing.expectEqualStrings("fx-dark", current().name);}
test "parseHexColor accepts the hex forms VS Code themes use" { const short = parseHexColor("#fff").?; try std.testing.expectEqual(@as(u8, 0xff), short.rgb.r); try std.testing.expectEqual(@as(u8, 0xff), short.alpha);
const full = parseHexColor("#82D2CE").?; try std.testing.expectEqual(@as(u8, 0x82), full.rgb.r); try std.testing.expectEqual(@as(u8, 0xd2), full.rgb.g); try std.testing.expectEqual(@as(u8, 0xce), full.rgb.b);
const alpha = parseHexColor("#F0F0F026").?; try std.testing.expectEqual(@as(u8, 0x26), alpha.alpha);
try std.testing.expect(parseHexColor("fff") == null); try std.testing.expect(parseHexColor("#ff") == null); try std.testing.expect(parseHexColor("#fffff") == null); try std.testing.expect(parseHexColor("#gggggg") == null); try std.testing.expect(parseHexColor("") == null);}
test "blendOver composites translucent colors onto a background" { const bg = Rgb{ .r = 0x18, .g = 0x18, .b = 0x18 }; const solid = blendOver(.{ .r = 0xf0, .g = 0xf0, .b = 0xf0 }, 0xff, bg); try std.testing.expectEqual(@as(u8, 0xf0), solid.r);
// 0x26/0xff of #F0F0F0 over #181818. const faint = blendOver(.{ .r = 0xf0, .g = 0xf0, .b = 0xf0 }, 0x26, bg); try std.testing.expect(faint.r > bg.r and faint.r < 0x40); try std.testing.expectEqual(faint.r, faint.g); try std.testing.expectEqual(faint.g, faint.b);}
test "rgbToAnsi256 lands on the expected palette indexes" { try std.testing.expectEqual(@as(u8, 16), rgbToAnsi256(0, 0, 0)); try std.testing.expectEqual(@as(u8, 231), rgbToAnsi256(255, 255, 255)); try std.testing.expectEqual(@as(u8, 196), rgbToAnsi256(255, 0, 0)); try std.testing.expectEqual(@as(u8, 46), rgbToAnsi256(0, 255, 0)); try std.testing.expectEqual(@as(u8, 21), rgbToAnsi256(0, 0, 255)); try std.testing.expectEqual(@as(u8, 244), rgbToAnsi256(0x80, 0x80, 0x80));}
test "parse resolves a native theme overlay on the matching builtin" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator();
const doc = \\{ \\ "name": "test-native", \\ "colors": { \\ "divider": "#ff0000", \\ "approval_button_active": { "fg": "#191c22", "bg": "#81a1c1", "bold": true }, \\ "diff_added_marker": "#30a46c" \\ }, \\ "syntax": { "keyword": "#82d2ce", "comment": { "fg": "#6a9955", "italic": true } } \\} ; const theme = try parse(alloc, doc, .{ .truecolor = true }); try std.testing.expectEqualStrings("test-native", theme.name); try std.testing.expect(!theme.light); try std.testing.expectEqualStrings("\x1b[38;2;255;0;0m", theme.divider_style); try std.testing.expectEqualStrings("\x1b[1;38;2;25;28;34;48;2;129;161;193m", theme.approval_button_active_style); try std.testing.expectEqualStrings("\x1b[38;2;48;164;108m", theme.diff_added_marker_truecolor); try std.testing.expectEqualStrings(fx_dark.diff_removed_marker_truecolor, theme.diff_removed_marker_truecolor); try std.testing.expectEqualStrings("\x1b[38;2;130;210;206m", theme.syntax.keyword_style); try std.testing.expectEqualStrings("\x1b[3;38;2;106;153;85m", theme.syntax.comment_style); // Untouched slots inherit the builtin variant. try std.testing.expectEqualStrings(fx_dark.hint_style, theme.hint_style); try std.testing.expectEqualStrings(fx_dark.syntax.string_style, theme.syntax.string_style);}
test "parse honors a native syntax boolean switch" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator();
const disabled = try parse(alloc, "{ \"name\": \"no-syntax\", \"syntax\": false }", .{ .truecolor = true }); try std.testing.expect(!disabled.syntax.enabled); // Style slots keep the builtin defaults; the flag alone gates highlighting. try std.testing.expectEqualStrings(fx_dark.syntax.keyword_style, disabled.syntax.keyword_style);
const enabled = try parse(alloc, "{ \"name\": \"yes-syntax\", \"syntax\": true }", .{ .truecolor = true }); try std.testing.expect(enabled.syntax.enabled);
const object = try parse(alloc, "{ \"name\": \"obj-syntax\", \"syntax\": { \"keyword\": \"#ff0000\" } }", .{ .truecolor = true }); try std.testing.expect(object.syntax.enabled);
try std.testing.expectError(error.InvalidTheme, parse(alloc, "{ \"syntax\": 3 }", .{ .truecolor = true }));}
test "parse quantizes native themes for 256-color terminals" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator();
const theme = try parse(alloc, "{ \"colors\": { \"divider\": \"#ff0000\", \"diff_added_marker\": \"#30a46c\" } }", .{ .truecolor = false }); try std.testing.expectEqualStrings("\x1b[38;5;196m", theme.divider_style); try std.testing.expectEqualStrings("\x1b[38;2;48;164;108m", theme.diff_added_marker_truecolor); const fallback_n = rgbToAnsi256(0x30, 0xa4, 0x6c); try std.testing.expectEqualStrings(try std.fmt.allocPrint(alloc, "\x1b[38;5;{d}m", .{fallback_n}), theme.diff_added_marker_fallback);}
test "parse resolves a VS Code theme through the adapter" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator();
const doc = \\{ \\ "name": "Test Dark", \\ "colors": { \\ "editor.background": "#181818", \\ "editor.foreground": "#F0F0F0", \\ "statusBar.foreground": "#F0F0F099", \\ "editorLineNumber.foreground": "#F0F0F05C", \\ "editorWarning.foreground": "#F1B467", \\ "terminal.ansiGreen": "#3FA266", \\ "terminal.ansiRed": "#FC6B83", \\ "button.background": "#81A1C1", \\ "button.foreground": "#191c22" \\ }, \\ "tokenColors": [ \\ { "scope": "keyword.operator", "settings": { "foreground": "#111111" } }, \\ { "scope": "keyword", "settings": { "foreground": "#82D2CE" } }, \\ { "scope": ["string", "markup"], "settings": { "foreground": "#A8CC7C" } }, \\ { "scope": "comment markup.link", "settings": { "foreground": "#000000" } }, \\ { "scope": "comment", "settings": { "foreground": "#6A9955", "fontStyle": "italic" } } \\ ] \\} ; const theme = try parse(alloc, doc, .{ .truecolor = true }); try std.testing.expectEqualStrings("Test Dark", theme.name); try std.testing.expect(!theme.light); // inferred from editor.background, no type field try std.testing.expectEqualStrings("\x1b[38;2;240;240;240m", theme.hint_style); // Alpha colors blend over editor.background before resolution. const status_expected = blendOver(.{ .r = 0xf0, .g = 0xf0, .b = 0xf0 }, 0x99, .{ .r = 0x18, .g = 0x18, .b = 0x18 }); try std.testing.expectEqualStrings(try std.fmt.allocPrint(alloc, "\x1b[38;2;{d};{d};{d}m", .{ status_expected.r, status_expected.g, status_expected.b }), theme.statusline_style); try std.testing.expectEqualStrings("\x1b[38;2;241;180;103m", theme.warning_style); try std.testing.expectEqualStrings("\x1b[38;2;63;162;102m", theme.green_style); try std.testing.expectEqualStrings("\x1b[38;2;252;107;131m", theme.red_style); try std.testing.expectEqualStrings("\x1b[1;38;2;25;28;34;48;2;129;161;193m", theme.approval_button_active_style); // Exact scope beats prefix; later entries override at equal specificity; // compound selectors never fill a base slot. try std.testing.expectEqualStrings("\x1b[38;2;130;210;206m", theme.syntax.keyword_style); try std.testing.expectEqualStrings("\x1b[38;2;168;204;124m", theme.syntax.string_style); try std.testing.expectEqualStrings("\x1b[3;38;2;106;153;85m", theme.syntax.comment_style); // Unmapped scopes and slots inherit the builtin variant. try std.testing.expectEqualStrings(fx_dark.syntax.number_style, theme.syntax.number_style);}
test "parse honors an explicit VS Code type field over background luminance" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator(); const theme = try parse(alloc, "{ \"type\": \"light\", \"colors\": { \"editor.background\": \"#181818\" } }", .{ .truecolor = true }); try std.testing.expect(theme.light); try std.testing.expectEqualStrings(fx_light.dim_style[0..4], theme.dim_style[0..4]);}
test "parse rejects malformed themes" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator(); try std.testing.expectError(error.InvalidTheme, parse(alloc, "not json", .{})); try std.testing.expectError(error.InvalidTheme, parse(alloc, "[]", .{})); try std.testing.expectError(error.InvalidTheme, parse(alloc, "{ \"type\": \"solarized\" }", .{})); try std.testing.expectError(error.InvalidTheme, parse(alloc, "{ \"colors\": { \"divider\": \"red\" } }", .{})); try std.testing.expectError(error.InvalidTheme, parse(alloc, "{ \"colors\": { \"divider\": { \"bold\": true } } }", .{}));}
test "loadNamed validates the theme name before touching disk" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator(); try std.testing.expectError(error.InvalidName, loadNamed(alloc, "", .{})); try std.testing.expectError(error.InvalidName, loadNamed(alloc, "../escape", .{})); try std.testing.expectError(error.InvalidName, loadNamed(alloc, "a/b", .{})); try std.testing.expectError(error.InvalidName, loadNamed(alloc, "..", .{})); try std.testing.expectError(error.ThemeNotFound, loadNamed(alloc, "fx-theme-that-does-not-exist-9z9z", .{}));}
test "siblingName maps variant suffixes for terminal-mode swaps" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator();
try std.testing.expectEqualStrings("cursor-light", (try siblingName(alloc, "cursor-dark", true)).?); try std.testing.expectEqualStrings("cursor-dark", (try siblingName(alloc, "cursor-light", false)).?); try std.testing.expectEqualStrings("GitHub_Dark", (try siblingName(alloc, "GitHub_Light", false)).?); // Already on the wanted variant, or no recognizable suffix. try std.testing.expect((try siblingName(alloc, "cursor-dark", false)) == null); try std.testing.expect((try siblingName(alloc, "monokai", true)) == null); try std.testing.expect((try siblingName(alloc, "-dark", true)) == null);}
test "sgrHasParam parses the parameter list exactly" { try std.testing.expect(sgrHasParam("\x1b[38;5;245m", "38")); try std.testing.expect(sgrHasParam("\x1b[1;38;2;1;2;3m", "38")); try std.testing.expect(sgrHasParam("\x1b[1;38;2;1;2;3m", "1")); try std.testing.expect(sgrHasParam("\x1b[3;48;5;240m", "48")); try std.testing.expect(sgrHasParam("\x1b[3;48;5;240m", "3")); // No prefix or substring confusion, and RGB triples are not attributes. try std.testing.expect(!sgrHasParam("\x1b[3;38;2;1;2;3m", "1")); try std.testing.expect(!sgrHasParam("\x1b[38;2;1;2;3m", "3")); try std.testing.expect(!sgrHasParam("\x1b[38;5;245m", "3")); try std.testing.expect(!sgrHasParam("\x1b[38;5;245m", "8")); try std.testing.expect(!sgrHasParam("\x1b[38;5;245m", "48")); try std.testing.expect(!sgrHasParam("\x1b[39m", "38")); try std.testing.expect(!sgrHasParam("plain", "38")); try std.testing.expect(!sgrHasParam("\x1b[38;5;245", "38"));}
test "closingFor resets exactly what the open set" { try std.testing.expectEqualStrings("\x1b[39m", closingFor(fx_dark.hint_style)); try std.testing.expectEqualStrings("\x1b[39m", closingFor(fx_dark.inline_code_open)); try std.testing.expectEqualStrings("\x1b[39m\x1b[22m", closingFor(fx_dark.tag_style)); try std.testing.expectEqualStrings("\x1b[39m\x1b[23m", closingFor("\x1b[3;38;2;1;2;3m")); try std.testing.expectEqualStrings("\x1b[39m\x1b[22m\x1b[23m", closingFor("\x1b[1;3;38;2;1;2;3m")); try std.testing.expectEqualStrings("\x1b[39m\x1b[49m\x1b[22m", closingFor(fx_dark.approval_button_active_style)); try std.testing.expectEqualStrings("\x1b[39m\x1b[49m", closingFor(fx_dark.approval_button_inactive_style));}
test "classifyValue maps configured theme values" { try std.testing.expect(classifyValue("") == null); try std.testing.expect(classifyValue("light").? == .pin_light); try std.testing.expect(classifyValue("Dark").? == .pin_dark); try std.testing.expectEqualStrings("cursor-dark", classifyValue("cursor-dark").?.custom);}
test "theme source copies the configured name and pin for live re-resolution" { defer setSource(null, false); try std.testing.expect(sourceName() == null); try std.testing.expect(!variantPinned());
// The donor buffer may be freed right after setSource; the source state // must not dangle (startup state is deinited before the event loop). const donated = try std.testing.allocator.dupe(u8, "cursor-dark"); setSource(donated, false); std.testing.allocator.free(donated); try std.testing.expectEqualStrings("cursor-dark", sourceName().?); try std.testing.expect(!variantPinned());
setSource(null, true); try std.testing.expect(sourceName() == null); try std.testing.expect(variantPinned());
const too_long = "x" ** 65; setSource(too_long, false); try std.testing.expect(sourceName() == null);}
test "parse maps the link color and split syntax scopes" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const alloc = arena.allocator();
const vscode_doc = \\{ \\ "type": "dark", \\ "colors": { "textLink.foreground": "#58A6FF" }, \\ "tokenColors": [ \\ { "scope": "keyword", "settings": { "foreground": "#82D2CE" } }, \\ { "scope": "entity.name.function", "settings": { "foreground": "#D2A8FF" } }, \\ { "scope": "variable.other", "settings": { "foreground": "#FFA657" } }, \\ { "scope": "keyword.operator", "settings": { "foreground": "#FF7B72" } } \\ ] \\} ; const vscode = try parse(alloc, vscode_doc, .{ .truecolor = true }); try std.testing.expectEqualStrings("\x1b[38;2;88;166;255m", vscode.link_style); try std.testing.expectEqualStrings("\x1b[38;2;210;168;255m", vscode.syntax.function_style); try std.testing.expectEqualStrings("\x1b[38;2;255;166;87m", vscode.syntax.variable_style); try std.testing.expectEqualStrings("\x1b[38;2;255;123;114m", vscode.syntax.operator_style); // Keyword keeps its own scope, not keyword.operator's. try std.testing.expectEqualStrings("\x1b[38;2;130;210;206m", vscode.syntax.keyword_style);
const native = try parse(alloc, "{ \"colors\": { \"link\": \"#58A6FF\" }, \"syntax\": { \"function\": \"#D2A8FF\", \"variable\": \"#FFA657\", \"operator\": \"#FF7B72\" } }", .{ .truecolor = true }); try std.testing.expectEqualStrings("\x1b[38;2;88;166;255m", native.link_style); try std.testing.expectEqualStrings("\x1b[38;2;210;168;255m", native.syntax.function_style); try std.testing.expectEqualStrings("\x1b[38;2;255;166;87m", native.syntax.variable_style); try std.testing.expectEqualStrings("\x1b[38;2;255;123;114m", native.syntax.operator_style);
// A VS Code theme without textLink keeps the builtin link color. const no_link = try parse(alloc, "{ \"type\": \"dark\", \"colors\": {} }", .{ .truecolor = true }); try std.testing.expectEqualStrings(fx_dark.link_style, no_link.link_style);}
test "builtin themes pin the new slot bytes" { try std.testing.expectEqualStrings("\x1b[38;5;75m", fx_dark.link_style); try std.testing.expectEqualStrings("\x1b[38;5;25m", fx_light.link_style); // The split slots default to the keyword color so default rendering is // byte-identical to before the split. for ([_][]const u8{ fx_dark.syntax.function_style, fx_dark.syntax.variable_style, fx_dark.syntax.operator_style }) |style| { try std.testing.expectEqualStrings(fx_dark.syntax.keyword_style, style); } for ([_][]const u8{ fx_light.syntax.function_style, fx_light.syntax.variable_style, fx_light.syntax.operator_style }) |style| { try std.testing.expectEqualStrings(fx_light.syntax.keyword_style, style); }}