//! User preferences: the choices that outlive a session. //! //! A small JSON file next to `layouts.json`, rewritten whole every time //! something changes. It carries a version from the start, so adding the next //! setting is not also the day we invent a format. //! //! The settings are three different shapes. The color scheme is one word. The //! **palette changes** are a base color per scheme, plus a color 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 palette changes deliberately stay out of that arena. They are a base //! color 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 color-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 //! key and four flags once it has been parsed, so nothing about it needs to //! outlive the parse as text. //! //! Because saving rewrites the whole file, there is exactly one `Settings` for //! the process, reached through `get`. Two holders each convinced they knew //! what was in the file would take turns overwriting the other's half of it — //! which is precisely how a startup list would get eaten by a theme change. //! //! File access goes through GLib for the same reasons `Layouts` does: it knows //! the XDG config directory, and `g_file_set_contents` writes to a temporary //! and renames, so an interrupted save leaves the previous settings intact //! rather than a truncated file that won't parse. const std = @import("std"); const glib = @import("glib"); const palette = @import("palette.zig"); const shortcuts = @import("shortcuts.zig"); const Settings = @This(); /// Bumped only if the on-disk shape changes incompatibly. Read but not yet /// acted on: there is nothing older to migrate from. pub const format_version = 1; const max_path = 4096; /// Ample for a version, a scheme, and a startup list of any length someone /// would sit through opening. A settings file larger than this is not one we /// wrote, and defaults are a better answer than a partial read of it. const max_file_size = 64 * 1024; /// Which palette to paint the window with. /// /// `system` is the default and defers to the desktop, which is what someone /// who never opens the settings page should get. The other two pin the app /// regardless of what the rest of the session is doing — worth having, since /// a terminal is often the one window you want dark on a light desktop. pub const Theme = enum { system, light, dark, /// What the settings page shows on the button for this choice. pub fn label(self: Theme) [:0]const u8 { return switch (self) { .system => "System", .light => "Light", .dark => "Dark", }; } }; /// What someone has done to the palette, per scheme: the color it is generated /// from, and the individual colors 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.Changes = .{}, light: palette.Changes = .{}, pub fn of(self: *Colors, scheme: palette.Scheme) *palette.Changes { 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, value: []const u8, }; /// A tab to open at launch. /// /// This is the answer to "restore my whole window" without storing a window's /// worth of live state. A tab that came from a layout is already fully /// described by the layout's name and the values it was opened with, and those /// are three short strings — so the thing that gets saved is the *recipe*, and /// launching re-runs it. A tab whose shells have wandered somewhere else since /// is not reproduced, and that is the trade: what comes back is the arrangement /// you set up, not the session you left. pub const StartupTab = struct { /// Saved layout to open, by name. Empty opens a plain shell — the same tab /// the new-tab button makes. layout: []const u8 = "", /// Name pinned on the tab's row, as though it had been typed into the /// rename entry. Empty leaves the label following the panes. name: []const u8 = "", /// Emoji shown in the row in place of the pane icon. Empty means none. emoji: []const u8 = "", /// What to fill the layout's parameters in with. A parameter this doesn't /// name opens at its own default, so an entry only has to carry the values /// that differ from what the layout already suggests. parameters: []const Value = &.{}, }; arena: std.heap.ArenaAllocator, theme: Theme = .system, /// Whether closing the window asks first. /// /// On by default, which is the opposite of what a terminal usually does — and /// deliberately so. A window here is not one shell but a whole arrangement of /// them, and under a tiling compositor the binding that closes it sits a /// modifier away from the ones that move focus. There is nothing else between /// a mistyped chord and every shell in every tab exiting. confirm_quit: bool = true, /// Whether new terminals run in herdr-core (`hc`) rather than in a pty of the /// app's own. /// /// Off by default: it needs `hc` installed, and it changes what quitting /// means. With it on, a shell's program belongs to the core, so closing the /// window — or the app crashing — leaves every shell and agent running, and /// the next launch restores the session without asking, attaching to them /// again. Closing a pane or a tab still ends what is in it. See `hc.zig`. herdr_core: bool = false, /// Whether a pane finishing posts a desktop notification. /// /// On by default. The whole shape of this app is "set something going in a tab /// and go and do something else", and a sidebar dot can only tell you that /// while you are looking at the sidebar. Someone who does not want to be told /// has a switch in the settings and a per-tab mute in every row menu; someone /// who does want to be told should not have to find a setting first. notifications: bool = true, /// Changes to the palette. Empty means every color is at its default, which is /// the state anyone who never opens the color 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 = &.{}, /// The shortcuts someone has rebound. Empty means every action answers to the /// chord this build ships for it — see `shortcuts.zig` for the table and for /// what an entry here replaces. keys: shortcuts.Overrides = shortcuts.no_overrides, // ------------------------------------------------------------------------- // The process-wide instance var instance: ?Settings = null; /// Read the settings file into the process-wide store. Called once, before the /// first window is built. pub fn init(alloc: std.mem.Allocator) void { std.debug.assert(instance == null); instance = load(alloc); } /// The settings this process is running with. pub fn get() *Settings { if (instance) |*self| return self; // `init` runs from the application's activate handler, which is before // anything that could ask. unreachable; } /// Release the store. Safe to call without a matching `init`, so the shutdown /// path doesn't have to know whether the app got as far as activating. pub fn deinit() void { if (instance) |*self| self.arena.deinit(); instance = null; } // ------------------------------------------------------------------------- // Loading /// Read the settings file, falling back to defaults for anything missing. /// /// Every failure below lands on the same behavior — carry on with defaults — /// because there is no useful alternative: this runs before there is a window /// to report an error in, and refusing to start over an unreadable preferences /// file would be a worse outcome than ignoring it. A malformed file is logged /// rather than silently swallowed, since the next save overwrites it. fn load(alloc: std.mem.Allocator) Settings { var self: Settings = .{ .arena = .init(alloc) }; var path_buf: [max_path]u8 = undefined; const path = configPath(&path_buf) orelse return self; var contents: [*]u8 = undefined; var length: usize = 0; var err: ?*glib.Error = null; if (glib.fileGetContents(path.ptr, &contents, &length, &err) == 0) { defer if (err) |e| e.free(); // No file yet is the normal state before anything has been changed. if (err) |e| { const missing = e.f_domain == glib.fileErrorQuark() and e.f_code == @intFromEnum(glib.FileError.noent); if (!missing) { std.log.warn("could not read settings: {s}", .{e.f_message orelse "unknown"}); } } return self; } defer glib.free(contents); if (length > max_file_size) { std.log.warn("settings file is implausibly large; using defaults", .{}); return self; } self.parse(contents[0..length]) catch { std.log.warn("could not parse {s}; using defaults", .{path}); // Whatever the failed parse allocated goes with the arena, and a // half-read startup list is worse than none: it would open some of the // tabs and then be written back as though that was all there was. _ = self.arena.reset(.free_all); return .{ .arena = self.arena }; }; return self; } const ParseError = error{ OutOfMemory, Malformed }; fn parse(self: *Settings, text: []const u8) ParseError!void { // Parsed into the arena and left there: the startup list points into this // tree's strings, so it has to outlive the call. const parsed = std.json.parseFromSliceLeaky( std.json.Value, self.arena.allocator(), text, .{}, ) catch return error.Malformed; const root = switch (parsed) { .object => |o| o, else => return error.Malformed, }; // An unknown value is treated as absent rather than as a failure. A file // written by a newer version naming a scheme this build has never heard of // should cost the user the default, not the whole file. if (root.get("theme")) |value| { if (value == .string) { if (std.meta.stringToEnum(Theme, value.string)) |t| self.theme = t; } } if (root.get("confirm_quit")) |value| { if (value == .bool) self.confirm_quit = value.bool; } if (root.get("herdr_core")) |value| { if (value == .bool) self.herdr_core = value.bool; } if (root.get("notifications")) |value| { if (value == .bool) self.notifications = value.bool; } 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); } /// Read the palette overrides: `{"dark": {"accent": "#ff8800"}, "light": {…}}`. /// /// Nothing here can fail the file. A color is one word of a palette, and the /// answer to a word we don't recognize 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 color 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 color", .{text}); continue; }; 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 colors 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 color", .{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 /// and writing `["ctrl+shift+h"]` for the common case would be noise. An empty /// list — or `null` — is how a shortcut is turned off, which is worth having: /// the chords here are ones a program running in the terminal may want back. /// /// Nothing here can fail the file, on the same grounds as the colors above. An /// action this build doesn't have, or a chord that doesn't parse, costs that one /// binding and is logged; what it falls back to is the shipped default, which is /// exactly what an absent entry already means. fn parseShortcuts(self: *Settings, raw: std.json.Value) void { const obj = switch (raw) { .object => |o| o, else => { std.log.warn("settings: \"shortcuts\" is not an object; ignoring it", .{}); return; }, }; var it = obj.iterator(); while (it.next()) |kv| { const action = std.meta.stringToEnum(shortcuts.Action, kv.key_ptr.*) orelse { std.log.warn("settings: no shortcut called \"{s}\"; ignoring it", .{kv.key_ptr.*}); continue; }; var set: shortcuts.ChordSet = .{}; switch (kv.value_ptr.*) { .string => |text| addChord(&set, text, action), .array => |items| for (items.items) |item| { switch (item) { .string => |text| addChord(&set, text, action), else => std.log.warn( "settings: shortcuts.{s} holds something that is not a chord", .{@tagName(action)}, ), } }, // Both spellings of "leave this key alone". .null => {}, else => { std.log.warn( "settings: shortcuts.{s} is not a chord or a list of them", .{@tagName(action)}, ); continue; }, } self.keys.set(action, set); } } fn addChord(set: *shortcuts.ChordSet, text: []const u8, action: shortcuts.Action) void { const chord = shortcuts.parse(text) orelse { std.log.warn( "settings: \"{s}\" is not a chord this can bind; shortcuts need Ctrl, Alt or Super", .{text}, ); return; }; if (!set.add(chord)) { std.log.warn("settings: {s} already has {d} chords; ignoring \"{s}\"", .{ @tagName(action), shortcuts.max_chords, text, }); } } /// Read the startup list. /// /// Entries that make no sense are skipped rather than failing the file. The /// list is hand-editable — that is half the point of it being a plain array of /// short objects — and one mistyped entry should cost that tab, not the theme /// and every other tab along with it. fn parseStartup(self: *Settings, raw: std.json.Value) ParseError!void { const array = switch (raw) { .array => |a| a, else => { std.log.warn("settings: \"startup\" is not a list; ignoring it", .{}); return; }, }; const alloc = self.arena.allocator(); var tabs: std.ArrayListUnmanaged(StartupTab) = .empty; try tabs.ensureTotalCapacity(alloc, array.items.len); for (array.items) |item| { const obj = switch (item) { .object => |o| o, else => { std.log.warn("settings: skipping a startup entry that is not an object", .{}); continue; }, }; var tab: StartupTab = .{ .layout = try self.dupeField(obj.get("layout")), .name = try self.dupeField(obj.get("name")), .emoji = try self.dupeField(obj.get("emoji")), }; if (obj.get("parameters")) |params_raw| { tab.parameters = try self.parseValues(params_raw); } tabs.appendAssumeCapacity(tab); } self.startup = try tabs.toOwnedSlice(alloc); } /// `{"name": "value"}` — an object rather than an array of pairs, because a /// parameter can only be filled in once per tab and an object is the shape /// that says so. fn parseValues(self: *Settings, raw: std.json.Value) ParseError![]const Value { const obj = switch (raw) { .object => |o| o, else => { std.log.warn("settings: a startup entry's \"parameters\" is not an object", .{}); return &.{}; }, }; const alloc = self.arena.allocator(); var values: std.ArrayListUnmanaged(Value) = .empty; try values.ensureTotalCapacity(alloc, obj.count()); var it = obj.iterator(); while (it.next()) |kv| { values.appendAssumeCapacity(.{ .name = try alloc.dupe(u8, kv.key_ptr.*), .value = try self.dupeField(kv.value_ptr.*), }); } return values.toOwnedSlice(alloc); } /// A string field, or an empty one for anything else — including a number or a /// null where a string was expected, which is a typo rather than a reason to /// throw the file away. fn dupeField(self: *Settings, raw: ?std.json.Value) ParseError![]const u8 { return switch (raw orelse return "") { .string => |s| try self.arena.allocator().dupe(u8, s), else => "", }; } // ------------------------------------------------------------------------- // Editing /// Replace the startup list with a copy of `entries`. /// /// Copied into a fresh arena which then takes the old one's place, rather than /// resetting in place and copying into that. It costs one allocation and makes /// the obvious call — hand back a lightly edited `settings.startup` — safe, /// instead of freeing the strings being read from half way through. pub fn setStartup(self: *Settings, entries: []const StartupTab) error{OutOfMemory}!void { var next: std.heap.ArenaAllocator = .init(self.arena.child_allocator); errdefer next.deinit(); const alloc = next.allocator(); const tabs = try alloc.alloc(StartupTab, entries.len); for (entries, tabs) |from, *to| { const values = try alloc.alloc(Value, from.parameters.len); for (from.parameters, values) |v, *out| { out.* = .{ .name = try alloc.dupe(u8, v.name), .value = try alloc.dupe(u8, v.value), }; } to.* = .{ .layout = try alloc.dupe(u8, from.layout), .name = try alloc.dupe(u8, from.name), .emoji = try alloc.dupe(u8, from.emoji), .parameters = values, }; } self.arena.deinit(); self.arena = next; self.startup = tabs; } // ------------------------------------------------------------------------- // Saving pub const SaveError = error{ OutOfMemory, WriteFailed }; /// Write the whole file back out, creating the config directory if needed. pub fn save(self: *Settings) SaveError!void { var dir_buf: [max_path]u8 = undefined; const dir = configDir(&dir_buf) orelse return error.WriteFailed; if (glib.mkdirWithParents(dir.ptr, 0o700) != 0) return error.WriteFailed; var path_buf: [max_path]u8 = undefined; const path = configPath(&path_buf) orelse return error.WriteFailed; const text = try self.serialize(); defer self.arena.child_allocator.free(text); var err: ?*glib.Error = null; if (glib.fileSetContents(path.ptr, text.ptr, @intCast(text.len), &err) == 0) { if (err) |e| e.free(); return error.WriteFailed; } } /// Through the JSON emitter rather than by hand, as this once was: the startup /// list carries names and paths the user typed, and those need escaping. fn serialize(self: *Settings) SaveError![]u8 { var out: std.Io.Writer.Allocating = .init(self.arena.child_allocator); errdefer out.deinit(); var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{ .whitespace = .indent_2 }, }; try json.beginObject(); try json.objectField("version"); try json.write(format_version); try json.objectField("theme"); try json.write(@tagName(self.theme)); try json.objectField("confirm_quit"); try json.write(self.confirm_quit); try json.objectField("herdr_core"); try json.write(self.herdr_core); try json.objectField("notifications"); try json.write(self.notifications); // Only the colors 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.overrides.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(); // And the base color 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 // colors — and with the same consequence, which is the one that matters // here: saving rewrites the whole file, so a hand-edited shortcut has to // come back out of this function or changing the theme would delete it. // Chords are written from the parsed form rather than the text they came // in as, so `Shift + CTRL + H` is read once and stored as `ctrl+shift+h`. try json.objectField("shortcuts"); try json.beginObject(); for (std.enums.values(shortcuts.Action)) |action| { const set = self.keys.get(action) orelse continue; try json.objectField(@tagName(action)); var buf: [shortcuts.text_len]u8 = undefined; if (set.len == 1) { try json.write(set.slice()[0].text(&buf)); continue; } // A list for none and for several. None is a shortcut someone has // switched off, and it has to survive the round trip as emphatically as // a bound one does: dropping it would hand the key back. try json.beginArray(); for (set.slice()) |c| try json.write(c.text(&buf)); try json.endArray(); } 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"); try json.beginArray(); for (self.startup) |tab| { try json.beginObject(); // Only what is set, so a hand-edited file stays readable. if (tab.layout.len > 0) { try json.objectField("layout"); try json.write(tab.layout); } if (tab.name.len > 0) { try json.objectField("name"); try json.write(tab.name); } if (tab.emoji.len > 0) { try json.objectField("emoji"); try json.write(tab.emoji); } if (tab.parameters.len > 0) { try json.objectField("parameters"); try json.beginObject(); for (tab.parameters) |v| { try json.objectField(v.name); try json.write(v.value); } try json.endObject(); } try json.endObject(); } try json.endArray(); try json.endObject(); // A trailing newline, so the file behaves in an editor. try out.writer.writeByte('\n'); return out.toOwnedSlice(); } /// `$XDG_CONFIG_HOME/playpen/settings.json`, or /// `~/.config/playpen/settings.json` when that isn't set. fn configPath(buf: []u8) ?[:0]const u8 { const dir = std.mem.span(glib.getUserConfigDir()); return std.mem.printSentinel(buf, "{s}/playpen/settings.json", .{dir}, 0) catch null; } fn configDir(buf: []u8) ?[:0]const u8 { const dir = std.mem.span(glib.getUserConfigDir()); return std.mem.printSentinel(buf, "{s}/playpen", .{dir}, 0) 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 color 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 "confirming a quit is on unless the file turns it off" { var settings = forTesting(); defer settings.arena.deinit(); // Nothing said about it: on. That is what anyone who never opens the // settings page gets, and it is the half of this that has to be right. try std.testing.expect(settings.confirm_quit); try settings.parse( \\{"theme": "dark", "confirm_quit": false} ); try std.testing.expect(!settings.confirm_quit); // And it goes back out. Saving rewrites the whole file, so a flag that // didn't come out of `serialize` would be handed back by the next theme // change — with a confirmation dialog someone had switched off. const text = try settings.serialize(); defer std.testing.allocator.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "\"confirm_quit\": false") != null); } test "notifications are on unless the file turns them off" { var settings = forTesting(); defer settings.arena.deinit(); try std.testing.expect(settings.notifications); try settings.parse( \\{"notifications": false} ); try std.testing.expect(!settings.notifications); const text = try settings.serialize(); defer std.testing.allocator.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "\"notifications\": false") != null); // And unreadable is the same as absent, which for this flag means on: the // failure someone would report is "it stopped telling me", not "it told me // and I had typed nonsense into the file". var typo = forTesting(); defer typo.arena.deinit(); try typo.parse( \\{"notifications": "off"} ); try std.testing.expect(typo.notifications); } test "a confirm_quit we can't read leaves the confirmation up" { var settings = forTesting(); defer settings.arena.deinit(); try settings.parse( \\{"confirm_quit": "no", "theme": "dark"} ); // Failing safe is the only direction worth failing in here, and the rest of // the file still lands. try std.testing.expect(settings.confirm_quit); try std.testing.expectEqual(Theme.dark, settings.theme); } 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.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 color 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.overrides.get(.accent)); try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar)); } test "a color we can't read costs that color and nothing else" { var settings = forTesting(); defer settings.arena.deinit(); try settings.parse( \\{"theme": "dark", "colors": {"dark": { \\ "not_a_color": "#123456", \\ "border": "rebeccapurple", \\ "sidebar": 12, \\ "accent": "#ff8800" \\}}} ); 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 colors and the // one color 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.overrides.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 colors are written, and they come back" { var settings = forTesting(); defer settings.arena.deinit(); 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); 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.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 color 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(); try settings.parse( \\{"shortcuts": { \\ "focus_pane_left": "ctrl+alt+h", \\ "toggle_zoom": ["ctrl+shift+z", "super+f"], \\ "new_tab": [] \\}} ); const left = settings.keys.get(.focus_pane_left).?; try std.testing.expectEqual(@as(usize, 1), left.len); try std.testing.expectEqual(shortcuts.parse("ctrl+alt+h").?, left.slice()[0]); const zoom = settings.keys.get(.toggle_zoom).?; try std.testing.expectEqual(@as(usize, 2), zoom.len); try std.testing.expectEqual(shortcuts.parse("super+f").?, zoom.slice()[1]); // Present but empty is a shortcut switched off, which is not the same as // absent — absent is the default, and this has to outrank it. try std.testing.expectEqual(@as(usize, 0), settings.keys.get(.new_tab).?.len); try std.testing.expectEqual(@as(?shortcuts.ChordSet, null), settings.keys.get(.close_pane)); } test "a chord we can't read costs that chord and nothing else" { var settings = forTesting(); defer settings.arena.deinit(); try settings.parse( \\{"theme": "dark", "shortcuts": { \\ "not_an_action": "ctrl+shift+q", \\ "next_tab": ["ctrl+nonesuch", "shift+j", "ctrl+alt+j"], \\ "prev_tab": 12 \\}} ); // Nothing bindable was named for prev_tab, so it keeps its defaults. try std.testing.expectEqual(@as(?shortcuts.ChordSet, null), settings.keys.get(.prev_tab)); // The unreadable chord and the one with no claiming modifier are dropped; // the third is kept, as is the theme above them. const next = settings.keys.get(.next_tab).?; try std.testing.expectEqual(@as(usize, 1), next.len); try std.testing.expectEqual(shortcuts.parse("ctrl+alt+j").?, next.slice()[0]); try std.testing.expectEqual(Theme.dark, settings.theme); } test "rebound shortcuts survive a save that was about something else" { var settings = forTesting(); defer settings.arena.deinit(); // The file as someone hand-edited it, then the theme change that rewrites // the whole thing. What is being checked is that the rewrite carries the // shortcuts back out with it. try settings.parse( \\{"shortcuts": {"focus_pane_down": "ctrl+alt+j", "copy": [], "toggle_zoom": ["ctrl+shift+z", "super+f"]}} ); settings.theme = .light; const text = try settings.serialize(); defer std.testing.allocator.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "\"focus_pane_down\": \"ctrl+alt+j\"") != null); try std.testing.expect(std.mem.indexOf(u8, text, "\"copy\": []") != null); // And nothing that was left at its default is written, so the file stays as // short as the changes it records. try std.testing.expect(std.mem.indexOf(u8, text, "\"new_tab\"") == null); var read_back = forTesting(); defer read_back.arena.deinit(); try read_back.parse(text); for (std.enums.values(shortcuts.Action)) |action| { try std.testing.expectEqual(settings.keys.get(action), read_back.keys.get(action)); } } test "shortcuts is written even when nothing has been rebound" { 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, "\"shortcuts\": {}") != null); } test "a scheme with nothing changed is left out of the file entirely" { var settings = forTesting(); defer settings.arena.deinit(); settings.colors.dark.overrides.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); }