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
+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) });
};
}