Add theme customizer.
This commit is contained in:
1 parent
2daeb6125f
commit
695417a601
12 files changed
+1661
-244
No files matched your search
+226
-6
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user