Add theme customizer.

This commit is contained in:
Greyson Parrelli committed 2026-08-13 15:55:23 -04:00
1 parent 2daeb6125f
commit 695417a601
12 files changed
+1661 -244

No files matched your search

+226 -6
View File
@@ -4,12 +4,19 @@
//! something changes. It carries a version from the start, so adding the next
//! setting is not also the day we invent a format.
//!
//! There are two settings, and they are very different shapes. The colour
//! scheme is one word. 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 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
//! 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
//! point, `setStartup` replaces the arena wholesale, so anything colour-shaped
//! living in it would be freed by an unrelated edit to the startup list.
//!
//! Because saving rewrites the whole file, there is exactly one `Settings` for
//! the process, reached through `get`. Two holders each convinced they knew
@@ -24,6 +31,8 @@
const std = @import("std");
const glib = @import("glib");
const palette = @import("palette.zig");
const Settings = @This();
/// Bumped only if the on-disk shape changes incompatibly. Read but not yet
@@ -58,6 +67,23 @@ pub const Theme = enum {
}
};
/// The palette colours someone has changed, per scheme.
///
/// 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,
pub fn of(self: *Colors, scheme: palette.Scheme) *palette.Overrides {
return switch (scheme) {
.dark => &self.dark,
.light => &self.light,
};
}
};
/// One of a layout's parameters, and what a startup tab opens it with.
pub const Value = struct {
name: []const u8,
@@ -95,6 +121,11 @@ arena: std.heap.ArenaAllocator,
theme: Theme = .system,
/// Changes to the palette. Empty means every colour is at its default, which is
/// the state anyone who never opens the colour editor stays in — and it is the
/// reason the defaults can be retuned in a later version and still reach them.
colors: Colors = .{},
/// The tabs to open at launch, in order. Empty means a single plain shell,
/// which is what the app did before this setting existed.
startup: []const StartupTab = &.{},
@@ -203,9 +234,65 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
}
}
if (root.get("colors")) |value| self.parseColors(value);
if (root.get("startup")) |value| try self.parseStartup(value);
}
/// Read the palette overrides: `{"dark": {"accent": "#ff8800"}, "light": {…}}`.
///
/// Nothing here can fail the file. A colour is one word of a palette, and the
/// answer to a word we don't recognise or can't parse is the default for it —
/// which is exactly what an absent key already means, so an unknown name from a
/// newer version and a typo in a hand-edited file cost the same nothing.
fn parseColors(self: *Settings, raw: std.json.Value) void {
const obj = switch (raw) {
.object => |o| o,
else => {
std.log.warn("settings: \"colors\" is not an object; ignoring it", .{});
return;
},
};
for (std.enums.values(palette.Scheme)) |scheme| {
const entry = obj.get(@tagName(scheme)) orelse continue;
const colors = switch (entry) {
.object => |o| o,
else => {
std.log.warn("settings: colors.{s} is not an object; ignoring it", .{@tagName(scheme)});
continue;
},
};
const into = self.colors.of(scheme);
var it = colors.iterator();
while (it.next()) |kv| {
const key = std.meta.stringToEnum(palette.Key, kv.key_ptr.*) orelse {
std.log.warn("settings: no colour called \"{s}\"; ignoring it", .{kv.key_ptr.*});
continue;
};
const text = switch (kv.value_ptr.*) {
.string => |s| s,
else => {
std.log.warn("settings: colors.{s}.{s} is not a string", .{
@tagName(scheme), @tagName(key),
});
continue;
},
};
const color = palette.Rgb.parse(text) orelse {
std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text});
continue;
};
into.set(key, color);
}
}
}
/// Read the startup list.
///
/// Entries that make no sense are skipped rather than failing the file. The
@@ -367,6 +454,31 @@ fn serialize(self: *Settings) SaveError![]u8 {
try json.objectField("theme");
try json.write(@tagName(self.theme));
// Only the colours that have been changed, and only the schemes that have
// any. The object itself is written even when empty, for the same reason
// the startup list below is: a file you have opened to see what you can put
// in it should name what it accepts.
try json.objectField("colors");
try json.beginObject();
for (std.enums.values(palette.Scheme)) |scheme| {
const colors = self.colors.of(scheme);
var started = false;
for (std.enums.values(palette.Key)) |key| {
const color = colors.get(key) orelse continue;
if (!started) {
try json.objectField(@tagName(scheme));
try json.beginObject();
started = true;
}
var buf: [7:0]u8 = undefined;
try json.objectField(@tagName(key));
try json.write(color.hex(&buf));
}
if (started) try json.endObject();
}
try json.endObject();
// Written even when empty, so that the key is discoverable in a file
// someone has opened to see what they can put in it.
try json.objectField("startup");
@@ -416,3 +528,111 @@ fn configDir(buf: []u8) ?[:0]const u8 {
const dir = std.mem.span(glib.getUserConfigDir());
return std.fmt.bufPrintZ(buf, "{s}/playpen", .{dir}) catch null;
}
// -------------------------------------------------------------------------
// Tests
//
// Reading and writing the file, minus the file: `parse` and `serialize` are
// where the on-disk shape actually lives, and they need nothing but an
// allocator. The palette overrides are what these are here for — a colour is a
// name and a hex string in a nested object, which is three chances to write out
// something the next launch reads back as a different palette.
fn forTesting() Settings {
return .{ .arena = .init(std.testing.allocator) };
}
test "palette overrides are read per scheme" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"theme": "dark", "colors": {
\\ "dark": {"accent": "#ff8800", "ansi_red": "#112233"},
\\ "light": {"sidebar": "#fafafa"}
\\}}
);
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).?);
// 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));
}
test "a colour we can't read costs that colour and nothing else" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"theme": "dark", "colors": {"dark": {
\\ "not_a_colour": "#123456",
\\ "border": "rebeccapurple",
\\ "sidebar": 12,
\\ "accent": "#ff8800"
\\}}}
);
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));
// 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).?);
}
test "colors is written even when nothing has been changed" {
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, "\"colors\": {}") != null);
}
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").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"accent\": \"#ff8800\"") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\"ansi_bright_white\": \"#010203\"") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\"text\": \"#040506\"") != null);
// Nothing else, so a file stays as short as the changes it records.
try std.testing.expect(std.mem.indexOf(u8, text, "\"sidebar\"") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "\"ansi_red\"") == null);
var read_back = forTesting();
defer read_back.arena.deinit();
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));
}
}
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").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"dark\"") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\"light\"") == null);
}