summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 02:21:01 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit0bfa4615988c0d1bcaa38453375760c60076c5ea (patch)
treefab4f972c10b07739aabc9d772c43e102bce2c07 /tools
parentbe404e0d4f91548af6ed600b2fd2027ad8a041c7 (diff)
downloadpardes-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.zig414
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;
+}