diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/gen_themes.zig | 278 |
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; } |
