summaryrefslogtreecommitdiff
path: root/tools/gen_themes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'tools/gen_themes.zig')
-rw-r--r--tools/gen_themes.zig278
1 files changed, 217 insertions, 61 deletions
diff --git a/tools/gen_themes.zig b/tools/gen_themes.zig
index 50c32b8b..4fc7ab67 100644
--- a/tools/gen_themes.zig
+++ b/tools/gen_themes.zig
@@ -10,21 +10,40 @@
//! 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.
+//! 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 <name>` 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). 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.
+//! *.json are zed's (zed-industries/zed assets/themes). The licences sit beside
+//! them as `<name>.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.
//!
-//! 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.
+//! 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;
@@ -58,15 +77,23 @@ pub fn main(init: std.process.Init) !void {
if (args.len < 3) std.process.fatal("usage: gen-themes <out-dir> <theme-source>...", .{});
var themes: std.ArrayList(Theme) = .empty;
+ // 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 stems: std.ArrayList([]const u8) = .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"))
+ if (std.mem.endsWith(u8, src, ".toml")) {
+ const stem = std.fs.path.stem(src);
+ try tomls.put(arena, stem, parseToml(arena, src, text));
+ try stems.append(arena, stem);
+ } 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});
}
+ for (stems.items) |stem| try helixTheme(arena, stem, resolve(arena, &tomls, stem, 8), &themes);
// 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);
@@ -153,9 +180,18 @@ fn slug(arena: std.mem.Allocator, raw: []const u8) ![]const u8 {
/// 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 {
+/// 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| {
@@ -170,57 +206,146 @@ fn readHelix(arena: std.mem.Allocator, path: []const u8, text: []const u8, out:
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));
+ palette.put(arena, key, unquote(val)) catch @panic("OOM");
+ } else if (std.mem.eql(u8, key, "inherits")) {
+ inherits = unquote(val);
} else if (quoted(val)) {
- try keys.put(arena, key, .{ .fg = unquote(val) });
+ keys.put(arena, key, .{ .fg = unquote(val) }) catch @panic("OOM");
} else if (val[0] == '{') {
- try keys.put(arena, key, table(val));
+ keys.put(arena, key, table(val)) catch @panic("OOM");
}
}
- // `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});
+ 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 };
+}
- 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);
+/// 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 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
+/// 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, out: *std.ArrayList(Theme)) !void {
+ 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.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;
+ 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;
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.
+ .name = try slug(arena, stem),
+ // `palette` stays null: 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,
+ .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.need("keyword", .fg),
- .str = h.need("string", .fg),
+ .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
- std.process.fatal("{s}: no constant.numeric or constant color", .{path}),
- .comment = h.need("comment", .fg),
- .lineno = h.get("ui.linenr", .fg) orelse fg,
+ .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 bg,
+ .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 {
@@ -236,25 +361,43 @@ const Helix = struct {
});
}
- 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 {
+ /// `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);
- name = h.palette.get(name) orelse
- std.process.fatal("{s}: color \"{s}\" is neither #rrggbb nor a [palette] entry", .{ h.path, start });
+ 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 });
}
@@ -315,7 +458,10 @@ fn table(s: []const u8) Pair {
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");
+ // 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);
@@ -355,7 +501,11 @@ fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, t
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),
+ // `_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"),
.tag_bg = z.need("status_bar.background"),
@@ -401,14 +551,20 @@ const Zed = struct {
}
};
-/// `#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.
+/// `#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 != 6 and d.len != 8) std.process.fatal("{s}: \"{s}\" is not #rrggbb", .{ path, 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| c.* = std.fmt.parseInt(u8, d[i * 2 ..][0..2], 16) catch
- std.process.fatal("{s}: \"{s}\" is not #rrggbb", .{ path, s });
+ 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;
}