Add ability to set boot layout.
This commit is contained in:
+305
-16
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user