Add custom layout creation.
This commit is contained in:
+276
-4
@@ -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) });
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user