diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 02:21:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | 0bfa4615988c0d1bcaa38453375760c60076c5ea (patch) | |
| tree | fab4f972c10b07739aabc9d772c43e102bce2c07 /tools | |
| parent | be404e0d4f91548af6ed600b2fd2027ad8a041c7 (diff) | |
| download | pardes-0bfa4615988c0d1bcaa38453375760c60076c5ea.tar.gz pardes-0bfa4615988c0d1bcaa38453375760c60076c5ea.zip | |
themes: one file each, generated from helix and zed, picked by stepping a list
Each theme is a .zig file of pure data and nothing enumerates them by hand —
fold() walks the container's declarations, so a theme is a file and that is the
whole registration. Field by field rather than a wholesale coercion, which
makes a missing field a compile error that names it.
tools/gen_themes.zig reads helix .toml and zed .json out of vendor/themes and
emits one .zig each; build.zig reads that directory, so adding a theme is
dropping a file in. Output goes to the build cache rather than the tree, so zig
owns the freshness check and a deleted source cannot leave a stale theme
behind. Vendored, not read from the genizah: the build stays offline. A source
it cannot map is a hard error naming the file and the key, never a silently
black-on-black theme.
ThemeSel is the clever half. Traits gained an `executes` column, so n/N over
that buffer hands the whole line to Exec instead of the leading word to Look —
both arms the ordinary builtin, so a stepped row does exactly what the matching
mouse button on it would. Stepping the list previews each theme live. The trait
is a pane property, not an output-pane branch, so any pane whose lines are
commands can opt in.
Goldens: leader gains SPC t t; theme's fourth NextColor no longer wraps to
helix because the ring is fifteen long. New: themesel.
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/gen_themes.zig | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/tools/gen_themes.zig b/tools/gen_themes.zig new file mode 100644 index 00000000..50c32b8b --- /dev/null +++ b/tools/gen_themes.zig @@ -0,0 +1,414 @@ +//! 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. The files are small and +//! we consume a dozen fields out of each, so a curated handful beats a mirror +//! of all 214 helix ships — bulk buys nothing when pardes throws away 95% of +//! what a theme carries. +//! +//! 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). Every one carries its +//! upstream licence beside it as `<name>.LICENSE`, and build.zig's directory +//! read skips anything that is not a .toml or a .json so they can sit there. +//! +//! A theme that cannot be mapped is FATAL, naming the file and the key. The +//! vendored set is curated, so a missing key means the file is not what this +//! reader thinks it is — printing a warning and emitting a black-on-black theme +//! would put that mistake on screen instead of in the build log. +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, + 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 <out-dir> <theme-source>...", .{}); + + var themes: std.ArrayList(Theme) = .empty; + for (args[2..]) |src| { + const text = try std.Io.Dir.cwd().readFileAlloc(io, src, arena, .limited(8 << 20)); + if (std.mem.endsWith(u8, src, ".toml")) + try readHelix(arena, src, text, &themes) + else if (std.mem.endsWith(u8, src, ".json")) + try readZed(arena, gpa, src, text, &themes) + else + std.process.fatal("{s}: not a helix .toml or a zed .json", .{src}); + } + // 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.items, {}, 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.items) |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.items) |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 <name>` 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 { + var out: std.ArrayList(u8) = .empty; + for (raw) |c| { + if (std.ascii.isAlphanumeric(c)) try out.append(arena, std.ascii.toLower(c)) + // one underscore per run of punctuation, and never a trailing one + else if (out.items.len > 0 and out.items[out.items.len - 1] != '_') try out.append(arena, '_'); + } + const s = std.mem.trim(u8, out.items, "_"); + 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 }; + +fn readHelix(arena: std.mem.Allocator, path: []const u8, text: []const u8, out: *std.ArrayList(Theme)) !void { + var palette: std.StringHashMapUnmanaged([]const u8) = .empty; + var keys: std.StringHashMapUnmanaged(Pair) = .empty; + 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) { + try palette.put(arena, key, unquote(val)); + } else if (quoted(val)) { + try keys.put(arena, key, .{ .fg = unquote(val) }); + } else if (val[0] == '{') { + try keys.put(arena, key, table(val)); + } + } + // `inherits = "other"` needs a second file we did not read, so the theme we + // would emit is only the half that overrides. Vendor the parent instead. + if (keys.contains("inherits")) std.process.fatal("{s}: inherits= is not supported; vendor a theme that stands alone", .{path}); + + const h: Helix = .{ .path = path, .keys = keys, .palette = palette }; + // helix's own rule for anything a theme left unset: the page's colors. + const fg = h.need("ui.text", .fg); + const bg = h.need("ui.background", .bg); + // 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.first(&.{ .{ "ui.cursor.primary", .bg }, .{ "ui.cursor", .bg }, .{ "keyword", .fg } }) orelse + std.process.fatal("{s}: no ui.cursor.primary/ui.cursor background and no keyword color", .{path}); + const sel = h.get("ui.selection", .bg) orelse bg; + try out.append(arena, .{ + .name = try slug(arena, std.fs.path.stem(path)), + // explicit rather than null: a helix theme dresses its whole page, and + // half-dressing it over whatever the host terminal defaults to is how + // you get grey-on-grey. `palette` stays null for the opposite reason — + // helix leaves the colors a PROGRAM emits alone, and so do we. + .bg = bg, + .fg = fg, + .tag_bg = h.get("ui.statusline", .bg) orelse bg, + .tag_fg = h.get("ui.statusline", .fg) orelse fg, + .box = box, + .box_dim = sel, + .kw = h.need("keyword", .fg), + .str = h.need("string", .fg), + // 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 + std.process.fatal("{s}: no constant.numeric or constant color", .{path}), + .comment = h.need("comment", .fg), + .lineno = h.get("ui.linenr", .fg) orelse fg, + // 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 bg, + .scroll_thumb = sel, + .palette = null, + }); +} + +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 need(h: Helix, key: []const u8, which: Which) Rgb { + return h.get(key, which) orelse + std.process.fatal("{s}: missing {s}.{s}", .{ h.path, key, @tagName(which) }); + } + + 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; + } + + /// a literal `#rrggbb`, or a `[palette]` name that resolves to one. 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. + 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); + name = h.palette.get(name) orelse + std.process.fatal("{s}: color \"{s}\" is neither #rrggbb nor a [palette] entry", .{ 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; + const k = 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 readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, text: []const u8, out: *std.ArrayList(Theme)) !void { + 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}); + for (list.array.items) |variant| { + 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})); + } + try out.append(arena, .{ + .name = 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"), + .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, + }); + } +} + +/// 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`, 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. +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 != 6 and d.len != 8) std.process.fatal("{s}: \"{s}\" is not #rrggbb", .{ path, s }); + var out: Rgb = undefined; + for (&out, 0..) |*c, i| c.* = std.fmt.parseInt(u8, d[i * 2 ..][0..2], 16) catch + std.process.fatal("{s}: \"{s}\" is not #rrggbb", .{ path, s }); + return out; +} |
