Add ability to set boot layout.
This commit is contained in:
+286
-38
@@ -1,18 +1,25 @@
|
||||
//! User preferences: the handful of choices that outlive a session.
|
||||
//! User preferences: the choices that outlive a session.
|
||||
//!
|
||||
//! A small JSON file next to `layouts.json`, rewritten whole every time
|
||||
//! something changes. There is one setting today — the colour scheme — but the
|
||||
//! file carries a version from the start, so adding the second one is not also
|
||||
//! the day we invent a format.
|
||||
//! something changes. It carries a version from the start, so adding the next
|
||||
//! setting is not also the day we invent a format.
|
||||
//!
|
||||
//! There are two settings, and they are very different shapes. The colour
|
||||
//! scheme is one word. The **startup tabs** are a list: which saved layout to
|
||||
//! open, what to fill its parameters in with, and what to call the tab — one
|
||||
//! entry per tab, opened in order at launch. That list is why this module holds
|
||||
//! an arena: every string in it is owned here, and replacing the list frees the
|
||||
//! previous one in a single stroke rather than tracking each field.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! Unlike `Layouts` this holds no allocated strings — every field is a fixed
|
||||
//! scalar — so it needs no arena and can be copied freely. Parsing borrows a
|
||||
//! stack buffer for the duration of the call and gives it back.
|
||||
|
||||
const std = @import("std");
|
||||
const glib = @import("glib");
|
||||
@@ -25,11 +32,10 @@ pub const format_version = 1;
|
||||
|
||||
const max_path = 4096;
|
||||
|
||||
/// Ample for a file holding a version and a word, with enough headroom that a
|
||||
/// hand-edited one with comments or extra keys still parses. A settings file
|
||||
/// larger than this is not one we wrote, and defaults are the better answer
|
||||
/// than a partial read of it.
|
||||
const max_file_size = 16 * 1024;
|
||||
/// 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.
|
||||
///
|
||||
@@ -52,8 +58,77 @@ pub const Theme = enum {
|
||||
}
|
||||
};
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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 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 behaviour — carry on with defaults —
|
||||
@@ -61,8 +136,8 @@ theme: Theme = .system,
|
||||
/// 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.
|
||||
pub fn load() Settings {
|
||||
var self: Settings = .{};
|
||||
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;
|
||||
@@ -92,25 +167,27 @@ pub fn load() Settings {
|
||||
|
||||
self.parse(contents[0..length]) catch {
|
||||
std.log.warn("could not parse {s}; using defaults", .{path});
|
||||
return .{};
|
||||
// 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;
|
||||
}
|
||||
|
||||
fn parse(self: *Settings, text: []const u8) !void {
|
||||
// The parse tree only has to outlive this function: every value read out
|
||||
// of it is copied into a scalar field, so a stack arena is enough and
|
||||
// nothing here needs to reach the caller's allocator.
|
||||
var buf: [max_file_size * 4]u8 = undefined;
|
||||
var fba: std.heap.FixedBufferAllocator = .init(&buf);
|
||||
const ParseError = error{ OutOfMemory, Malformed };
|
||||
|
||||
const parsed = try std.json.parseFromSliceLeaky(
|
||||
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,
|
||||
fba.allocator(),
|
||||
self.arena.allocator(),
|
||||
text,
|
||||
.{},
|
||||
);
|
||||
) catch return error.Malformed;
|
||||
|
||||
const root = switch (parsed) {
|
||||
.object => |o| o,
|
||||
@@ -125,12 +202,137 @@ fn parse(self: *Settings, text: []const u8) !void {
|
||||
if (std.meta.stringToEnum(Theme, value.string)) |t| self.theme = t;
|
||||
}
|
||||
}
|
||||
|
||||
if (root.get("startup")) |value| try self.parseStartup(value);
|
||||
}
|
||||
|
||||
pub const SaveError = error{WriteFailed};
|
||||
/// 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 {
|
||||
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;
|
||||
@@ -138,17 +340,8 @@ pub fn save(self: Settings) SaveError!void {
|
||||
var path_buf: [max_path]u8 = undefined;
|
||||
const path = configPath(&path_buf) orelse return error.WriteFailed;
|
||||
|
||||
// Written by hand rather than through the JSON emitter: this is a fixed
|
||||
// two-line document with no user-supplied strings in it, so there is
|
||||
// nothing here that needs escaping and nothing that needs an allocator.
|
||||
var text_buf: [256]u8 = undefined;
|
||||
const text = std.fmt.bufPrint(&text_buf,
|
||||
\\{{
|
||||
\\ "version": {d},
|
||||
\\ "theme": "{s}"
|
||||
\\}}
|
||||
\\
|
||||
, .{ format_version, @tagName(self.theme) }) catch 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) {
|
||||
@@ -157,6 +350,61 @@ pub fn save(self: Settings) SaveError!void {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user