574 lines
18 KiB
Zig
574 lines
18 KiB
Zig
//! 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..] });
|
|
}
|