//! Build-time generator: a vendored helix or zed theme in, one `.zig` file per //! theme out. build.zig runs it before anything compiles and hands the output //! directory to src/pardes.zig as the `generated_themes` module, so a theme is //! an ordinary source file by the time the compiler sees it. //! //! ADDING A THEME IS DROPPING A FILE INTO `vendor/themes/`: a helix `.toml` or //! a zed `.json`. The extension picks the reader and records where it came //! from; build.zig reads the directory, so there is no list anywhere to append //! to. A zed file holds a whole FAMILY (one.json is One Dark and One Light), so //! one input can be several themes out. //! //! Why vendored and not read out of ~/05-genizah: `zig build` must work on a //! fresh clone with no network and no helix checkout. It is the WHOLE of what //! both projects ship — helix's 214 runtime themes and zed's three families, //! 1.5MB of text — because "which ones are worth having" is not a question a //! build tool gets to answer on the user's behalf. //! //! NAMES. A helix theme is its file stem; a zed variant is its `name` slugged //! with `_zed` on the end. Both projects ship gruvbox, ayu and one, so the //! collision is systematic rather than accidental and qualifying every zed //! variant is one rule instead of eight exceptions — it also keeps a name from //! moving when the other source gains or loses a file. helix's own `acme.toml` //! is vendored as `acme_helix.toml` for the same reason, since pardes ships an //! acme of its own; the file is otherwise untouched. Two themes answering to //! one name is a bug (`Theme ` would silently pick one), so src/pardes.zig //! asserts the ring's names are distinct at comptime. //! //! Provenance: vendor/themes/*.toml are helix's own runtime themes (from //! helix-editor/helix runtime/themes, each file keeping its author's header); //! *.json are zed's (zed-industries/zed assets/themes). The licences sit beside //! them as `.LICENSE` — `helix.LICENSE` covering the runtime set, one per //! zed family, plus the twenty-two helix keeps in `runtime/themes/licenses/` //! for the themes it took from somewhere else (their names group by SOURCE, not //! by file: `everforest.LICENSE` covers both everforest themes). build.zig's //! directory read skips anything that is not a .toml or a .json, which is what //! lets them all sit in the same flat directory as the themes they cover. //! //! SPARSE vs BROKEN. At nine hand-picked files every missing key was a mistake, //! so every missing key was fatal. At 225 that is wrong: helix themes legally //! leave things unstyled, and helix's own rule for an unstyled scope is "the //! page's colors", ending at "whatever the terminal is already wearing". So the //! fallbacks below are helix's semantics, not a papering-over — see `helixTheme` //! for the field-by-field list. What stays FATAL, naming the file and the key, //! is input this reader cannot read at all: a colour that is neither #rrggbb nor //! a palette entry nor one of the sixteen names, an `inherits` whose parent is //! not vendored, a zed variant missing a key zed always writes. const std = @import("std"); const Rgb = [3]u8; /// pardes's Theme, minus the type: the generated file spells an anonymous /// `.{...}` because a theme file deliberately imports NOTHING (it is data, not /// code) — src/pardes.zig's `fold` is what turns it into a real Theme. const Theme = struct { name: []const u8, bg: ?Rgb, fg: ?Rgb, sel_bg: Rgb, sel_fg: Rgb, tag_bg: Rgb, tag_fg: Rgb, box: Rgb, box_dim: Rgb, kw: Rgb, str: Rgb, num: Rgb, comment: Rgb, lineno: Rgb, scroll_track: Rgb, scroll_thumb: Rgb, palette: ?[16]Rgb, }; pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); if (args.len < 3) std.process.fatal("usage: gen-themes ...", .{}); const sources = args[2..]; const texts = try arena.alloc([]const u8, sources.len); var helix_count: usize = 0; var theme_count: usize = 0; for (sources, texts) |src, *text| { text.* = try std.Io.Dir.cwd().readFileAlloc(io, src, arena, .limited(8 << 20)); if (std.mem.endsWith(u8, src, ".toml")) { helix_count += 1; theme_count += 1; } else if (std.mem.endsWith(u8, src, ".json")) { theme_count += try zedThemeCount(gpa, src, text.*); } else { std.process.fatal("{s}: not a helix .toml or a zed .json", .{src}); } } const themes = try arena.alloc(Theme, theme_count); const stems = try arena.alloc([]const u8, helix_count); // TWO passes over the helix half, because `inherits = "other"` names a // SIBLING SOURCE by stem: nothing can be mapped until every file is read, // and the parent may be later in argv (or inherit something itself). var tomls: std.StringHashMapUnmanaged(Toml) = .empty; var theme_index: usize = 0; var stem_index: usize = 0; for (sources, texts) |src, text| { if (std.mem.endsWith(u8, src, ".toml")) { const stem = std.fs.path.stem(src); try tomls.put(arena, stem, parseToml(arena, src, text)); stems[stem_index] = stem; stem_index += 1; } else { theme_index += try readZed(arena, gpa, src, text, themes[theme_index..]); } } std.debug.assert(stem_index == stems.len); for (stems) |stem| { themes[theme_index] = try helixTheme(arena, stem, resolve(arena, &tomls, stem, 8)); theme_index += 1; } std.debug.assert(theme_index == themes.len); // sorted, so the ring's tail is the same on every machine whatever order // the directory happened to be read in std.mem.sort(Theme, themes, {}, byName); var dir = try std.Io.Dir.cwd().openDir(io, args[1], .{}); defer dir.close(io); var buf: std.Io.Writer.Allocating = .init(arena); for (themes) |t| { buf.clearRetainingCapacity(); try emit(&buf.writer, t); try write(io, dir, arena, t.name, buf.written()); } // the INDEX, and the only reason it is generated rather than written: a zig // file cannot list a directory at comptime, so the imports have to come // from whoever can. pardes.zig folds its declarations into the ring. buf.clearRetainingCapacity(); try buf.writer.writeAll("// Generated by tools/gen_themes.zig. Do not edit.\n"); for (themes) |t| try buf.writer.print("pub const {s} = @import(\"{s}.zig\");\n", .{ t.name, t.name }); try write(io, dir, arena, "list", buf.written()); } fn write(io: std.Io, dir: std.Io.Dir, arena: std.mem.Allocator, stem: []const u8, text: []const u8) !void { const file = try dir.createFile(io, try std.fmt.allocPrint(arena, "{s}.zig", .{stem}), .{}); defer file.close(io); try file.writeStreamingAll(io, text); } fn byName(_: void, a: Theme, b: Theme) bool { return std.mem.order(u8, a.name, b.name) == .lt; } fn emit(w: *std.Io.Writer, t: Theme) !void { try w.writeAll("// Generated by tools/gen_themes.zig. Do not edit.\npub const theme = .{\n"); try w.print(" .name = \"{s}\",\n", .{t.name}); inline for (@typeInfo(Theme).@"struct".fields) |f| { if (comptime std.mem.eql(u8, f.name, "name")) continue; if (comptime std.mem.eql(u8, f.name, "palette")) continue; try w.print(" .{s} = ", .{f.name}); if (comptime @typeInfo(f.type) == .optional) { if (@field(t, f.name)) |c| try rgb(w, c) else try w.writeAll("null"); } else try rgb(w, @field(t, f.name)); try w.writeAll(",\n"); } if (t.palette) |p| { try w.writeAll(" .palette = .{\n"); for (p) |c| { try w.writeAll(" "); try rgb(w, c); try w.writeAll(",\n"); } try w.writeAll(" },\n"); } else try w.writeAll(" .palette = null,\n"); try w.writeAll("};\n"); } fn rgb(w: *std.Io.Writer, c: Rgb) !void { try w.print(".{{ 0x{x:0>2}, 0x{x:0>2}, 0x{x:0>2} }}", .{ c[0], c[1], c[2] }); } /// `Ayu Mirage` -> `ayu_mirage`: the user-visible theme NAME, the generated /// file's stem and its declaration in the index, all one string. It has to be a /// single word (a `Theme ` row is executed by splitting on the first /// space) and a legal identifier (it is a decl in list.zig). fn slug(arena: std.mem.Allocator, raw: []const u8) ![]const u8 { const out = try arena.alloc(u8, raw.len); var len: usize = 0; for (raw) |c| { if (std.ascii.isAlphanumeric(c)) { out[len] = std.ascii.toLower(c); len += 1; } else if (len > 0 and out[len - 1] != '_') { // one underscore per run of punctuation; it is trimmed below when // the run ends the name out[len] = '_'; len += 1; } } const s = std.mem.trim(u8, out[0..len], "_"); if (s.len == 0 or !std.ascii.isAlphabetic(s[0])) std.process.fatal("theme name {s} does not slug to an identifier", .{raw}); return s; } // ---- helix (.toml) ---- // Not a TOML parser: helix themes are one `key = value` per line, where a value // is either a bare color string or an inline table, plus a `[palette]` section // of name -> color. A real parser would be a dependency and several hundred // lines to read a shape this flat. /// what a key was assigned, unresolved — `fg`/`bg` name a palette entry or a /// literal `#rrggbb`, and helix's bare form (`"ui.text" = "nord4"`) is an fg const Pair = struct { fg: ?[]const u8 = null, bg: ?[]const u8 = null }; /// one .toml as written, before `inherits` is applied const Toml = struct { path: []const u8, inherits: ?[]const u8, keys: std.StringHashMapUnmanaged(Pair), palette: std.StringHashMapUnmanaged([]const u8), }; fn parseToml(arena: std.mem.Allocator, path: []const u8, text: []const u8) Toml { var palette: std.StringHashMapUnmanaged([]const u8) = .empty; var keys: std.StringHashMapUnmanaged(Pair) = .empty; var inherits: ?[]const u8 = null; var in_palette = false; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, uncomment(raw), " \t\r"); if (line.len == 0) continue; if (line[0] == '[') { in_palette = std.mem.eql(u8, line, "[palette]"); continue; } const eq = topLevel(line, '=') orelse continue; const key = unquote(std.mem.trim(u8, line[0..eq], " \t")); const val = std.mem.trim(u8, line[eq + 1 ..], " \t"); if (val.len == 0) continue; if (in_palette) { palette.put(arena, key, unquote(val)) catch @panic("OOM"); } else if (std.mem.eql(u8, key, "inherits")) { inherits = unquote(val); } else if (quoted(val)) { keys.put(arena, key, .{ .fg = unquote(val) }) catch @panic("OOM"); } else if (val[0] == '{') { keys.put(arena, key, table(val)) catch @panic("OOM"); } } return .{ .path = path, .inherits = inherits, .keys = keys, .palette = palette }; } /// `inherits = "other"` — a THIRD of helix's themes are a handful of overrides /// on top of a sibling, so refusing them would cost 76 of the 214. /// /// The merge is WHOLE-KEY, not field-by-field: helix calls merge_toml_values at /// depth ONE for the theme (helix-view theme.rs merge_themes), so the recursion /// stops before it reaches inside an inline table and the child's value replaces /// the parent's outright. That is load-bearing rather than pedantic — the three /// inheriting `*_transparent` themes clear their parent's background by writing /// `"ui.background" = {}`, which a field-wise merge would leave painted. The /// [palette] is merged separately at depth two, i.e. entry by entry, which is /// what lets a child's key name a colour only its parent defines. /// /// `depth` bounds the walk: a chain that long is a cycle, and a build tool /// should say so rather than recurse until the stack runs out. No memoisation — /// the deepest chain in helix's set is three and the widest parent has four /// children, so the whole thing is a few hundred hash-map copies. fn resolve(arena: std.mem.Allocator, tomls: *const std.StringHashMapUnmanaged(Toml), stem: []const u8, depth: usize) Helix { const t = tomls.get(stem) orelse std.process.fatal("inherits = \"{s}\" names a theme that is not vendored", .{stem}); const parent = t.inherits orelse return .{ .path = t.path, .keys = t.keys, .palette = t.palette }; if (depth == 0) std.process.fatal("{s}: inherits= chain is a cycle or deeper than eight", .{t.path}); const base = resolve(arena, tomls, parent, depth - 1); // CLONED, not mutated: the parent's own maps are the ones in `tomls`, and // four themes inherit from github_dark alone var keys = base.keys.clone(arena) catch @panic("OOM"); var palette = base.palette.clone(arena) catch @panic("OOM"); var kit = t.keys.iterator(); while (kit.next()) |e| keys.put(arena, e.key_ptr.*, e.value_ptr.*) catch @panic("OOM"); var pit = t.palette.iterator(); while (pit.next()) |e| palette.put(arena, e.key_ptr.*, e.value_ptr.*) catch @panic("OOM"); return .{ .path = t.path, .keys = keys, .palette = palette }; } /// A resolved helix theme mapped onto pardes's fourteen fields. /// /// EVERY FALLBACK HERE IS HELIX'S OWN RULE, which is why none of them is fatal: /// an unstyled scope wears `ui.text`, an unpainted cell wears `ui.background`, /// and a theme that styles neither is saying "wear whatever the terminal is /// already wearing" (the base16/term16/transparent themes exist precisely to do /// that). pardes spells that last step as a null bg/fg, so those two pass /// straight through — but the other twelve fields are RGB and cannot be null, so /// they need a concrete stand-in for "the terminal's", and that is the default /// palette's ink and page below. Field by field: /// /// bg ui.background.bg, else null = the host terminal's own cell /// fg ui.text.fg, else null = ditto /// box ui.cursor.primary.bg, else ui.cursor.bg, else keyword.fg, /// else ink — skipping any of them that IS the page (see /// firstNotPage) /// box_dim, /// scroll_thumb, /// sel_bg ui.selection.bg, else ui.selection.primary.bg, else /// ui.selection.fg, same page-skipping, else page. No selection /// colour anywhere means the author drew no selection either, /// and a dim box the colour of the page is the honest rendering /// of that — no theme in the vendored set actually gets there /// sel_fg ui.selection.fg, unless that IS sel_bg, else whichever of /// ink and page the highlight is farther from /// tag_bg, /// scroll_track ui.statusline.bg, else page /// tag_fg ui.statusline.fg, else ink /// kw/str/comment their scope's fg, else ink (helix: unstyled = ui.text) /// num constant.numeric.fg, else constant.fg, else ink /// lineno ui.linenr.fg, else ink fn helixTheme(arena: std.mem.Allocator, stem: []const u8, h: Helix) !Theme { const fg = h.get("ui.text", .fg); const bg = h.get("ui.background", .bg); const ink = fg orelse ansi_default[7]; const page = bg orelse ansi_default[0]; // The move box is the accent that says "here". helix's is the primary // cursor's background — but a theme that draws its cursor by REVERSING // video (nord, monokai) never names one, so the fallback is the loudest // hue the theme did commit to. const box = h.firstNotPage(page, &.{ .{ "ui.cursor.primary", .bg }, .{ "ui.cursor", .bg }, .{ "keyword", .fg } }) orelse ink; // ...and the dim box is the selection, which the same themes draw the same // reversed way, so it is read the same way: the primary selection is what // helix highlights the range you are ON with, and a theme that names only a // foreground for it (peachpuff, base16_terminal) is naming the colour the // reverse will land in the background. const sel = h.firstNotPage(page, &.{ .{ "ui.selection", .bg }, .{ "ui.selection.primary", .bg }, .{ "ui.selection", .fg } }) orelse page; // ...and the same colour is what pardes paints a SELECTION with, which needs // an ink over it as well. Most themes name none — helix tints the cell and // lets the syntax colour ride — so the stand-in is whichever of the theme's // own two extremes the highlight is farther from, in the Rec.601-ish weights // the move box's glyph is picked with, left unscaled because these three are // only ever compared with each other. Not a fixed threshold like that one: // a quarter of the ring is light, and there "the bright end" is the page. // // ponytail: two of the theme's own colours, whichever is farther. A theme // that styles neither ui.text nor a selection foreground on a LIGHT page // has no dark colour among the two — its ink stand-in is the default // palette's index 7, a grey meant for dark terminals — and comes out pale // on pale. One of the 228 (iceberg_light, which inherits its selection from // the dark variant it repaints). Upgrade path is the move box's other half: // fall back to flat black or white when neither extreme separates. const sel_lum = @as(i32, sel[0]) * 3 + @as(i32, sel[1]) * 6 + sel[2]; const ink_lum = @as(i32, ink[0]) * 3 + @as(i32, ink[1]) * 6 + ink[2]; const page_lum = @as(i32, page[0]) * 3 + @as(i32, page[1]) * 6 + page[2]; const sel_far = if (@abs(sel_lum - ink_lum) >= @abs(sel_lum - page_lum)) ink else page; // A theme that DID name a selection foreground meant it — 16 of the // vendored ones do — except when it is the colour taken for the background // one line up, which is the reverse-video case again: those two would paint // the highlight in one colour on itself. const named_ink = h.get("ui.selection", .fg); return .{ .name = try slug(arena, stem), // `palette` stays null: helix leaves the colors a PROGRAM emits alone, // and so do we. .bg = bg, .fg = fg, .sel_bg = sel, .sel_fg = if (named_ink) |c| (if (std.mem.eql(u8, &c, &sel)) sel_far else c) else sel_far, .tag_bg = h.get("ui.statusline", .bg) orelse page, .tag_fg = h.get("ui.statusline", .fg) orelse ink, .box = box, .box_dim = sel, .kw = h.get("keyword", .fg) orelse ink, .str = h.get("string", .fg) orelse ink, // most themes only colour `constant`; the ones that split out numbers // are being more specific, so they win .num = h.first(&.{ .{ "constant.numeric", .fg }, .{ "constant", .fg } }) orelse ink, .comment = h.get("comment", .fg) orelse ink, .lineno = h.get("ui.linenr", .fg) orelse ink, // the scrollbar is chrome, so it takes the chrome colors: the tag bar's // own background for the track, the selection (one step brighter than // anything else in the theme's greys) for the thumb riding it .scroll_track = h.get("ui.statusline", .bg) orelse page, .scroll_thumb = sel, .palette = null, }; } /// helix lets a theme name one of the terminal's sixteen colors instead of an /// RGB (`fg = "light-gray"`), and four of them — base16_terminal, term16_dark, /// term16_light, ttox — are written that way from top to bottom. pardes's Theme /// is RGB, so a name has to become one, and these are ghostty's defaults: the /// very colors a pardes terminal pane already paints an unstyled ANSI index as /// (zig-pkg's ghostty, src/terminal/color.zig, `Name.default`). Copied and not /// imported because the generator is a host tool that links nothing — sixteen /// constants are not worth compiling a terminal emulator for the build machine. /// Index 7 and index 0 are also the ink and page a theme gets when it styles /// neither `ui.text` nor `ui.background`. /// /// The ORDER is helix's, which is not quite ANSI's: helix's `gray` is index 8 /// and its `light-gray` is index 7 (helix-view/src/graphics.rs maps them onto /// crossterm's DarkGrey and Grey respectively). const ansi_names = [16][]const u8{ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "light-gray", "gray", "light-red", "light-green", "light-yellow", "light-blue", "light-magenta", "light-cyan", "white", }; const ansi_default = [16]Rgb{ .{ 0x1d, 0x1f, 0x21 }, .{ 0xcc, 0x66, 0x66 }, .{ 0xb5, 0xbd, 0x68 }, .{ 0xf0, 0xc6, 0x74 }, .{ 0x81, 0xa2, 0xbe }, .{ 0xb2, 0x94, 0xbb }, .{ 0x8a, 0xbe, 0xb7 }, .{ 0xc5, 0xc8, 0xc6 }, .{ 0x66, 0x66, 0x66 }, .{ 0xd5, 0x4e, 0x53 }, .{ 0xb9, 0xca, 0x4a }, .{ 0xe7, 0xc5, 0x47 }, .{ 0x7a, 0xa6, 0xda }, .{ 0xc3, 0x97, 0xd8 }, .{ 0x70, 0xc0, 0xb1 }, .{ 0xea, 0xea, 0xea }, }; const Which = enum { fg, bg }; const Helix = struct { path: []const u8, keys: std.StringHashMapUnmanaged(Pair), palette: std.StringHashMapUnmanaged([]const u8), fn get(h: Helix, key: []const u8, which: Which) ?Rgb { const p = h.keys.get(key) orelse return null; return h.color(switch (which) { .fg => p.fg orelse return null, .bg => p.bg orelse return null, }); } fn first(h: Helix, tries: []const struct { []const u8, Which }) ?Rgb { for (tries) |t| if (h.get(t[0], t[1])) |c| return c; return null; } /// `first`, skipping any candidate that comes out equal to the page. A /// cursor or a selection painted in the page's own colour is a theme /// drawing that highlight by REVERSING video (it names the cell it is about /// to invert), not one naming an accent — take it and pardes paints an /// invisible box on an invisible scrollbar thumb. fn firstNotPage(h: Helix, page: Rgb, tries: []const struct { []const u8, Which }) ?Rgb { for (tries) |t| if (h.get(t[0], t[1])) |c| if (!std.mem.eql(u8, &c, &page)) return c; return null; } /// a literal `#rrggbb`, a `[palette]` name that resolves to one, or one of /// the sixteen terminal colours helix names. A palette entry may itself be a /// name (helix lets one alias another), so this walks — bounded, because a /// cycle is a broken theme and not a hang. /// /// The theme's own `[palette]` is consulted BEFORE the sixteen names, which /// is helix's order too (ThemePalette::new extends the defaults with the /// theme's, so a theme that defines `red` means its own red). `default` is /// helix's Color::Reset — no colour at all — and comes back as null so the /// caller's fallback picks the right side's stand-in, ink for an fg and page /// for a bg. fn color(h: Helix, start: []const u8) ?Rgb { var name = start; for (0..8) |_| { if (name.len > 0 and name[0] == '#') return hex(h.path, name); if (h.palette.get(name)) |next| { name = next; continue; } if (std.mem.eql(u8, name, "default")) return null; for (ansi_names, 0..) |n, i| if (std.mem.eql(u8, n, name)) return ansi_default[i]; std.process.fatal("{s}: color \"{s}\" is neither #rrggbb nor a [palette] entry nor a terminal color name", .{ h.path, start }); } std.process.fatal("{s}: [palette] entry \"{s}\" never resolves to a color", .{ h.path, start }); } }; /// cut a `#` comment, unless it is inside a string — where every colour in the /// file lives (`bg = "#282828" # main background`). TOML has two string /// quotes and themes use both, sometimes in the same file: solarized writes its /// whole palette in '...'. fn uncomment(line: []const u8) []const u8 { var q: u8 = 0; for (line, 0..) |c, i| { if (q != 0) { if (c == q) q = 0; } else if (c == '"' or c == '\'') q = c else if (c == '#') return line[0..i]; } return line; } /// index of the first `c` outside a string and outside brackets — the `=` that /// separates a key from its value, or the commas between an inline table's /// entries (an `underline = { color = ..., style = ... }` nested inside must /// not split) fn topLevel(s: []const u8, c: u8) ?usize { var q: u8 = 0; var depth: usize = 0; for (s, 0..) |ch, i| { if (q != 0) { if (ch == q) q = 0; continue; } switch (ch) { '"', '\'' => q = ch, '{', '[' => depth += 1, '}', ']' => depth -|= 1, else => if (ch == c and depth == 0) return i, } } return null; } fn quoted(s: []const u8) bool { return s.len > 0 and (s[0] == '"' or s[0] == '\''); } fn unquote(s: []const u8) []const u8 { if (s.len >= 2 and quoted(s) and s[s.len - 1] == s[0]) return s[1 .. s.len - 1]; return s; } /// `{ fg = "red1", modifiers = ["italic"] }` -> the fg/bg it names fn table(s: []const u8) Pair { var out: Pair = .{}; const body = s[1 .. s.len - @intFromBool(s[s.len - 1] == '}')]; var rest = body; while (rest.len > 0) { const end = topLevel(rest, ',') orelse rest.len; const item = rest[0..end]; rest = rest[@min(end + 1, rest.len)..]; const eq = topLevel(item, '=') orelse continue; // the key gets unquoted too: TOML allows `{ "bg" = "selection" }` and // night_rider is written that way, so matching the bare spelling only // would drop its selection colour on the floor const k = unquote(std.mem.trim(u8, item[0..eq], " \t")); const v = std.mem.trim(u8, item[eq + 1 ..], " \t"); if (!quoted(v)) continue; if (std.mem.eql(u8, k, "fg")) out.fg = unquote(v); if (std.mem.eql(u8, k, "bg")) out.bg = unquote(v); } return out; } // ---- zed (.json) ---- // One file is a FAMILY: `themes` is an array of variants, each with a flat // `style` map of dotted names plus a `style.syntax` map of {color, ...}. // Everything is spelled out (no palette indirection), and unlike helix a zed // theme names all sixteen terminal ANSI colors — which is the whole reason to // take them: a light theme is only usable if the shell's own output is // repainted for it, and pardes's acme-light is the hand-written proof. fn zedThemeCount(gpa: std.mem.Allocator, path: []const u8, text: []const u8) !usize { const parsed = std.json.parseFromSlice(std.json.Value, gpa, text, .{}) catch std.process.fatal("{s}: not valid JSON", .{path}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => std.process.fatal("{s}: top level is not an object", .{path}), }; const list = root.get("themes") orelse std.process.fatal("{s}: no `themes` array", .{path}); return list.array.items.len; } fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, text: []const u8, out: []Theme) !usize { const parsed = std.json.parseFromSlice(std.json.Value, gpa, text, .{}) catch std.process.fatal("{s}: not valid JSON", .{path}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |o| o, else => std.process.fatal("{s}: top level is not an object", .{path}), }; const list = root.get("themes") orelse std.process.fatal("{s}: no `themes` array", .{path}); std.debug.assert(list.array.items.len <= out.len); for (list.array.items, 0..) |variant, index| { const v = variant.object; const z: Zed = .{ .path = path, .style = (v.get("style") orelse std.process.fatal("{s}: a variant has no `style`", .{path})).object, }; const syntax = (z.style.get("syntax") orelse std.process.fatal("{s}: a variant has no `style.syntax`", .{path})).object; var pal: [16]Rgb = undefined; for (ansi, 0..) |n, i| { pal[i] = z.need(try std.fmt.allocPrint(arena, "terminal.ansi.{s}", .{n})); pal[i + 8] = z.need(try std.fmt.allocPrint(arena, "terminal.ansi.bright_{s}", .{n})); } out[index] = .{ // `_zed` on every one of them: see the naming note at the top — // zed re-ships gruvbox, ayu and one, so the source is part of the // name rather than a suffix that appears only on the eight that // happen to clash today .name = try std.fmt.allocPrint(arena, "{s}_zed", .{try slug(arena, (v.get("name") orelse std.process.fatal("{s}: a variant has no `name`", .{path})).string)}), .bg = z.need("editor.background"), .fg = z.need("editor.foreground"), // zed's TEXT selection is `players[0].selection`, and it is a 24% // alpha over the page — the colour on screen is the composite, not // the value. pardes paints opaque cells and `hex` drops alpha, so // taking it raw would put a full-strength accent behind the text. // `element.selected` is the same theme's own opaque "this is // selected" fill, which is the question actually being asked. The // ink then stays the editor's own text colour, because that fill is // always a step off the background and never off the text. .sel_bg = z.need("element.selected"), .sel_fg = z.need("editor.foreground"), .tag_bg = z.need("status_bar.background"), .tag_fg = z.need("text.muted"), // zed names its accent, so the move box does not have to be guessed // out of the cursor the way it is for a helix theme .box = z.need("text.accent"), .box_dim = z.need("border"), .kw = z.syn(syntax, "keyword"), .str = z.syn(syntax, "string"), .num = z.syn(syntax, "number"), .comment = z.syn(syntax, "comment"), .lineno = z.need("editor.line_number"), .scroll_track = z.need("scrollbar.thumb.border"), .scroll_thumb = z.need("scrollbar.thumb.background"), .palette = pal, }; } return list.array.items.len; } /// zed's order, which is also the ANSI one: 0-7 plain, 8-15 the `bright_` half const ansi = [8][]const u8{ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white" }; const Zed = struct { path: []const u8, style: std.json.ObjectMap, fn need(z: Zed, key: []const u8) Rgb { const v = z.style.get(key) orelse std.process.fatal("{s}: missing style.{s}", .{ z.path, key }); return switch (v) { .string => |s| hex(z.path, s), else => std.process.fatal("{s}: style.{s} is not a color string", .{ z.path, key }), }; } fn syn(z: Zed, syntax: std.json.ObjectMap, key: []const u8) Rgb { const v = syntax.get(key) orelse std.process.fatal("{s}: missing style.syntax.{s}", .{ z.path, key }); const c = v.object.get("color") orelse std.process.fatal("{s}: style.syntax.{s} has no color", .{ z.path, key }); return switch (c) { .string => |s| hex(z.path, s), else => std.process.fatal("{s}: style.syntax.{s}.color is not a string", .{ z.path, key }), }; } }; /// `#rrggbb`, helix's `#rgb` shorthand, or zed's `#rrggbbaa`. The alpha is /// DROPPED: pardes paints opaque cells, and the only translucent colors we read /// are the scrollbar's, where a dropped alpha reads as a slightly bolder bar and /// nothing else. `#rgb` doubles each nibble the way helix does (`#ccc` is /// `#cccccc`, not `#c0c0c0`) — 58 colours in the vendored set are written that /// way and rounding them down would darken those themes by a hair. fn hex(path: []const u8, s: []const u8) Rgb { const d = if (s.len > 0 and s[0] == '#') s[1..] else s; if (d.len != 3 and d.len != 6 and d.len != 8) std.process.fatal("{s}: \"{s}\" is not #rgb or #rrggbb", .{ path, s }); var out: Rgb = undefined; for (&out, 0..) |*c, i| { const digits = if (d.len == 3) [2]u8{ d[i], d[i] } else d[i * 2 ..][0..2].*; c.* = std.fmt.parseInt(u8, &digits, 16) catch std.process.fatal("{s}: \"{s}\" is not #rgb or #rrggbb", .{ path, s }); } return out; }