Improve styling.

This commit is contained in:
Greyson Parrelli
2026-08-12 21:15:07 -04:00
parent 4e0e400372
commit e16b147e16
13 changed files with 1104 additions and 119 deletions
+170
View File
@@ -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;
}