Add quick theming tool.

This commit is contained in:
Greyson Parrelli committed 2026-08-24 16:45:21 -04:00
1 parent 407dd81512
commit 071522f67c
7 files changed
+1367 -77

No files matched your search

+179 -24
View File
@@ -5,16 +5,18 @@
//! setting is not also the day we invent a format.
//!
//! The settings are three different shapes. The colour scheme is one word. The
//! **palette overrides** are a colour per name per scheme, and only for the
//! names someone has actually changed. The **startup tabs** are a list: which
//! **palette changes** are a base colour per scheme, plus a colour per name for
//! the names someone has gone on to change by hand. The **startup tabs** are a
//! list: which
//! saved layout to open, what to fill its parameters in with, and what to call
//! the tab — one entry per tab, opened in order at launch. That list is why this
//! module holds an arena: every string in it is owned here, and replacing the
//! list frees the previous one in a single stroke rather than tracking each
//! field.
//!
//! The overrides deliberately stay out of that arena. They are a fixed-size
//! array of optionals, which needs no allocation at all — and, more to the
//! The palette changes deliberately stay out of that arena. They are a base
//! colour and a fixed-size array of optionals, which needs no allocation at
//! all — and, more to the
//! point, `setStartup` replaces the arena wholesale, so anything colour-shaped
//! living in it would be freed by an unrelated edit to the startup list. The
//! **shortcut overrides** are the same shape for the same reason: a chord is a
@@ -71,16 +73,17 @@ pub const Theme = enum {
}
};
/// The palette colours someone has changed, per scheme.
/// What someone has done to the palette, per scheme: the colour it is generated
/// from, and the individual colours they have set by hand.
///
/// Both schemes are kept, not just the one in force: the editor changes the
/// palette you can see, and pinning the app light for an afternoon shouldn't
/// cost you the dark palette you spent an evening on.
pub const Colors = struct {
dark: palette.Overrides = palette.no_overrides,
light: palette.Overrides = palette.no_overrides,
dark: palette.Changes = .{},
light: palette.Changes = .{},
pub fn of(self: *Colors, scheme: palette.Scheme) *palette.Overrides {
pub fn of(self: *Colors, scheme: palette.Scheme) *palette.Changes {
return switch (scheme) {
.dark => &self.dark,
.light => &self.light,
@@ -245,6 +248,8 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
if (root.get("colors")) |value| self.parseColors(value);
if (root.get("tint")) |value| self.parseTint(value);
if (root.get("shortcuts")) |value| self.parseShortcuts(value);
if (root.get("startup")) |value| try self.parseStartup(value);
@@ -299,11 +304,68 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
continue;
};
into.set(key, color);
into.overrides.set(key, color);
}
}
}
/// Read the generated palettes: `{"dark": {"base": "#3a76f0", "contrast": 0.5}}`.
///
/// A scheme without a `base` has no generated palette at all, whatever else the
/// entry says — the contrast is how far apart to spread the colours built from
/// the base, so on its own it describes nothing. That is also why a malformed
/// contrast costs only the contrast: the base is the setting, and dropping the
/// whole theme over one bad number would be the wrong trade.
fn parseTint(self: *Settings, raw: std.json.Value) void {
const obj = switch (raw) {
.object => |o| o,
else => {
std.log.warn("settings: \"tint\" is not an object; ignoring it", .{});
return;
},
};
for (std.enums.values(palette.Scheme)) |scheme| {
const entry = switch (obj.get(@tagName(scheme)) orelse continue) {
.object => |o| o,
else => {
std.log.warn("settings: tint.{s} is not an object; ignoring it", .{@tagName(scheme)});
continue;
},
};
const text = switch (entry.get("base") orelse continue) {
.string => |t| t,
else => {
std.log.warn("settings: tint.{s}.base is not a string", .{@tagName(scheme)});
continue;
},
};
const base = palette.Rgb.parse(text) orelse {
std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text});
continue;
};
// Clamped rather than rejected: the range is a detail of what the slider
// can express, and a file that says 2 plainly means "as stark as it
// goes".
const contrast: f32 = switch (entry.get("contrast") orelse std.json.Value{ .float = 0 }) {
.float => |f| @floatCast(f),
.integer => |i| @floatFromInt(i),
else => blk: {
std.log.warn("settings: tint.{s}.contrast is not a number", .{@tagName(scheme)});
break :blk 0;
},
};
self.colors.of(scheme).tint = .{
.base = base,
.contrast = std.math.clamp(contrast, -1, 1),
};
}
}
/// Read the rebound shortcuts: `{"focus_pane_left": "ctrl+shift+h", …}`.
///
/// A value is either one chord or a list of them, because most actions want one
@@ -545,7 +607,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
var started = false;
for (std.enums.values(palette.Key)) |key| {
const color = colors.get(key) orelse continue;
const color = colors.overrides.get(key) orelse continue;
if (!started) {
try json.objectField(@tagName(scheme));
try json.beginObject();
@@ -559,6 +621,26 @@ fn serialize(self: *Settings) SaveError![]u8 {
}
try json.endObject();
// And the base colour each scheme's palette is generated from, for the
// schemes that have one. Written after the overrides rather than before
// because that is the order they apply in, and a settings file is read by
// people as well as by this.
try json.objectField("tint");
try json.beginObject();
for (std.enums.values(palette.Scheme)) |scheme| {
const tint = self.colors.of(scheme).tint orelse continue;
var buf: [7:0]u8 = undefined;
try json.objectField(@tagName(scheme));
try json.beginObject();
try json.objectField("base");
try json.write(tint.base.hex(&buf));
try json.objectField("contrast");
try json.write(tint.contrast);
try json.endObject();
}
try json.endObject();
// Only the shortcuts that have been rebound, for the same reasons as the
// colours — and with the same consequence, which is the one that matters
// here: saving rewrites the whole file, so a hand-edited shortcut has to
@@ -660,14 +742,14 @@ test "palette overrides are read per scheme" {
\\}}
);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.get(.accent).?);
try std.testing.expectEqual(palette.Rgb.parse("#112233").?, settings.colors.dark.get(.ansi_red).?);
try std.testing.expectEqual(palette.Rgb.parse("#fafafa").?, settings.colors.light.get(.sidebar).?);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.overrides.get(.accent).?);
try std.testing.expectEqual(palette.Rgb.parse("#112233").?, settings.colors.dark.overrides.get(.ansi_red).?);
try std.testing.expectEqual(palette.Rgb.parse("#fafafa").?, settings.colors.light.overrides.get(.sidebar).?);
// A scheme that says nothing about a colour leaves it at its default, which
// is what an absent override means everywhere else.
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.light.get(.accent));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.get(.sidebar));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.light.overrides.get(.accent));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
}
test "a colour we can't read costs that colour and nothing else" {
@@ -683,13 +765,13 @@ test "a colour we can't read costs that colour and nothing else" {
\\}}}
);
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.get(.border));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.get(.sidebar));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.border));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
// The whole file survives, including the theme above the colours and the
// one colour in the list that was well formed.
try std.testing.expectEqual(Theme.dark, settings.theme);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.get(.accent).?);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.overrides.get(.accent).?);
}
test "colors is written even when nothing has been changed" {
@@ -706,9 +788,9 @@ test "only the changed colours are written, and they come back" {
var settings = forTesting();
defer settings.arena.deinit();
settings.colors.dark.set(.accent, palette.Rgb.parse("#ff8800").?);
settings.colors.dark.set(.ansi_bright_white, palette.Rgb.parse("#010203").?);
settings.colors.light.set(.text, palette.Rgb.parse("#040506").?);
settings.colors.dark.overrides.set(.accent, palette.Rgb.parse("#ff8800").?);
settings.colors.dark.overrides.set(.ansi_bright_white, palette.Rgb.parse("#010203").?);
settings.colors.light.overrides.set(.text, palette.Rgb.parse("#040506").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
@@ -726,11 +808,84 @@ test "only the changed colours are written, and they come back" {
try read_back.parse(text);
for (std.enums.values(palette.Key)) |key| {
try std.testing.expectEqual(settings.colors.dark.get(key), read_back.colors.dark.get(key));
try std.testing.expectEqual(settings.colors.light.get(key), read_back.colors.light.get(key));
try std.testing.expectEqual(settings.colors.dark.overrides.get(key), read_back.colors.dark.overrides.get(key));
try std.testing.expectEqual(settings.colors.light.overrides.get(key), read_back.colors.light.overrides.get(key));
}
}
test "a base colour and its contrast survive the file" {
var settings = forTesting();
defer settings.arena.deinit();
settings.colors.dark.tint = .{ .base = palette.Rgb.parse("#d2691e").?, .contrast = 0.45 };
settings.colors.dark.overrides.set(.accent, palette.Rgb.parse("#ff8800").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
var read_back = forTesting();
defer read_back.arena.deinit();
try read_back.parse(text);
try std.testing.expect(read_back.colors.dark.tint.?.eql(settings.colors.dark.tint.?));
// The scheme that has no base still has none, and the override the base sits
// under came back with it: the two are separate settings and a round trip
// that merged them would be one of them lost.
try std.testing.expectEqual(@as(?palette.Tint, null), read_back.colors.light.tint);
try std.testing.expectEqual(
palette.Rgb.parse("#ff8800").?,
read_back.colors.dark.overrides.get(.accent).?,
);
}
test "tint is written even when no scheme has one" {
var settings = forTesting();
defer settings.arena.deinit();
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"tint\": {}") != null);
}
test "a tint we can't read costs that tint and nothing else" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"theme": "dark", "tint": {
\\ "dark": {"base": "rebeccapurple", "contrast": 0.5},
\\ "light": {"contrast": 0.5}
\\}}
);
// A base that doesn't parse and a scheme that never named one both come out
// the same way: no generated palette, and a file that is otherwise intact.
try std.testing.expectEqual(@as(?palette.Tint, null), settings.colors.dark.tint);
try std.testing.expectEqual(@as(?palette.Tint, null), settings.colors.light.tint);
try std.testing.expectEqual(Theme.dark, settings.theme);
}
test "a contrast outside the range is pulled into it" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"tint": {
\\ "dark": {"base": "#3a76f0", "contrast": 4},
\\ "light": {"base": "#3a76f0", "contrast": "very"}
\\}}
);
try std.testing.expectEqual(@as(f32, 1), settings.colors.dark.tint.?.contrast);
// An unreadable contrast leaves the base standing at the neutral spread,
// which is what a base with no contrast beside it already means.
try std.testing.expectEqual(@as(f32, 0), settings.colors.light.tint.?.contrast);
try std.testing.expectEqual(palette.Rgb.parse("#3a76f0").?, settings.colors.light.tint.?.base);
}
test "a shortcut can be given one chord or several" {
var settings = forTesting();
defer settings.arena.deinit();
@@ -825,7 +980,7 @@ test "a scheme with nothing changed is left out of the file entirely" {
var settings = forTesting();
defer settings.arena.deinit();
settings.colors.dark.set(.accent, palette.Rgb.parse("#ff8800").?);
settings.colors.dark.overrides.set(.accent, palette.Rgb.parse("#ff8800").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);