Add custom layout creation.

This commit is contained in:
Greyson Parrelli
2026-08-11 13:57:54 -04:00
parent a7a9429d09
commit 3026bdcd4c
14 changed files with 1964 additions and 20 deletions
+23 -1
View File
@@ -49,7 +49,17 @@ on_exit: *const fn (ctx: ?*anyopaque) void,
on_focus: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser {
/// What a layout can specify for a web pane.
pub const Options = struct {
/// Page to open with. Empty leaves the pane on its address bar.
url: []const u8 = "",
};
pub fn create(
alloc: std.mem.Allocator,
opts: Options,
cbs: Pane.Callbacks,
) !*Browser {
const self = try alloc.create(Browser);
errdefer alloc.destroy(self);
@@ -99,6 +109,11 @@ pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser {
self.box.as(gtk.Widget).addController(focus.as(gtk.EventController));
self.syncNav();
// Navigating here rather than after the widget is realized is fine: the
// load runs on the main loop and the view catches up when it appears.
if (opts.url.len > 0) self.navigate(opts.url);
return self;
}
@@ -147,6 +162,13 @@ pub fn title(self: *Browser) []const u8 {
return "web";
}
/// The page currently loaded, or an empty slice if there isn't one. Borrowed
/// from WebKit, so copy it if it needs to outlive the next navigation.
pub fn currentUrl(self: *Browser) []const u8 {
const uri = self.view.getUri() orelse return "";
return std.mem.span(uri);
}
/// Load an address, applying the same interpretation the address bar does.
pub fn navigate(self: *Browser, input: []const u8) void {
const uri = self.resolve(input) orelse return;
+16
View File
@@ -157,6 +157,22 @@ pub fn remove(self: *Layout, node: *Node) void {
self.alloc.destroy(parent);
}
/// Build a split holding two existing subtrees.
///
/// `insert` grows a tree one pane at a time, which is the right shape for
/// interactive splitting but can't express an arbitrary arrangement with
/// per-split ratios. Opening a saved layout needs exactly that, so it builds
/// the tree bottom-up through this instead.
pub fn newSplit(
self: *Layout,
orientation: gtk.Orientation,
a: *Node,
b: *Node,
ratio: f64,
) !*Node {
return self.makeSplit(orientation, a, b, ratio);
}
fn makeSplit(
self: *Layout,
orientation: gtk.Orientation,
+573
View File
@@ -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..] });
}
+170
View File
@@ -0,0 +1,170 @@
//! "Open layout": one field per parameter, prefilled with its default.
//!
//! A layout with no parameters never gets here — the caller opens it straight
//! away rather than showing an empty dialog with nothing to fill in.
//!
//! The values are handed back as borrowed slices pointing into the entries,
//! which is safe because the callback runs while the dialog is still up and
//! the view copies everything during substitution.
const std = @import("std");
const gtk = @import("gtk");
const Layouts = @import("Layouts.zig");
const OpenLayoutDialog = @This();
pub const Callback = *const fn (
ctx: ?*anyopaque,
layout: *Layouts.Layout,
bindings: []const Layouts.Binding,
) void;
alloc: std.mem.Allocator,
window: *gtk.Window,
layout: *Layouts.Layout,
/// One entry per parameter, in the layout's own order.
entries: []*gtk.Entry,
on_open: Callback,
ctx: ?*anyopaque,
pub fn present(
alloc: std.mem.Allocator,
parent: *gtk.Window,
layout: *Layouts.Layout,
on_open: Callback,
ctx: ?*anyopaque,
) !void {
const self = try alloc.create(OpenLayoutDialog);
errdefer alloc.destroy(self);
const entries = try alloc.alloc(*gtk.Entry, layout.parameters.len);
errdefer alloc.free(entries);
self.* = .{
.alloc = alloc,
.window = gtk.Window.new(),
.layout = layout,
.entries = entries,
.on_open = on_open,
.ctx = ctx,
};
var title_buf: [128]u8 = undefined;
const title = std.fmt.bufPrintZ(&title_buf, "Open “{s}”", .{layout.name}) catch "Open layout";
self.window.setTitle(title);
self.window.setTransientFor(parent);
self.window.setModal(1);
self.window.setDefaultSize(480, -1);
self.window.as(gtk.Widget).addCssClass("vtabs-dialog");
const content = gtk.Box.new(.vertical, 12);
content.as(gtk.Widget).addCssClass("vtabs-dialog-content");
const fields = gtk.Grid.new();
fields.setRowSpacing(8);
fields.setColumnSpacing(12);
for (layout.parameters, 0..) |param, i| {
const label = gtk.Label.new(null);
// The description is what the author wrote for a human; the bare name
// is the fallback so a parameter is never unlabelled.
var label_buf: [128]u8 = undefined;
const text = if (param.description.len > 0) param.description else param.name;
label.setText(std.fmt.bufPrintZ(&label_buf, "{s}", .{text}) catch "parameter");
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-label");
fields.attach(label.as(gtk.Widget), 0, @intCast(i), 1, 1);
const entry = gtk.Entry.new();
entry.as(gtk.Widget).setHexpand(1);
setEntryText(entry, param.default);
var hint_buf: [128]u8 = undefined;
entry.setPlaceholderText(
std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null,
);
// Enter anywhere in the form opens the layout, so a one-parameter
// layout is type-and-go.
_ = gtk.Entry.signals.activate.connect(
entry,
*OpenLayoutDialog,
&onEntryActivate,
self,
.{},
);
fields.attach(entry.as(gtk.Widget), 1, @intCast(i), 1, 1);
entries[i] = entry;
}
content.append(fields.as(gtk.Widget));
const buttons = gtk.Box.new(.horizontal, 8);
buttons.as(gtk.Widget).setHalign(.end);
const cancel = gtk.Button.newWithLabel("Cancel");
_ = gtk.Button.signals.clicked.connect(cancel, *OpenLayoutDialog, &onCancel, self, .{});
buttons.append(cancel.as(gtk.Widget));
const open = gtk.Button.newWithLabel("Open");
open.as(gtk.Widget).addCssClass("suggested-action");
_ = gtk.Button.signals.clicked.connect(open, *OpenLayoutDialog, &onOpen, self, .{});
buttons.append(open.as(gtk.Widget));
content.append(buttons.as(gtk.Widget));
self.window.setChild(content.as(gtk.Widget));
_ = gtk.Widget.signals.destroy.connect(
self.window,
*OpenLayoutDialog,
&onDestroy,
self,
.{},
);
self.window.present();
if (entries.len > 0) _ = entries[0].as(gtk.Widget).grabFocus();
}
fn setEntryText(entry: *gtk.Entry, text: []const u8) void {
var buf: [1024]u8 = undefined;
const z = std.fmt.bufPrintZ(&buf, "{s}", .{text}) catch return;
entry.as(gtk.Editable).setText(z);
}
fn submit(self: *OpenLayoutDialog) void {
var storage: [16]Layouts.Binding = undefined;
const count = @min(self.entries.len, storage.len);
for (self.entries[0..count], 0..) |entry, i| {
storage[i] = .{
.name = self.layout.parameters[i].name,
.value = std.mem.span(entry.as(gtk.Editable).getText()),
};
}
// The callback builds the tab while this window is still alive, so the
// borrowed entry text stays valid for the whole substitution pass.
self.on_open(self.ctx, self.layout, storage[0..count]);
self.window.destroy();
}
fn onOpen(_: *gtk.Button, self: *OpenLayoutDialog) callconv(.c) void {
self.submit();
}
fn onEntryActivate(_: *gtk.Entry, self: *OpenLayoutDialog) callconv(.c) void {
self.submit();
}
fn onCancel(_: *gtk.Button, self: *OpenLayoutDialog) callconv(.c) void {
self.window.destroy();
}
fn onDestroy(_: *gtk.Window, self: *OpenLayoutDialog) callconv(.c) void {
self.alloc.free(self.entries);
self.alloc.destroy(self);
}
+29 -7
View File
@@ -49,6 +49,26 @@ pub const Kind = enum {
}
};
/// Everything needed to open a pane: which kind, and what that kind needs to
/// start with. A pane opened the ordinary way uses the defaults; one opened
/// from a layout carries that layout's directory, script or URL.
pub const Spec = union(Kind) {
terminal: Terminal.Options,
web: Browser.Options,
/// A plain pane of the given kind, with nothing preloaded.
pub fn plain(of: Kind) Spec {
return switch (of) {
.terminal => .{ .terminal = .{} },
.web => .{ .web = .{} },
};
}
pub fn kind(self: Spec) Kind {
return self;
}
};
/// What a content kind reports back to its pane. Shared by both kinds so the
/// pane can wire either one up with the same handlers.
pub const Callbacks = struct {
@@ -108,10 +128,12 @@ label: *gtk.Label,
/// Latest title reported by the content, kept NUL-terminated for GTK.
title: [128:0]u8 = @splat(0),
pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane {
pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
const self = try alloc.create(Pane);
errdefer alloc.destroy(self);
const kind = spec.kind();
self.* = .{
.alloc = alloc,
.view = view,
@@ -124,16 +146,16 @@ pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane {
setTitle(self, kind.initialTitle());
// Both constructors take the same callbacks, so the only thing that
// varies between kinds is which one is called.
// varies between kinds is which one is called and what it is given.
const callbacks: Callbacks = .{
.on_title = &onContentTitle,
.on_exit = &onContentExit,
.on_focus = &onContentFocus,
.ctx = self,
};
self.content = switch (kind) {
.terminal => .{ .terminal = try .create(alloc, callbacks) },
.web => .{ .web = try .create(alloc, callbacks) },
self.content = switch (spec) {
.terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) },
.web => |opts| .{ .web = try .create(alloc, opts, callbacks) },
};
errdefer self.content.destroy();
@@ -396,13 +418,13 @@ fn onContentFocus(ctx: ?*anyopaque) void {
}
fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.addPane(.terminal) catch |err| {
self.view.addPane(.plain(.terminal)) catch |err| {
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
};
}
fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.addPane(.web) catch |err| {
self.view.addPane(.plain(.web)) catch |err| {
std.log.err("failed to open web view: {s}", .{@errorName(err)});
};
}
+38 -1
View File
@@ -27,6 +27,8 @@ const c = struct {
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
extern "c" fn setsid() pid_t;
extern "c" fn fork() pid_t;
extern "c" fn chdir(path: [*:0]const u8) c_int;
extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsiz: usize) isize;
extern "c" fn close(fd: fd_t) c_int;
extern "c" fn dup2(old: fd_t, new: fd_t) c_int;
extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int;
@@ -86,10 +88,15 @@ pub const Error = error{
/// started by running e.g. /usr/bin/zsh with an argv[0] of "-zsh"; folding
/// them together would pass "-zsh" as an ordinary argument instead, which
/// shells either misparse or reject outright.
///
/// `dir` is the directory the child starts in. A null, or a directory that
/// cannot be entered, leaves it wherever the app was started — a layout
/// naming a path that no longer exists should still give you a usable shell.
pub fn create(
alloc: std.mem.Allocator,
path: [:0]const u8,
argv: []const [:0]const u8,
dir: ?[:0]const u8,
size: Winsize,
) !Pty {
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
@@ -120,7 +127,7 @@ pub fn create(
const pid = c.fork();
if (pid < 0) return Error.ForkFailed;
if (pid == 0) {
childExec(master, slave_path_z, path, argv_z, envp_z);
childExec(master, slave_path_z, path, argv_z, envp_z, dir);
// childExec only returns on failure, and a forked child has no
// sensible way to report that back to us.
c._exit(127);
@@ -136,9 +143,17 @@ fn childExec(
path: [:0]const u8,
argv: [:null]const ?[*:0]const u8,
envp: [:null]const ?[*:0]const u8,
dir: ?[:0]const u8,
) void {
_ = c.close(master);
// Deliberately unchecked: a layout pointing at a directory that has since
// been moved or deleted should still open a shell, in the directory the
// app was started from, rather than a pane that dies on arrival.
if (dir) |d| if (d.len > 0) {
_ = c.chdir(d.ptr);
};
// A new session detaches us from the parent's controlling terminal so
// that we can claim the slave as our own below.
if (c.setsid() < 0) return;
@@ -211,6 +226,28 @@ fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
alloc.free(envp);
}
/// The child's current working directory, copied into `buf`, or null if it
/// can't be read.
///
/// This is the shell's directory as of right now, not the one it started in,
/// which is what makes "save this tab as a layout" record where you actually
/// are rather than where you began. Read straight out of procfs: there is no
/// portable API for it, and this app is Linux-only regardless.
pub fn cwd(self: Pty, buf: []u8) ?[]const u8 {
var link_buf: [64]u8 = undefined;
const link = std.fmt.bufPrintZ(&link_buf, "/proc/{d}/cwd", .{self.pid}) catch return null;
const n = c.readlink(link.ptr, buf.ptr, buf.len);
if (n <= 0) return null;
const len: usize = @intCast(n);
// readlink truncates rather than failing, and a truncated path is worse
// than none: it would silently point somewhere else.
if (len >= buf.len) return null;
return buf[0..len];
}
/// Tell the child its window changed size. This both updates the kernel's
/// idea of the terminal size and delivers SIGWINCH to the foreground group.
pub fn setSize(self: Pty, size: Winsize) void {
+481
View File
@@ -0,0 +1,481 @@
//! The layout editor, used both for saving the current tab as a layout and for
//! editing one that is already saved. Either way the job is the same: name the
//! arrangement, declare its parameters, and say what each pane starts with.
//!
//! The shape is not edited here. When saving, it was captured from the live tab
//! before this opened, splits and ratios and all; when editing, it is whatever
//! the layout already had. That is why there is no layout builder in this app —
//! you build a layout by arranging a tab the way you already do, and reshape an
//! existing one by opening it, rearranging, and saving over it. What is left is
//! the part the app cannot infer: the scripts.
//!
//! The tree is allocated in the layout store's arena in both cases, so saving
//! writes the edited strings straight back into its leaves. Nothing is written
//! until the confirm button, so cancelling an edit leaves the layout alone.
const std = @import("std");
const gtk = @import("gtk");
const Layouts = @import("Layouts.zig");
const SaveLayoutDialog = @This();
pub const Callback = *const fn (ctx: ?*anyopaque) void;
/// A parameter being edited. Rows can be added and removed, so each one owns
/// its widgets and is tracked by pointer rather than by index.
const ParamRow = struct {
dialog: *SaveLayoutDialog,
box: *gtk.Box,
name: *gtk.Entry,
description: *gtk.Entry,
default: *gtk.Entry,
};
/// One captured pane and the fields that fill it in.
const PaneRow = struct {
node: *Layouts.Node,
/// Directory and script, for a terminal pane.
cwd: ?*gtk.Entry = null,
command: ?*gtk.Entry = null,
/// Page to open, for a web pane.
url: ?*gtk.Entry = null,
};
/// How the editor was opened. The two differ only in wording and in whether
/// there is an existing entry to replace.
pub const Options = struct {
/// Window title.
title: [:0]const u8,
/// Verb on the confirm button.
confirm: [:0]const u8,
/// What the name field starts as.
name: []const u8,
/// Parameter rows to start with, for editing a layout that has some.
parameters: []const Layouts.Parameter = &.{},
/// The name this layout is currently filed under, when editing. Renaming
/// then has to drop the old entry, or the edit would leave the original
/// sitting in the menu alongside its replacement.
original_name: ?[]const u8 = null,
};
alloc: std.mem.Allocator,
layouts: *Layouts,
window: *gtk.Window,
root: *Layouts.Node,
original_name: ?[]const u8,
name_entry: *gtk.Entry,
/// Container the parameter rows live in, so rows can be appended after the
/// dialog is already on screen.
params_box: *gtk.Box,
params: std.ArrayListUnmanaged(*ParamRow) = .empty,
panes: []PaneRow,
/// Shown instead of silently doing nothing when the name is empty or the
/// write fails.
error_label: *gtk.Label,
on_saved: Callback,
ctx: ?*anyopaque,
pub fn present(
alloc: std.mem.Allocator,
parent: *gtk.Window,
layouts: *Layouts,
root: *Layouts.Node,
opts: Options,
on_saved: Callback,
ctx: ?*anyopaque,
) !void {
const self = try alloc.create(SaveLayoutDialog);
errdefer alloc.destroy(self);
self.* = .{
.alloc = alloc,
.layouts = layouts,
.window = gtk.Window.new(),
.root = root,
.original_name = opts.original_name,
.name_entry = gtk.Entry.new(),
.params_box = gtk.Box.new(.vertical, 6),
.panes = undefined,
.error_label = gtk.Label.new(null),
.on_saved = on_saved,
.ctx = ctx,
};
self.window.setTitle(opts.title);
self.window.setTransientFor(parent);
self.window.setModal(1);
self.window.setDefaultSize(560, 620);
self.window.as(gtk.Widget).addCssClass("vtabs-dialog");
const content = gtk.Box.new(.vertical, 12);
content.as(gtk.Widget).addCssClass("vtabs-dialog-content");
// ---- name ----------------------------------------------------------
content.append(heading("Name"));
setEntryText(self.name_entry, opts.name);
self.name_entry.setPlaceholderText("Layout name");
_ = gtk.Entry.signals.activate.connect(
self.name_entry,
*SaveLayoutDialog,
&onNameActivate,
self,
.{},
);
content.append(self.name_entry.as(gtk.Widget));
// ---- parameters ----------------------------------------------------
content.append(heading("Parameters"));
content.append(hint(
"Referred to as {{name}} in any directory, script or address below.",
));
content.append(self.params_box.as(gtk.Widget));
for (opts.parameters) |param| try self.addParamRow(param);
const add = gtk.Button.newWithLabel("Add parameter");
add.as(gtk.Widget).setHalign(.start);
add.as(gtk.Widget).addCssClass("flat");
_ = gtk.Button.signals.clicked.connect(add, *SaveLayoutDialog, &onAddParam, self, .{});
content.append(add.as(gtk.Widget));
// ---- panes -----------------------------------------------------------
content.append(heading("Panes"));
// Leaves in tree order, which reads top-left to bottom-right on screen.
var leaves: std.ArrayListUnmanaged(*Layouts.Node) = .empty;
defer leaves.deinit(alloc);
try collectLeaves(alloc, root, &leaves);
self.panes = try alloc.alloc(PaneRow, leaves.items.len);
errdefer alloc.free(self.panes);
for (leaves.items, 0..) |leaf, i| {
self.panes[i] = .{ .node = leaf };
content.append(self.buildPaneSection(i, leaf));
}
// ---- footer ----------------------------------------------------------
self.error_label.setXalign(0);
self.error_label.as(gtk.Widget).addCssClass("vtabs-dialog-error");
self.error_label.as(gtk.Widget).setVisible(0);
content.append(self.error_label.as(gtk.Widget));
const scroller = gtk.ScrolledWindow.new();
scroller.setPolicy(.never, .automatic);
scroller.as(gtk.Widget).setVexpand(1);
scroller.setChild(content.as(gtk.Widget));
const buttons = gtk.Box.new(.horizontal, 8);
buttons.as(gtk.Widget).setHalign(.end);
buttons.as(gtk.Widget).addCssClass("vtabs-dialog-actions");
const cancel = gtk.Button.newWithLabel("Cancel");
_ = gtk.Button.signals.clicked.connect(cancel, *SaveLayoutDialog, &onCancel, self, .{});
buttons.append(cancel.as(gtk.Widget));
const save = gtk.Button.newWithLabel(opts.confirm);
save.as(gtk.Widget).addCssClass("suggested-action");
_ = gtk.Button.signals.clicked.connect(save, *SaveLayoutDialog, &onSave, self, .{});
buttons.append(save.as(gtk.Widget));
const outer = gtk.Box.new(.vertical, 0);
outer.append(scroller.as(gtk.Widget));
outer.append(buttons.as(gtk.Widget));
self.window.setChild(outer.as(gtk.Widget));
_ = gtk.Widget.signals.destroy.connect(
self.window,
*SaveLayoutDialog,
&onDestroy,
self,
.{},
);
self.window.present();
_ = self.name_entry.as(gtk.Widget).grabFocus();
}
/// The fields for one captured pane, prefilled with whatever could be read off
/// the live pane: a terminal's current directory, a web pane's current page.
fn buildPaneSection(self: *SaveLayoutDialog, index: usize, leaf: *Layouts.Node) *gtk.Widget {
const spec = leaf.pane;
const box = gtk.Box.new(.vertical, 4);
box.as(gtk.Widget).addCssClass("vtabs-dialog-pane");
var title_buf: [64]u8 = undefined;
const title = std.fmt.bufPrintZ(&title_buf, "Pane {d} — {s}", .{
index + 1,
@tagName(spec.kind),
}) catch "Pane";
const label = gtk.Label.new(title);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-label");
box.append(label.as(gtk.Widget));
const grid = gtk.Grid.new();
grid.setRowSpacing(4);
grid.setColumnSpacing(8);
switch (spec.kind) {
.terminal => {
const cwd = field(grid, 0, "Directory", spec.cwd, "~/projects/{{path}}");
const command = field(grid, 1, "Script", spec.command, "npm run dev");
self.panes[index].cwd = cwd;
self.panes[index].command = command;
},
.web => {
const url = field(grid, 0, "Address", spec.url, "https://example.com");
self.panes[index].url = url;
},
}
box.append(grid.as(gtk.Widget));
return box.as(gtk.Widget);
}
fn field(
grid: *gtk.Grid,
row: c_int,
label_text: [:0]const u8,
value: []const u8,
placeholder: [:0]const u8,
) *gtk.Entry {
const label = gtk.Label.new(label_text);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-sublabel");
grid.attach(label.as(gtk.Widget), 0, row, 1, 1);
const entry = gtk.Entry.new();
entry.as(gtk.Widget).setHexpand(1);
entry.setPlaceholderText(placeholder);
setEntryText(entry, value);
grid.attach(entry.as(gtk.Widget), 1, row, 1, 1);
return entry;
}
fn collectLeaves(
alloc: std.mem.Allocator,
node: *Layouts.Node,
out: *std.ArrayListUnmanaged(*Layouts.Node),
) !void {
switch (node.*) {
.pane => try out.append(alloc, node),
.split => |s| {
try collectLeaves(alloc, s.first, out);
try collectLeaves(alloc, s.second, out);
},
}
}
// -------------------------------------------------------------------------
// Parameter rows
fn addParamRow(self: *SaveLayoutDialog, param: Layouts.Parameter) !void {
const row = try self.alloc.create(ParamRow);
errdefer self.alloc.destroy(row);
row.* = .{
.dialog = self,
.box = gtk.Box.new(.horizontal, 6),
.name = gtk.Entry.new(),
.description = gtk.Entry.new(),
.default = gtk.Entry.new(),
};
row.name.setPlaceholderText("name");
setEntryText(row.name, param.name);
row.box.append(row.name.as(gtk.Widget));
row.description.setPlaceholderText("what it means");
row.description.as(gtk.Widget).setHexpand(1);
setEntryText(row.description, param.description);
row.box.append(row.description.as(gtk.Widget));
row.default.setPlaceholderText("default");
setEntryText(row.default, param.default);
row.box.append(row.default.as(gtk.Widget));
const remove = gtk.Button.newFromIconName("list-remove-symbolic");
remove.as(gtk.Widget).addCssClass("flat");
_ = gtk.Button.signals.clicked.connect(remove, *ParamRow, &onRemoveParam, row, .{});
row.box.append(remove.as(gtk.Widget));
try self.params.append(self.alloc, row);
self.params_box.append(row.box.as(gtk.Widget));
}
fn onAddParam(_: *gtk.Button, self: *SaveLayoutDialog) callconv(.c) void {
self.addParamRow(.{ .name = "" }) catch |err| {
std.log.err("failed to add parameter row: {s}", .{@errorName(err)});
};
}
fn onRemoveParam(_: *gtk.Button, row: *ParamRow) callconv(.c) void {
const self = row.dialog;
for (self.params.items, 0..) |candidate, i| {
if (candidate == row) {
_ = self.params.orderedRemove(i);
break;
}
}
self.params_box.remove(row.box.as(gtk.Widget));
self.alloc.destroy(row);
}
// -------------------------------------------------------------------------
// Saving
fn onSave(_: *gtk.Button, self: *SaveLayoutDialog) callconv(.c) void {
self.commit();
}
fn onNameActivate(_: *gtk.Entry, self: *SaveLayoutDialog) callconv(.c) void {
self.commit();
}
fn commit(self: *SaveLayoutDialog) void {
const name = std.mem.trim(
u8,
std.mem.span(self.name_entry.as(gtk.Editable).getText()),
&std.ascii.whitespace,
);
if (name.len == 0) {
self.showError("Give the layout a name.");
return;
}
// Everything that can refuse the save happens before anything is written.
// Editing writes straight into the live layout's own nodes, so bailing out
// half way would leave it altered on screen but unaltered on disk.
//
// Renaming *onto* another layout is refused rather than silently replacing
// it. Saving over a name is a deliberate act when you type it into the save
// dialog; reaching it by renaming something else is a mistake, and the
// layout it would destroy isn't the one on screen.
const renaming = if (self.original_name) |original|
!std.mem.eql(u8, original, name)
else
false;
if (renaming and self.layouts.find(name) != null) {
self.showError("Another layout already has that name.");
return;
}
const builder = self.layouts.builder();
// Write the edited fields back into the captured tree. Everything is
// copied into the store's arena, so it outlives these widgets.
for (self.panes) |row| {
const spec = &row.node.pane;
if (row.cwd) |e| spec.cwd = builder.dupe(entryText(e)) catch {
self.showError("Out of memory.");
return;
};
if (row.command) |e| spec.command = builder.dupe(entryText(e)) catch {
self.showError("Out of memory.");
return;
};
if (row.url) |e| spec.url = builder.dupe(entryText(e)) catch {
self.showError("Out of memory.");
return;
};
}
var params: std.ArrayListUnmanaged(Layouts.Parameter) = .empty;
defer params.deinit(self.alloc);
for (self.params.items) |row| {
const param_name = std.mem.trim(u8, entryText(row.name), &std.ascii.whitespace);
// A row left blank is someone who clicked Add and changed their mind,
// not a parameter called "".
if (param_name.len == 0) continue;
params.append(self.alloc, .{
.name = param_name,
.description = entryText(row.description),
.default = entryText(row.default),
}) catch {
self.showError("Out of memory.");
return;
};
}
// A rename moves the layout rather than cloning it. Dropping the old entry
// first also means `put` appends under the new name instead of finding a
// stale match.
if (renaming) self.layouts.remove(self.original_name.?);
self.layouts.put(name, params.items, self.root) catch {
self.showError("Out of memory.");
return;
};
self.layouts.save() catch {
// The layout is in memory but not on disk. Say so rather than closing
// as though it worked; reopening the app would lose it.
self.showError("Could not write the layouts file.");
return;
};
self.on_saved(self.ctx);
self.window.destroy();
}
fn entryText(entry: *gtk.Entry) []const u8 {
return std.mem.span(entry.as(gtk.Editable).getText());
}
fn showError(self: *SaveLayoutDialog, message: [:0]const u8) void {
self.error_label.setText(message);
self.error_label.as(gtk.Widget).setVisible(1);
}
fn onCancel(_: *gtk.Button, self: *SaveLayoutDialog) callconv(.c) void {
self.window.destroy();
}
fn onDestroy(_: *gtk.Window, self: *SaveLayoutDialog) callconv(.c) void {
for (self.params.items) |row| self.alloc.destroy(row);
self.params.deinit(self.alloc);
self.alloc.free(self.panes);
self.alloc.destroy(self);
}
// -------------------------------------------------------------------------
// Small widget helpers
fn heading(text: [:0]const u8) *gtk.Widget {
const label = gtk.Label.new(text);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-heading");
return label.as(gtk.Widget);
}
fn hint(text: [:0]const u8) *gtk.Widget {
const label = gtk.Label.new(text);
label.setXalign(0);
label.setWrap(1);
label.as(gtk.Widget).addCssClass("vtabs-dialog-hint");
return label.as(gtk.Widget);
}
fn setEntryText(entry: *gtk.Entry, text: []const u8) void {
var buf: [4096]u8 = undefined;
const z = std.fmt.bufPrintZ(&buf, "{s}", .{text}) catch return;
entry.as(gtk.Editable).setText(z);
}
+49 -1
View File
@@ -37,6 +37,10 @@ watch: c_uint = 0,
/// True once the child process has exited and the PTY hung up.
exited: bool = false,
/// A script to run once the shell is ready, from the layout this pane came
/// from. Held rather than written at spawn time — see `flushStartupCommand`.
startup_command: ?[]u8 = null,
/// Called after terminal state changes, so the owner can queue a redraw.
on_damage: *const fn (ctx: ?*anyopaque) void,
@@ -55,10 +59,21 @@ pub const Callbacks = struct {
ctx: ?*anyopaque,
};
/// What a layout can ask for beyond a plain shell. Both are empty for a
/// terminal opened the ordinary way.
pub const Options = struct {
/// Directory the shell starts in.
cwd: []const u8 = "",
/// Script fed to the shell once it is up.
command: []const u8 = "",
};
pub fn create(
alloc: std.mem.Allocator,
cols: u16,
rows: u16,
opts: Options,
cbs: Callbacks,
) !*Session {
const self = try alloc.create(Session);
@@ -103,12 +118,22 @@ pub fn create(
}, 0);
defer alloc.free(argv0);
self.pty = try .create(alloc, shell, &.{argv0}, .{
const cwd_z: ?[:0]const u8 = if (opts.cwd.len > 0)
try alloc.dupeZ(u8, opts.cwd)
else
null;
defer if (cwd_z) |z| alloc.free(z);
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{
.ws_row = rows,
.ws_col = cols,
});
errdefer self.pty.deinit();
if (opts.command.len > 0) {
self.startup_command = try alloc.dupe(u8, opts.command);
}
self.watch = glibunix.fdAdd(
self.pty.master,
.{ .in = true, .hup = true, .err = true },
@@ -120,6 +145,7 @@ pub fn create(
}
pub fn destroy(self: *Session) void {
if (self.startup_command) |cmd| self.alloc.free(cmd);
if (self.watch != 0) _ = glib.Source.remove(self.watch);
self.pty.deinit();
self.stream.deinit();
@@ -161,6 +187,7 @@ fn onReadable(
var buf: [read_buf_size]u8 = undefined;
if (self.pty.read(&buf)) |n| {
self.stream.nextSlice(buf[0..n]);
self.flushStartupCommand();
self.on_damage(self.ctx);
return 1;
}
@@ -173,6 +200,27 @@ fn onReadable(
return 0;
}
/// Type a layout's script into the shell, once, as soon as the shell has
/// shown that it is alive.
///
/// The wait matters. Writing at spawn time puts the script into the tty input
/// buffer before the shell has started, and shells that set up line editing
/// (zsh's ZLE, bash's readline) can discard whatever was buffered while they
/// were initializing, so the command silently vanishes. The shell's first
/// output — its prompt — is proof that it has finished starting and is reading
/// input, which is exactly the moment this becomes safe.
///
/// It is typed rather than executed for us, so the shell is still there
/// afterwards with the script sitting in its history.
fn flushStartupCommand(self: *Session) void {
const command = self.startup_command orelse return;
self.startup_command = null;
defer self.alloc.free(command);
self.pty.writeAll(command);
self.pty.writeAll("\n");
}
/// Effect callback: the terminal wants to send bytes back to the child.
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
const self = fromHandler(handler);
+9 -2
View File
@@ -54,7 +54,14 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal {
/// What a layout can specify for a terminal pane.
pub const Options = Session.Options;
pub fn create(
alloc: std.mem.Allocator,
opts: Options,
cbs: Pane.Callbacks,
) !*Terminal {
const self = try alloc.create(Terminal);
errdefer alloc.destroy(self);
@@ -76,7 +83,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal {
// The grid size follows the widget size, but we need a starting point
// for the session before the widget has ever been allocated.
self.session = try .create(alloc, 80, 24, .{
self.session = try .create(alloc, 80, 24, opts, .{
.on_damage = &onDamage,
.on_title = &onSessionTitle,
.on_exit = &onSessionExit,
+138 -2
View File
@@ -12,6 +12,7 @@ const std = @import("std");
const gtk = @import("gtk");
const Layout = @import("Layout.zig");
const Layouts = @import("Layouts.zig");
const Pane = @import("Pane.zig");
const Terminal = @import("Terminal.zig");
@@ -152,8 +153,8 @@ pub fn focus(self: *View) void {
/// Add a pane, splitting the focused one so the new pane appears beside
/// whatever you were working in.
pub fn addPane(self: *View, kind: Kind) !void {
const pane = try Pane.create(self.alloc, self, kind);
pub fn addPane(self: *View, spec: Pane.Spec) !void {
const pane = try Pane.create(self.alloc, self, spec);
errdefer pane.destroy();
const node = try self.layout.newLeaf(pane);
@@ -228,6 +229,141 @@ pub fn paneTitleChanged(self: *View, pane: *Pane) void {
if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx);
}
// -------------------------------------------------------------------------
// Layouts
/// Fill an empty view from a saved layout, substituting `bindings` into every
/// directory, script and URL as the panes are created.
///
/// Only valid on a view that has no panes yet — a layout describes a whole
/// tab, not an addition to one.
pub fn applyLayout(
self: *View,
spec: *const Layouts.Node,
bindings: []const Layouts.Binding,
) !void {
std.debug.assert(self.panes.items.len == 0);
const root = try self.buildNode(spec, bindings);
self.layout.root = root;
root.parent = null;
self.layout.materialize(self.box);
// The first pane in tree order is the top-left one, which is where you
// would start reading the tab and so where focus belongs.
if (self.panes.items.len > 0) {
self.setFocused(self.panes.items[0]);
self.panes.items[0].grabFocus();
}
self.on_title(self.ctx);
}
/// Build one subtree. Panes are appended to `self.panes` as they are created,
/// so a failure part-way leaves them owned by the view and torn down with it
/// rather than leaked.
fn buildNode(
self: *View,
spec: *const Layouts.Node,
bindings: []const Layouts.Binding,
) !*Layout.Node {
switch (spec.*) {
.pane => |p| {
const pane_spec = try self.paneSpec(p, bindings);
defer freePaneSpec(self.alloc, pane_spec);
const pane = try Pane.create(self.alloc, self, pane_spec);
errdefer pane.destroy();
const node = try self.layout.newLeaf(pane);
errdefer self.alloc.destroy(node);
try self.panes.append(self.alloc, pane);
return node;
},
.split => |s| {
const first = try self.buildNode(s.first, bindings);
const second = try self.buildNode(s.second, bindings);
return self.layout.newSplit(
switch (s.orientation) {
.horizontal => .horizontal,
.vertical => .vertical,
},
first,
second,
s.ratio,
);
},
}
}
/// Turn a layout's pane description into the spec `Pane.create` wants, with
/// parameters substituted. The strings are owned by the caller.
fn paneSpec(
self: *View,
p: Layouts.Pane,
bindings: []const Layouts.Binding,
) !Pane.Spec {
return switch (p.kind) {
.terminal => blk: {
const cwd = try Layouts.expandPath(self.alloc, p.cwd, bindings);
errdefer self.alloc.free(cwd);
const command = try Layouts.expand(self.alloc, p.command, bindings);
break :blk .{ .terminal = .{ .cwd = cwd, .command = command } };
},
.web => .{ .web = .{
.url = try Layouts.expand(self.alloc, p.url, bindings),
} },
};
}
fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void {
switch (spec) {
.terminal => |o| {
alloc.free(o.cwd);
alloc.free(o.command);
},
.web => |o| alloc.free(o.url),
}
}
/// Capture this view's arrangement as a layout tree, for "save tab as layout".
///
/// The shape, split orientations and divider ratios come across exactly as
/// they are on screen. What each pane should *run* can't be known from a live
/// pane, so terminals come back with their current directory and an empty
/// command for the user to fill in; web panes bring their current URL.
pub fn capture(self: *View, builder: Layouts.Builder) !?*Layouts.Node {
const root = self.layout.root orelse return null;
return try self.captureNode(root, builder);
}
fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layouts.Node {
return switch (node.kind) {
.leaf => |pane| switch (pane.content) {
.terminal => |t| blk: {
var buf: [std.fs.max_path_bytes]u8 = undefined;
break :blk try builder.pane(.{
.kind = .terminal,
.cwd = t.session.pty.cwd(&buf) orelse "",
});
},
.web => |b| try builder.pane(.{
.kind = .web,
.url = b.currentUrl(),
}),
},
.split => |s| try builder.split(
switch (s.paned.as(gtk.Orientable).getOrientation()) {
.vertical => .vertical,
else => .horizontal,
},
s.ratio,
try self.captureNode(s.a, builder),
try self.captureNode(s.b, builder),
),
};
}
// -------------------------------------------------------------------------
// Rearranging
+276 -4
View File
@@ -14,6 +14,9 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
const vt = @import("ghostty-vt");
const Layouts = @import("Layouts.zig");
const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
@@ -43,6 +46,17 @@ updating: bool = false,
/// doesn't try to close a tab we're already destroying.
closing: bool = false,
/// Saved tab templates, read from the config file at startup.
layouts: Layouts,
/// The popover listing them. Rebuilt whenever the set changes, since its
/// contents are one row per layout.
layout_popover: *gtk.Popover,
/// Per-row context for the popover's handlers, owned for as long as the rows
/// they belong to are on screen.
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
/// A single tab: a view of one or more panes, plus the sidebar row that
/// selects it.
const Tab = struct {
@@ -76,7 +90,11 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
.window = window,
.stack = gtk.Stack.new(),
.list = gtk.ListBox.new(),
.layouts = .init(alloc),
.layout_popover = gtk.Popover.new(),
};
self.layouts.load();
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
window.as(gtk.Widget).addCssClass("vtabs-window");
@@ -103,6 +121,16 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
.{},
);
header.packEnd(new_tab_button.as(gtk.Widget));
// Layouts sit behind their own button rather than replacing the plain
// new-tab one: opening an ordinary shell stays a single click.
const layout_button = gtk.MenuButton.new();
layout_button.setIconName("view-grid-symbolic");
layout_button.as(gtk.Widget).setTooltipText("Open a saved layout");
layout_button.setPopover(self.layout_popover);
self.layout_popover.as(gtk.Widget).addCssClass("vtabs-layout-popover");
self.refreshLayoutMenu();
header.packEnd(layout_button.as(gtk.Widget));
sidebar.append(header.as(gtk.Widget));
self.list.setSelectionMode(.single);
@@ -158,6 +186,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
);
try self.newTab();
return self;
}
@@ -172,7 +201,25 @@ pub fn present(self: *Window) void {
}
/// Open a new tab and switch to it.
/// Open a new tab holding a single terminal — the plain case, unchanged by
/// layouts existing.
pub fn newTab(self: *Window) !void {
const tab = try self.newTabEmpty();
// Only once the tab is in `self.tabs` is it complete enough for the
// view's callbacks to use, so this is the first safe moment to give the
// view its first pane.
try tab.view.addPane(.plain(.terminal));
self.refreshLabel(tab);
self.select(tab);
}
/// The tab and its sidebar row, with no panes in the view yet.
///
/// Split out from `newTab` because a layout fills the view itself, with a
/// whole tree rather than one pane, and the tab has to exist first.
fn newTabEmpty(self: *Window) !*Tab {
const tab = try self.alloc.create(Tab);
errdefer self.alloc.destroy(tab);
@@ -222,14 +269,234 @@ pub fn newTab(self: *Window) !void {
try self.tabs.append(self.alloc, tab);
// Only now is `tab` complete enough for the view's callbacks to use, so
// this is the first safe moment to give the view its first pane.
try view.addPane(.terminal);
return tab;
}
// -------------------------------------------------------------------------
// Layouts
/// Per-row context for the layout menu. One is allocated per row and freed
/// when the menu is rebuilt, so a row's handler always knows which layout it
/// belongs to without indexing into a list that may have changed.
const LayoutRow = struct {
window: *Window,
name: []const u8,
};
/// Rebuild the popover: one row per saved layout, then the actions.
fn refreshLayoutMenu(self: *Window) void {
self.freeLayoutRows();
const box = gtk.Box.new(.vertical, 2);
box.as(gtk.Widget).addCssClass("vtabs-layout-menu");
if (self.layouts.items.items.len == 0) {
const empty = gtk.Label.new(if (self.layouts.load_error != null)
"Layouts file could not be read"
else
"No saved layouts yet");
empty.as(gtk.Widget).addCssClass("vtabs-layout-empty");
box.append(empty.as(gtk.Widget));
}
for (self.layouts.items.items) |layout| {
const row = self.alloc.create(LayoutRow) catch continue;
row.* = .{ .window = self, .name = layout.name };
self.layout_rows.append(self.alloc, row) catch {
self.alloc.destroy(row);
continue;
};
const line = gtk.Box.new(.horizontal, 4);
var label_buf: [128]u8 = undefined;
const text = std.fmt.bufPrintZ(&label_buf, "{s}", .{layout.name}) catch continue;
const open = gtk.Button.newWithLabel(text);
open.as(gtk.Widget).addCssClass("flat");
open.as(gtk.Widget).setHexpand(1);
open.setHasFrame(0);
if (open.getChild()) |child| child.setHalign(.start);
_ = gtk.Button.signals.clicked.connect(open, *LayoutRow, &onLayoutClicked, row, .{});
line.append(open.as(gtk.Widget));
const edit = gtk.Button.newFromIconName("document-edit-symbolic");
edit.as(gtk.Widget).addCssClass("flat");
edit.as(gtk.Widget).setTooltipText("Edit this layout");
_ = gtk.Button.signals.clicked.connect(edit, *LayoutRow, &onLayoutEdit, row, .{});
line.append(edit.as(gtk.Widget));
const delete = gtk.Button.newFromIconName("user-trash-symbolic");
delete.as(gtk.Widget).addCssClass("flat");
delete.as(gtk.Widget).setTooltipText("Delete this layout");
_ = gtk.Button.signals.clicked.connect(delete, *LayoutRow, &onLayoutDelete, row, .{});
line.append(delete.as(gtk.Widget));
box.append(line.as(gtk.Widget));
}
box.append(gtk.Separator.new(.horizontal).as(gtk.Widget));
const save = gtk.Button.newWithLabel("Save tab as layout…");
save.as(gtk.Widget).addCssClass("flat");
save.setHasFrame(0);
if (save.getChild()) |child| child.setHalign(.start);
_ = gtk.Button.signals.clicked.connect(save, *Window, &onSaveLayoutClicked, self, .{});
box.append(save.as(gtk.Widget));
const reload = gtk.Button.newWithLabel("Reload from disk");
reload.as(gtk.Widget).addCssClass("flat");
reload.setHasFrame(0);
if (reload.getChild()) |child| child.setHalign(.start);
_ = gtk.Button.signals.clicked.connect(reload, *Window, &onReloadLayouts, self, .{});
box.append(reload.as(gtk.Widget));
self.layout_popover.setChild(box.as(gtk.Widget));
}
fn freeLayoutRows(self: *Window) void {
for (self.layout_rows.items) |row| self.alloc.destroy(row);
self.layout_rows.clearRetainingCapacity();
}
fn onLayoutClicked(_: *gtk.Button, row: *LayoutRow) callconv(.c) void {
const self = row.window;
self.layout_popover.popdown();
const layout = self.layouts.find(row.name) orelse return;
// Nothing to ask for, so skip straight past the dialog.
if (layout.parameters.len == 0) {
openLayout(self, layout, &.{});
return;
}
OpenLayoutDialog.present(
self.alloc,
self.window.as(gtk.Window),
layout,
&onLayoutParameters,
self,
) catch |err| {
std.log.err("failed to open layout dialog: {s}", .{@errorName(err)});
};
}
fn onLayoutParameters(
ctx: ?*anyopaque,
layout: *Layouts.Layout,
bindings: []const Layouts.Binding,
) void {
const self: *Window = @ptrCast(@alignCast(ctx.?));
openLayout(self, layout, bindings);
}
/// Open a layout in a new tab.
fn openLayout(self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding) void {
const tab = self.newTabEmpty() catch |err| {
std.log.err("failed to open tab: {s}", .{@errorName(err)});
return;
};
tab.view.applyLayout(layout.root, bindings) catch |err| {
std.log.err("failed to build layout: {s}", .{@errorName(err)});
// A half-built view has no panes to work in and no shell to close,
// so drop the tab rather than leave an empty one behind.
if (tab.view.panes.items.len == 0) {
self.closeTab(tab);
return;
}
};
self.refreshLabel(tab);
self.select(tab);
}
/// Edit a saved layout in place: its name, parameters and per-pane scripts.
///
/// The arrangement itself isn't editable here — to reshape one, open it,
/// rearrange the tab, and save over it under the same name.
fn onLayoutEdit(_: *gtk.Button, row: *LayoutRow) callconv(.c) void {
const self = row.window;
self.layout_popover.popdown();
const layout = self.layouts.find(row.name) orelse return;
SaveLayoutDialog.present(
self.alloc,
self.window.as(gtk.Window),
&self.layouts,
layout.root,
.{
.title = "Edit layout",
.confirm = "Save",
.name = layout.name,
.parameters = layout.parameters,
.original_name = layout.name,
},
&onLayoutSaved,
self,
) catch |err| {
std.log.err("failed to open layout editor: {s}", .{@errorName(err)});
};
}
fn onLayoutDelete(_: *gtk.Button, row: *LayoutRow) callconv(.c) void {
const self = row.window;
self.layouts.remove(row.name);
self.layouts.save() catch {
std.log.err("failed to write layouts file", .{});
};
// Rebuilding frees `row`, so nothing may touch it after this.
self.refreshLayoutMenu();
}
fn onSaveLayoutClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
self.layout_popover.popdown();
self.saveCurrentTabAsLayout();
}
fn saveCurrentTabAsLayout(self: *Window) void {
const tab = self.activeTab() orelse return;
const root = tab.view.capture(self.layouts.builder()) catch |err| {
std.log.err("failed to capture layout: {s}", .{@errorName(err)});
return;
} orelse return;
// The tab's own label is the obvious first guess at a name.
var name_buf: [128]u8 = undefined;
const suggested = tab.view.label(&name_buf);
SaveLayoutDialog.present(
self.alloc,
self.window.as(gtk.Window),
&self.layouts,
root,
.{
.title = "Save tab as layout",
.confirm = "Save",
.name = suggested,
},
&onLayoutSaved,
self,
) catch |err| {
std.log.err("failed to open save dialog: {s}", .{@errorName(err)});
};
}
fn onLayoutSaved(ctx: ?*anyopaque) void {
const self: *Window = @ptrCast(@alignCast(ctx.?));
self.refreshLayoutMenu();
}
fn onReloadLayouts(_: *gtk.Button, self: *Window) callconv(.c) void {
self.layout_popover.popdown();
self.layouts.load();
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
self.refreshLayoutMenu();
}
/// Make `tab` the visible one.
fn select(self: *Window, tab: *Tab) void {
self.updating = true;
@@ -333,6 +600,11 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
self.alloc.destroy(tab);
}
self.tabs.deinit(self.alloc);
self.freeLayoutRows();
self.layout_rows.deinit(self.alloc);
self.layouts.deinit();
self.alloc.destroy(self);
}
@@ -358,7 +630,7 @@ fn focusedTerminal(self: *Window) ?*Terminal {
/// Split the visible tab's focused pane, adding a pane of the given kind.
fn addPane(self: *Window, kind: View.Kind) void {
const tab = self.activeTab() orelse return;
tab.view.addPane(kind) catch |err| {
tab.view.addPane(.plain(kind)) catch |err| {
std.log.err("failed to open {s} pane: {s}", .{ @tagName(kind), @errorName(err) });
};
}
+10
View File
@@ -39,6 +39,7 @@ pub fn main() u8 {
}
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
forceDark();
loadCss();
const window = Window.create(gpa.allocator(), app) catch |err| {
@@ -48,6 +49,15 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
window.present();
}
/// The window's own chrome is dark by hand in `style.css`, but stock widgets —
/// popovers, dialogs, text entries — follow the desktop's colour scheme and
/// would come up light against it. Layouts brought the first real dialogs into
/// the app, which is where that mismatch became visible.
fn forceDark() void {
const manager = adw.StyleManager.getDefault();
manager.setColorScheme(.force_dark);
}
fn loadCss() void {
const display = gdk.Display.getDefault() orelse return;
const provider = gtk.CssProvider.new();
+61
View File
@@ -171,6 +171,67 @@
border-color: #b29df5;
}
/* Saved-layout menu, hanging off the sidebar header. */
.vtabs-layout-menu {
min-width: 260px;
}
.vtabs-layout-menu button {
padding: 4px 8px;
border-radius: 6px;
}
.vtabs-layout-empty {
padding: 8px;
color: #8f87a3;
font-size: 0.9em;
}
/* Layout dialogs: opening one, and saving the current tab as one. */
.vtabs-dialog {
background-color: #16141c;
}
.vtabs-dialog-content {
padding: 16px;
}
.vtabs-dialog-actions {
padding: 12px 16px;
border-top: 1px solid #262133;
}
.vtabs-dialog-heading {
margin-top: 8px;
font-weight: bold;
color: #ded7ef;
}
.vtabs-dialog-label {
color: #b6afc7;
}
.vtabs-dialog-sublabel {
font-size: 0.88em;
color: #8f87a3;
}
.vtabs-dialog-hint {
font-size: 0.85em;
color: #8f87a3;
}
.vtabs-dialog-error {
color: #f2a0a0;
}
/* Each captured pane in the save dialog, so the sections read apart. */
.vtabs-dialog-pane {
padding: 8px;
border: 1px solid #2a2536;
border-radius: 8px;
}
/* Divider between panes. Wide enough to grab without hunting for it. */
.vtabs-view paned > separator {
background-color: #0f0d14;