//! Saved layouts: named tab templates that open a whole arrangement of panes //! at once, each with a directory and a script to run. //! //! A layout is the shape of a tab (the same split tree a view uses) plus, per //! pane, what to start it with. Layouts take **parameters** — declared by name, //! filled in when you open one — and every `cwd`, `command` and `url` is run //! through `{{name}}` substitution first. That is what makes one layout usable //! against any number of projects. //! //! Everything lives in a single JSON file under the user's config directory. //! JSON because the app writes this file as well as reading it: layouts are //! authored in the app, and a format we can round-trip without a hand-written //! emitter is worth more here than a prettier one. //! //! All strings are owned by `arena`, so loading, adding and removing layouts //! never has to track individual allocations — the whole set is freed or //! replaced at once. //! //! File access goes through GLib rather than `std.fs`. GLib is already linked, //! it knows the XDG config directory, and `g_file_set_contents` writes to a //! temporary and renames — so an interrupted save leaves the previous layouts //! intact instead of a truncated file that won't parse. Zig's own filesystem //! API moved behind `std.Io` in 0.16 and would mean carrying an `Io` around //! for two calls. const std = @import("std"); const glib = @import("glib"); const Layouts = @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; /// Enough for any path this builds, without pulling in a std constant that /// has been moving between namespaces. const max_path = 4096; pub const Orientation = enum { horizontal, vertical }; /// What a pane can hold, mirroring `Pane.Kind`. Duplicated rather than /// imported so this module stays free of GTK and of the widget tree. pub const Kind = enum { terminal, web }; /// A value the user supplies when opening a layout. pub const Parameter = struct { name: []const u8, description: []const u8 = "", default: []const u8 = "", }; /// One pane's starting state. Which fields matter depends on `kind`; the /// others are kept as empty strings so a round-trip through the editor never /// silently drops something the user typed. pub const Pane = struct { kind: Kind = .terminal, /// Directory to start in. A leading `~` is expanded at open time. cwd: []const u8 = "", /// Script to run once the shell is up. Empty means "just a shell". command: []const u8 = "", /// Page to load, for web panes. url: []const u8 = "", }; pub const Node = union(enum) { pane: Pane, split: Split, }; pub const Split = struct { orientation: Orientation, ratio: f64 = 0.5, first: *Node, second: *Node, }; pub const Layout = struct { name: []const u8, parameters: []const Parameter = &.{}, root: *Node, }; arena: std.heap.ArenaAllocator, items: std.ArrayListUnmanaged(*Layout) = .empty, /// Set when the config file existed but could not be understood, so the UI can /// say so instead of silently presenting an empty list and then overwriting /// the file on the next save. load_error: ?[]const u8 = null, pub fn init(alloc: std.mem.Allocator) Layouts { return .{ .arena = .init(alloc) }; } pub fn deinit(self: *Layouts) void { const child = self.arena.child_allocator; self.items.deinit(child); self.arena.deinit(); } fn allocator(self: *Layouts) std.mem.Allocator { return self.arena.allocator(); } pub fn find(self: *Layouts, name: []const u8) ?*Layout { for (self.items.items) |layout| { if (std.mem.eql(u8, layout.name, name)) return layout; } return null; } // ------------------------------------------------------------------------- // Paths /// `$XDG_CONFIG_HOME/vtabs/layouts.json`, or `~/.config/vtabs/layouts.json` /// when that isn't set — whichever GLib reports as the user's config dir. pub fn configPath(buf: []u8) ?[:0]const u8 { const dir = std.mem.span(glib.getUserConfigDir()); return std.fmt.bufPrintZ(buf, "{s}/vtabs/layouts.json", .{dir}) catch null; } /// The directory `configPath` lives in. fn configDir(buf: []u8) ?[:0]const u8 { const dir = std.mem.span(glib.getUserConfigDir()); return std.fmt.bufPrintZ(buf, "{s}/vtabs", .{dir}) catch null; } // ------------------------------------------------------------------------- // Loading /// Replace the in-memory set with what is on disk. A missing file is not an /// error — it just means no layouts have been saved yet. pub fn load(self: *Layouts) void { self.items.clearRetainingCapacity(); self.load_error = null; _ = self.arena.reset(.retain_capacity); var path_buf: [max_path]u8 = undefined; const path = configPath(&path_buf) orelse return; 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 the first layout is saved, // not something to complain about. if (err) |e| { if (e.f_domain == glib.fileErrorQuark() and e.f_code == @intFromEnum(glib.FileError.noent)) return; self.setLoadError("{s}", .{e.f_message orelse "could not read layouts"}); } return; } defer glib.free(contents); self.parse(contents[0..length]) catch |parse_err| { self.setLoadError("could not parse {s}: {s}", .{ path, @errorName(parse_err) }); self.items.clearRetainingCapacity(); }; } fn setLoadError(self: *Layouts, comptime fmt: []const u8, args: anytype) void { self.load_error = std.fmt.allocPrint(self.allocator(), fmt, args) catch "layouts failed to load"; } const ParseError = error{ OutOfMemory, Malformed }; fn parse(self: *Layouts, text: []const u8) ParseError!void { // Parsed into the arena and left there: every string we keep points into // this tree, so it has to outlive the call. const parsed = std.json.parseFromSliceLeaky( std.json.Value, self.allocator(), text, .{}, ) catch return error.Malformed; const root = switch (parsed) { .object => |o| o, else => return error.Malformed, }; const list = switch (root.get("layouts") orelse return) { .array => |a| a, else => return error.Malformed, }; for (list.items) |entry| { const obj = switch (entry) { .object => |o| o, else => return error.Malformed, }; try self.parseLayout(obj); } } fn parseLayout(self: *Layouts, obj: std.json.ObjectMap) ParseError!void { const alloc = self.allocator(); const layout = try alloc.create(Layout); layout.* = .{ .name = try self.dupeString(obj.get("name") orelse return error.Malformed), .root = try self.parseNode(obj.get("root") orelse return error.Malformed), }; if (obj.get("parameters")) |raw| { const array = switch (raw) { .array => |a| a, else => return error.Malformed, }; const params = try alloc.alloc(Parameter, array.items.len); for (array.items, 0..) |item, i| { const p = switch (item) { .object => |o| o, else => return error.Malformed, }; params[i] = .{ .name = try self.dupeString(p.get("name") orelse return error.Malformed), .description = try self.optionalString(p.get("description")), .default = try self.optionalString(p.get("default")), }; } layout.parameters = params; } try self.items.append(self.arena.child_allocator, layout); } fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node { const obj = switch (raw) { .object => |o| o, else => return error.Malformed, }; const node = try self.allocator().create(Node); // A node is a split if it says which way it splits; otherwise it is a leaf. if (obj.get("split")) |split_raw| { const orientation = std.meta.stringToEnum( Orientation, switch (split_raw) { .string => |s| s, else => return error.Malformed, }, ) orelse return error.Malformed; node.* = .{ .split = .{ .orientation = orientation, .ratio = clampRatio(numberOr(obj.get("ratio"), 0.5)), .first = try self.parseNode(obj.get("first") orelse return error.Malformed), .second = try self.parseNode(obj.get("second") orelse return error.Malformed), } }; return node; } node.* = .{ .pane = .{ .kind = if (obj.get("kind")) |k| switch (k) { .string => |s| std.meta.stringToEnum(Kind, s) orelse return error.Malformed, else => return error.Malformed, } else .terminal, .cwd = try self.optionalString(obj.get("cwd")), .command = try self.optionalString(obj.get("command")), .url = try self.optionalString(obj.get("url")), } }; return node; } /// Keep dividers away from the extremes, where a pane would open with no /// usable area at all. fn clampRatio(value: f64) f64 { if (!std.math.isFinite(value)) return 0.5; return @min(@max(value, 0.05), 0.95); } fn numberOr(raw: ?std.json.Value, fallback: f64) f64 { return switch (raw orelse return fallback) { .float => |f| f, .integer => |i| @floatFromInt(i), else => fallback, }; } fn dupeString(self: *Layouts, raw: std.json.Value) ParseError![]const u8 { return switch (raw) { .string => |s| try self.allocator().dupe(u8, s), else => error.Malformed, }; } fn optionalString(self: *Layouts, raw: ?std.json.Value) ParseError![]const u8 { return switch (raw orelse return "") { .string => |s| try self.allocator().dupe(u8, s), .null => "", else => error.Malformed, }; } // ------------------------------------------------------------------------- // Saving pub const SaveError = error{ OutOfMemory, WriteFailed }; /// Write the whole set back out, creating the config directory if needed. pub fn save(self: *Layouts) 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; } } fn serialize(self: *Layouts) 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("layouts"); try json.beginArray(); for (self.items.items) |layout| try writeLayout(&json, layout); try json.endArray(); try json.endObject(); // A trailing newline, so the file behaves in an editor. try out.writer.writeByte('\n'); return out.toOwnedSlice(); } fn writeLayout(json: *std.json.Stringify, layout: *const Layout) SaveError!void { try json.beginObject(); try json.objectField("name"); try json.write(layout.name); if (layout.parameters.len > 0) { try json.objectField("parameters"); try json.beginArray(); for (layout.parameters) |p| { try json.beginObject(); try json.objectField("name"); try json.write(p.name); if (p.description.len > 0) { try json.objectField("description"); try json.write(p.description); } if (p.default.len > 0) { try json.objectField("default"); try json.write(p.default); } try json.endObject(); } try json.endArray(); } try json.objectField("root"); try writeNode(json, layout.root); try json.endObject(); } fn writeNode(json: *std.json.Stringify, node: *const Node) SaveError!void { try json.beginObject(); switch (node.*) { .split => |s| { try json.objectField("split"); try json.write(@tagName(s.orientation)); try json.objectField("ratio"); try json.write(s.ratio); try json.objectField("first"); try writeNode(json, s.first); try json.objectField("second"); try writeNode(json, s.second); }, .pane => |p| { try json.objectField("kind"); try json.write(@tagName(p.kind)); // Only what is set, so a hand-edited file stays readable. if (p.cwd.len > 0) { try json.objectField("cwd"); try json.write(p.cwd); } if (p.command.len > 0) { try json.objectField("command"); try json.write(p.command); } if (p.url.len > 0) { try json.objectField("url"); try json.write(p.url); } }, } try json.endObject(); } // ------------------------------------------------------------------------- // Editing // // Layouts are built in the app, so these take plain slices and copy everything // into the arena. Callers hand over borrowed strings and forget about them. pub const Builder = struct { layouts: *Layouts, pub fn node(self: Builder, value: Node) !*Node { const n = try self.layouts.allocator().create(Node); n.* = value; return n; } pub fn pane(self: Builder, spec: Pane) !*Node { return self.node(.{ .pane = .{ .kind = spec.kind, .cwd = try self.dupe(spec.cwd), .command = try self.dupe(spec.command), .url = try self.dupe(spec.url), } }); } pub fn split( self: Builder, orientation: Orientation, ratio: f64, first: *Node, second: *Node, ) !*Node { return self.node(.{ .split = .{ .orientation = orientation, .ratio = clampRatio(ratio), .first = first, .second = second, } }); } pub fn dupe(self: Builder, text: []const u8) ![]const u8 { return self.layouts.allocator().dupe(u8, text); } }; pub fn builder(self: *Layouts) Builder { return .{ .layouts = self }; } /// Add a layout, replacing any existing one with the same name. /// /// Replacing in place keeps the menu order stable when you re-save a layout /// you have just tweaked, which is the common case. pub fn put( self: *Layouts, name: []const u8, parameters: []const Parameter, root: *Node, ) !void { const alloc = self.allocator(); const params = try alloc.alloc(Parameter, parameters.len); for (parameters, 0..) |p, i| { params[i] = .{ .name = try alloc.dupe(u8, p.name), .description = try alloc.dupe(u8, p.description), .default = try alloc.dupe(u8, p.default), }; } if (self.find(name)) |existing| { existing.parameters = params; existing.root = root; return; } const layout = try alloc.create(Layout); layout.* = .{ .name = try alloc.dupe(u8, name), .parameters = params, .root = root, }; try self.items.append(self.arena.child_allocator, layout); } pub fn remove(self: *Layouts, name: []const u8) void { for (self.items.items, 0..) |layout, i| { if (std.mem.eql(u8, layout.name, name)) { _ = self.items.orderedRemove(i); return; } } } // ------------------------------------------------------------------------- // Parameter substitution /// One parameter's value for a single opening of a layout. pub const Binding = struct { name: []const u8, value: []const u8, }; /// Replace every `{{name}}` in `template` with its bound value. /// /// An unknown name is left exactly as written rather than blanked out: a /// literal `{{` in a script is far more likely to be a typo than a deliberate /// erasure, and leaving it visible makes that obvious in the pane. pub fn expand( alloc: std.mem.Allocator, template: []const u8, bindings: []const Binding, ) ![]u8 { var out: std.ArrayListUnmanaged(u8) = .empty; errdefer out.deinit(alloc); var rest = template; while (std.mem.indexOf(u8, rest, "{{")) |open| { const after = rest[open + 2 ..]; const close = std.mem.indexOf(u8, after, "}}") orelse break; const name = std.mem.trim(u8, after[0..close], &std.ascii.whitespace); try out.appendSlice(alloc, rest[0..open]); if (lookup(bindings, name)) |value| { try out.appendSlice(alloc, value); } else { try out.appendSlice(alloc, rest[open .. open + 2 + close + 2]); } rest = after[close + 2 ..]; } try out.appendSlice(alloc, rest); return out.toOwnedSlice(alloc); } fn lookup(bindings: []const Binding, name: []const u8) ?[]const u8 { for (bindings) |b| { if (std.mem.eql(u8, b.name, name)) return b.value; } return null; } /// Expand a path the way a shell would treat it: `{{...}}` first, then a /// leading `~`. Returns an empty slice for an empty template, meaning /// "wherever the app was started". pub fn expandPath( alloc: std.mem.Allocator, template: []const u8, bindings: []const Binding, ) ![]u8 { const expanded = try expand(alloc, template, bindings); if (!std.mem.startsWith(u8, expanded, "~")) return expanded; if (expanded.len > 1 and expanded[1] != '/') return expanded; const home = std.mem.span(glib.getHomeDir()); if (home.len == 0) return expanded; defer alloc.free(expanded); return std.fmt.allocPrint(alloc, "{s}{s}", .{ home, expanded[1..] }); }