Add ability to set boot layout.

This commit is contained in:
Greyson Parrelli
2026-08-13 10:52:58 -04:00
parent 6c7c3bfa63
commit 4e622b15af
9 changed files with 1330 additions and 90 deletions
+305 -16
View File
@@ -18,11 +18,13 @@ const Layouts = @import("Layouts.zig");
const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
const Pane = @import("Pane.zig");
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
const Settings = @import("Settings.zig");
const SettingsDialog = @import("SettingsDialog.zig");
const TabSettingsDialog = @import("TabSettingsDialog.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
const appearance = @import("appearance.zig");
const emoji = @import("emoji.zig");
const Window = @This();
@@ -69,6 +71,21 @@ layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
/// a row and a pane header show the same five states for the same reasons.
const Attention = Pane.Attention;
/// Where a tab came from, when it came from a saved layout.
///
/// Kept so that "use these tabs at launch" in the settings page has something
/// to write down. A live view can be captured as a *shape* — that is what "save
/// tab as layout" does — but the shape is not what a startup entry wants; it
/// wants the name of the layout and the values it was opened with, and those are
/// only knowable at the moment of opening. So they are recorded then.
///
/// Every string is owned by the window's allocator, since the dialog the values
/// were typed into is long gone by the time anyone asks.
const Source = struct {
layout: []u8,
values: []Settings.Value,
};
/// A single tab: a view of one or more panes, plus the sidebar row that
/// selects it.
const Tab = struct {
@@ -96,6 +113,9 @@ const Tab = struct {
/// Null means the label tracks the content, which is the default.
custom_name: ?[]u8 = null,
/// The layout this tab was opened from, if any. Null for a plain shell.
source: ?Source = null,
/// The row's right-click menu, parented to this tab's row.
menu_popover: *gtk.Popover,
@@ -268,7 +288,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
appearance.onChanged(&onAppearanceChanged, self);
try self.newTab();
try self.openStartupTabs();
return self;
}
@@ -714,25 +734,43 @@ fn onLayoutParameters(
openLayout(self, layout, bindings);
}
/// Open a layout in a new tab.
/// Open a layout in a new tab and go to it.
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)});
const tab = self.buildLayoutTab(layout, bindings) catch |err| {
std.log.err("failed to open layout \"{s}\": {s}", .{ layout.name, @errorName(err) });
return;
};
self.select(tab);
}
/// Build a tab holding `layout`, with `bindings` substituted into it.
///
/// The tab is left unselected. Opening one from the menu goes to it; the startup
/// list opens several and then goes to the first, so which one you land in is
/// the caller's decision rather than a side effect of building.
fn buildLayoutTab(
self: *Window,
layout: *Layouts.Layout,
bindings: []const Layouts.Binding,
) !*Tab {
const tab = try self.newTabEmpty();
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.
// 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. Discarded rather
// than closed: closing the only tab takes the window with it, and at
// startup this is reachable before there is another one.
if (tab.view.panes.items.len == 0) {
self.closeTab(tab);
return;
self.discardTab(tab);
return err;
}
std.log.err("layout \"{s}\" only partly built: {s}", .{ layout.name, @errorName(err) });
};
self.recordSource(tab, layout.name, bindings);
self.refreshLabel(tab);
self.select(tab);
return tab;
}
/// Edit a saved layout in place: its name, parameters and per-pane scripts.
@@ -820,6 +858,227 @@ fn onReloadLayouts(_: *gtk.Button, self: *Window) callconv(.c) void {
self.refreshLayoutMenu();
}
// -------------------------------------------------------------------------
// Startup tabs
//
// The window opens itself out of the settings' startup list: one tab per entry,
// each naming a saved layout and the values to fill its parameters in with. It
// is deliberately a list of recipes rather than a snapshot of a previous
// session — three layouts against three worktrees is a thing you can *write
// down*, and a window's worth of live shells is not. Nothing here restores a
// scrollback or a running command; it re-runs the arrangement, which is the part
// that was tedious to set up by hand every morning.
//
// A list that opens nothing at all still has to leave a window you can type in,
// so a missing layout costs its tab and an empty list falls back to the plain
// single-shell window the app opened with before this existed.
/// Open the tabs the settings ask for, or one plain shell when they ask for
/// nothing.
fn openStartupTabs(self: *Window) !void {
for (Settings.get().startup) |entry| self.openStartupTab(entry);
if (self.tabs.items.len == 0) {
try self.newTab();
return;
}
// The first, not the last: a startup list reads top to bottom, and the tab
// you want to be looking at is the one you put at the top of it.
self.select(self.tabs.items[0]);
}
/// Open one entry. A failure is reported and skipped — the other tabs are still
/// worth having, and a window that refused to open because the fourth of six
/// layouts had been renamed would be a poor trade.
fn openStartupTab(self: *Window, entry: Settings.StartupTab) void {
const tab = self.buildStartupTab(entry) catch |err| {
std.log.warn("could not open startup tab \"{s}\": {s}", .{
if (entry.layout.len > 0) entry.layout else "shell",
@errorName(err),
});
return;
} orelse return;
self.applyStartupChrome(tab, entry);
self.refreshLabel(tab);
}
/// The tab for one entry, or null when it names a layout that no longer exists.
fn buildStartupTab(self: *Window, entry: Settings.StartupTab) !?*Tab {
// No layout named is the plain case, and worth supporting: a startup list
// is often two configured tabs and one ordinary shell to work in.
if (entry.layout.len == 0) {
const tab = try self.newTabEmpty();
errdefer self.discardTab(tab);
try tab.view.addPane(.plain(.terminal));
return tab;
}
const layout = self.layouts.find(entry.layout) orelse {
// Renamed or deleted since the list was written. Worth saying out loud:
// the alternative is a window that is quietly one tab short.
std.log.warn("startup: no layout named \"{s}\"", .{entry.layout});
return null;
};
const bindings = try self.startupBindings(layout, entry);
defer self.alloc.free(bindings);
return try self.buildLayoutTab(layout, bindings);
}
/// What to open a layout's parameters with: the values the entry names, then
/// every declared parameter it doesn't name, at that parameter's own default.
/// The entry's values come first, and `expand` takes the first match, so an
/// entry always wins over a default.
///
/// A value for something the layout doesn't declare is kept rather than dropped.
/// A script may refer to `{{anything}}` whether or not the layout declared it,
/// and an entry that fills one in is far more likely to know something the
/// declaration list has fallen behind on than to be wrong.
///
/// Every string here is borrowed — from the settings arena or the layouts arena,
/// both of which outlive the tab — so only the slice itself is allocated.
fn startupBindings(
self: *Window,
layout: *Layouts.Layout,
entry: Settings.StartupTab,
) ![]Layouts.Binding {
var out: std.ArrayListUnmanaged(Layouts.Binding) = .empty;
errdefer out.deinit(self.alloc);
try out.ensureTotalCapacity(self.alloc, entry.parameters.len + layout.parameters.len);
for (entry.parameters) |v| {
out.appendAssumeCapacity(.{ .name = v.name, .value = v.value });
}
for (layout.parameters) |p| {
if (namesValue(entry.parameters, p.name)) continue;
out.appendAssumeCapacity(.{ .name = p.name, .value = p.default });
}
return out.toOwnedSlice(self.alloc);
}
fn namesValue(values: []const Settings.Value, name: []const u8) bool {
for (values) |v| {
if (std.mem.eql(u8, v.name, name)) return true;
}
return false;
}
/// The name and emoji an entry pins on its tab, both behaving exactly as though
/// they had been set by hand once it was open.
fn applyStartupChrome(self: *Window, tab: *Tab, entry: Settings.StartupTab) void {
if (entry.name.len > 0) {
tab.custom_name = self.alloc.dupe(u8, entry.name) catch |err| blk: {
std.log.warn("could not name startup tab: {s}", .{@errorName(err)});
break :blk null;
};
}
// Resolved against the emoji table rather than copied, because a row holds
// a pointer into that table and nothing else. A glyph that isn't in it is
// a hand-edited file naming something this build can't draw.
if (entry.emoji.len > 0) {
tab.emoji = emoji.lookup(entry.emoji);
if (tab.emoji == null) {
std.log.warn("startup: \"{s}\" is not an emoji this build knows", .{entry.emoji});
}
}
}
/// Remember what a tab was opened with, so the settings page can write it down
/// later. Best effort: failing to record it costs the tab its place in a
/// captured list, which is not a reason to refuse to open it.
fn recordSource(
self: *Window,
tab: *Tab,
layout_name: []const u8,
bindings: []const Layouts.Binding,
) void {
self.freeSource(tab);
tab.source = self.captureSource(layout_name, bindings) catch |err| {
std.log.warn("could not record how a tab was opened: {s}", .{@errorName(err)});
return;
};
}
fn captureSource(
self: *Window,
layout_name: []const u8,
bindings: []const Layouts.Binding,
) !Source {
const layout = try self.alloc.dupe(u8, layout_name);
errdefer self.alloc.free(layout);
var values: std.ArrayListUnmanaged(Settings.Value) = .empty;
errdefer {
for (values.items) |v| {
self.alloc.free(v.name);
self.alloc.free(v.value);
}
values.deinit(self.alloc);
}
for (bindings) |b| {
const name = try self.alloc.dupe(u8, b.name);
errdefer self.alloc.free(name);
const value = try self.alloc.dupe(u8, b.value);
try values.append(self.alloc, .{ .name = name, .value = value });
}
return .{ .layout = layout, .values = try values.toOwnedSlice(self.alloc) };
}
fn freeSource(self: *Window, tab: *Tab) void {
const source = tab.source orelse return;
for (source.values) |v| {
self.alloc.free(v.name);
self.alloc.free(v.value);
}
self.alloc.free(source.values);
self.alloc.free(source.layout);
tab.source = null;
}
/// Write the open tabs into the settings as the startup list, in sidebar order.
///
/// What each tab contributes is its recipe — the layout it was opened from and
/// the values it was opened with — plus whatever name and emoji it is wearing. A
/// tab opened as a plain shell contributes a plain shell. What is deliberately
/// not captured is where the shells have wandered to since: that would be a
/// snapshot with an expiry date, and the layout it came from is the thing the
/// user actually maintains.
fn captureStartupTabs(ctx: ?*anyopaque) void {
const self: *Window = @ptrCast(@alignCast(ctx.?));
var entries: std.ArrayListUnmanaged(Settings.StartupTab) = .empty;
defer entries.deinit(self.alloc);
for (self.tabs.items) |tab| {
entries.append(self.alloc, .{
.layout = if (tab.source) |s| s.layout else "",
.name = tab.custom_name orelse "",
.emoji = tab.emoji orelse "",
.parameters = if (tab.source) |s| s.values else &.{},
}) catch |err| {
std.log.err("could not capture the open tabs: {s}", .{@errorName(err)});
return;
};
}
const settings = Settings.get();
settings.setStartup(entries.items) catch |err| {
std.log.err("could not capture the open tabs: {s}", .{@errorName(err)});
return;
};
settings.save() catch {
std.log.err("failed to save settings", .{});
};
}
/// Make `tab` the visible one.
fn select(self: *Window, tab: *Tab) void {
self.updating = true;
@@ -842,9 +1101,14 @@ fn indexOf(self: *Window, tab: *Tab) ?usize {
return null;
}
/// Close a tab, and the window along with it if it was the last one.
fn closeTab(self: *Window, tab: *Tab) void {
if (self.closing) return;
/// Take a tab out of the window and free it, with no view about what should be
/// selected next or whether anything is left.
///
/// `closeTab` is the one to reach for. This is the half of it the startup path
/// needs, where a tab that couldn't be built has to go away without taking the
/// window down with it — which closing the only tab would do, before there is
/// another one to fall back to.
fn discardTab(self: *Window, tab: *Tab) void {
const index = self.indexOf(tab) orelse return;
self.stack.remove(tab.view.widget());
@@ -861,8 +1125,24 @@ fn closeTab(self: *Window, tab: *Tab) void {
_ = self.tabs.orderedRemove(index);
tab.view.destroy();
self.releaseTab(tab);
}
/// Free a tab's own allocations. The view is not one of them — it is destroyed
/// by whoever took the tab out of the window, which on the teardown path is not
/// the same code.
fn releaseTab(self: *Window, tab: *Tab) void {
if (tab.custom_name) |name| self.alloc.free(name);
self.freeSource(tab);
self.alloc.destroy(tab);
}
/// Close a tab, and the window along with it if it was the last one.
fn closeTab(self: *Window, tab: *Tab) void {
if (self.closing) return;
const index = self.indexOf(tab) orelse return;
self.discardTab(tab);
if (self.tabs.items.len == 0) {
// Teardown of our own state happens in onDestroy.
@@ -879,7 +1159,11 @@ fn closeTab(self: *Window, tab: *Tab) void {
// Settings
fn openSettings(self: *Window) void {
SettingsDialog.present(self.alloc, self.window.as(gtk.Window)) catch |err| {
SettingsDialog.present(self.alloc, self.window.as(gtk.Window), .{
.layouts = &self.layouts,
.on_capture = &captureStartupTabs,
.ctx = self,
}) catch |err| {
std.log.err("failed to open settings: {s}", .{@errorName(err)});
};
}
@@ -1021,6 +1305,12 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
// otherwise walk a tab list we are about to destroy.
appearance.clearOnChanged();
// The settings page holds this window's layout store and a pointer back to
// the window itself. It is modal, so you cannot close the window underneath
// it by hand — but a shell exiting can take the last tab and so the window,
// which makes this reachable.
SettingsDialog.close();
// Each terminal owns a session, which owns a PTY and its child process.
// Dropping them here reaps the children rather than orphaning them.
for (self.tabs.items) |tab| {
@@ -1028,8 +1318,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
// window, so nothing else takes it down before the tab it points at.
TabSettingsDialog.closeFor(tab);
tab.view.destroy();
if (tab.custom_name) |name| self.alloc.free(name);
self.alloc.destroy(tab);
self.releaseTab(tab);
}
self.tabs.deinit(self.alloc);