1100 lines
41 KiB
Zig
1100 lines
41 KiB
Zig
//! 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);
|
|
}
|