Add quick theming tool.
This commit is contained in:
1 parent
407dd81512
commit
071522f67c
7 files changed
+1367
-77
No files matched your search
+179
-24
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user