1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
|
//! 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 <name>` would silently pick one), so src/colors.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 `<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.
//!
//! 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/colors.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 <out-dir> <theme-source>...", .{});
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. colors.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 <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 {
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;
}
|