Add custom layout creation.
This commit is contained in:
+573
@@ -0,0 +1,573 @@
|
||||
//! 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..] });
|
||||
}
|
||||
Reference in New Issue
Block a user