Improve styling.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
//! User preferences: the handful of 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.
|
||||
//!
|
||||
//! 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");
|
||||
|
||||
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 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;
|
||||
|
||||
/// 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",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
theme: Theme = .system,
|
||||
|
||||
/// Read the settings file, falling back to defaults for anything missing.
|
||||
///
|
||||
/// Every failure below lands on the same behaviour — 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.
|
||||
pub fn load() Settings {
|
||||
var self: Settings = .{};
|
||||
|
||||
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});
|
||||
return .{};
|
||||
};
|
||||
|
||||
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 parsed = try std.json.parseFromSliceLeaky(
|
||||
std.json.Value,
|
||||
fba.allocator(),
|
||||
text,
|
||||
.{},
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const SaveError = error{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;
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// `$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.fmt.bufPrintZ(buf, "{s}/playpen/settings.json", .{dir}) catch null;
|
||||
}
|
||||
|
||||
fn configDir(buf: []u8) ?[:0]const u8 {
|
||||
const dir = std.mem.span(glib.getUserConfigDir());
|
||||
return std.fmt.bufPrintZ(buf, "{s}/playpen", .{dir}) catch null;
|
||||
}
|
||||
Reference in New Issue
Block a user