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
+65 -9
View File
@@ -4,7 +4,7 @@ A proof-of-concept workspace built on
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
**vertical tabs**, each holding a split tree of terminal and web panes, with
**saved layouts** that open a whole arrangement — directories, scripts and all —
in one go.
in one go, and a **startup list** that opens the ones you always want.
The sidebar holds the window controls, a new-tab button, one row per tab, and a
settings gear at its foot — the layout Zen Browser uses for vertical tabs —
@@ -124,8 +124,8 @@ Pty.zig openpt/fork/exec, controlling terminal setup
script.zig runs a command for `$(...)` in a layout's directory
key.zig GDK keyval -> libghostty-vt key mapping
theme.zig colors libghostty-vt has no opinion about, per scheme
Settings.zig preferences: model, JSON on disk
SettingsDialog.zig the settings page
Settings.zig preferences: theme and startup tabs, JSON on disk
SettingsDialog.zig the settings page, including the startup list editor
TabSettingsDialog.zig one tab's own settings, and the emoji picker
emoji.zig generated: every emoji the picker offers, and the search over them
```
@@ -288,6 +288,55 @@ A node is a split if it has a `split` key and a leaf otherwise. Saves are
atomic — written to a temporary and renamed — so an interrupted write leaves
the previous layouts intact rather than a file that won't parse.
## Startup tabs
Opening the same three layouts against the same three worktrees every morning is
the kind of thing the app should be doing for you. **Settings → Startup** is a
list of tabs to open at launch: one row per tab, each naming a saved layout, the
values to fill its parameters in with, and optionally a name and an emoji to pin
on the row.
The fastest way to fill it in is *Use current tabs*: arrange the window you want,
then say that this is the window you always want. It writes down one entry per
open tab, in sidebar order, with whatever each was opened with.
What is saved is the **recipe, not the session**. An entry is the layout's name
and a handful of values, so it is a few lines you can read, edit and keep — and
the layout itself stays the thing you maintain. Nothing here restores a
scrollback, a running command, or a shell that has since wandered into another
directory; launching re-runs the arrangement, and that was the tedious part.
Rows in the list can be reordered, and the first one is the tab you land in. An
entry with no layout picked opens a plain shell, which is worth having — a
startup list is often two configured tabs and one ordinary shell to work in. An
entry naming a layout that has since been renamed or deleted is skipped with a
warning rather than silently costing you a tab, and the settings page keeps
showing it — with *This layout is no longer saved* under it — so the values you
typed for it survive until you deal with it.
Parameters that the entry doesn't mention open at the layout's own default, which
is the same rule the parameter prompt follows. So an entry only has to carry the
values that differ from what the layout already suggests.
The list lives in `settings.json` beside the theme:
```json
{
"version": 1,
"theme": "dark",
"startup": [
{ "layout": "Project", "name": "signal", "emoji": "🚀",
"parameters": { "path": "~/src/signal" } },
{ "layout": "Project", "parameters": { "path": "~/src/playpen" } },
{ "name": "scratch" }
]
}
```
With no `startup` list the app opens a single shell, exactly as it did before
this existed — and if a list opens nothing at all, that is what you get too,
since a window you can't type in is not an outcome worth being faithful for.
Two design choices worth calling out:
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
@@ -324,6 +373,9 @@ in principle, but a terminal grid is small.
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
and scripts — opened in one go, parameterised by `{{name}}`, authored by
arranging a tab and saving it. See [Layouts](#layouts)
- **A startup list**: the tabs to open at launch, each a layout with its
parameters filled in, fillable from the tabs you have open now. See
[Startup tabs](#startup-tabs)
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
it is working, waiting on you, or finished and still unanswered. See
[Agent status](#agent-status)
@@ -566,16 +618,18 @@ the tab simply keeps the name it had.
Deep navy surfaces with Signal's ultramarine (`#3a76f0`) as the accent, in a
light and a dark scheme. `Ctrl+,` or the gear at the foot of the sidebar opens
**Settings**, which currently holds one choice: light, dark, or **system**,
which is the default and follows the desktop.
**Settings**, whose Appearance section holds the choice: light, dark, or
**system**, which is the default and follows the desktop.
The preference lives in `~/.config/playpen/settings.json` (or
`$XDG_CONFIG_HOME`), beside `layouts.json`:
`$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the
[startup tabs](#startup-tabs):
```json
{
"version": 1,
"theme": "system"
"theme": "system",
"startup": []
}
```
@@ -632,8 +686,10 @@ This is a proof of concept, and the following are deliberately absent:
nothing else: no bookmarks, history, downloads, devtools, or find-in-page,
and each pane uses WebKit's default context, so nothing is persisted between
runs. Links that ask for a new window are ignored rather than opening a pane.
- **Restoring a session.** Layouts save arrangements you open deliberately;
nothing restores the tabs you happened to have open when the app closed.
- **Restoring a session.** [Startup tabs](#startup-tabs) reopen the
arrangement you asked for, but nothing captures the state a session got itself
into: no scrollback, no running commands, no directories a shell wandered off
to. A tab comes back as its layout, not as you left it.
- **Kitty graphics, hyperlinks, tab reordering.**
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
`ghostty`, since we don't install a terminfo entry.
+286 -38
View File
@@ -1,18 +1,25 @@
//! User preferences: the handful of choices that outlive a session.
//! User preferences: the choices that outlive a session.
//!
//! A small JSON file next to `layouts.json`, rewritten whole every time
//! something changes. There is one setting today — the colour scheme — but the
//! file carries a version from the start, so adding the second one is not also
//! the day we invent a format.
//! something changes. It carries a version from the start, so adding the next
//! setting is not also the day we invent a format.
//!
//! There are two settings, and they are very different shapes. The colour
//! scheme is one word. The **startup tabs** are a list: which saved layout to
//! open, what to fill its parameters in with, and what to call the tab — one
//! entry per tab, opened in order at launch. That list is why this module holds
//! an arena: every string in it is owned here, and replacing the list frees the
//! previous one in a single stroke rather than tracking each field.
//!
//! Because saving rewrites the whole file, there is exactly one `Settings` for
//! the process, reached through `get`. Two holders each convinced they knew
//! what was in the file would take turns overwriting the other's half of it —
//! which is precisely how a startup list would get eaten by a theme change.
//!
//! File access goes through GLib for the same reasons `Layouts` does: it knows
//! the XDG config directory, and `g_file_set_contents` writes to a temporary
//! and renames, so an interrupted save leaves the previous settings intact
//! rather than a truncated file that won't parse.
//!
//! Unlike `Layouts` this holds no allocated strings — every field is a fixed
//! scalar — so it needs no arena and can be copied freely. Parsing borrows a
//! stack buffer for the duration of the call and gives it back.
const std = @import("std");
const glib = @import("glib");
@@ -25,11 +32,10 @@ pub const format_version = 1;
const max_path = 4096;
/// Ample for a file holding a version and a word, with enough headroom that a
/// hand-edited one with comments or extra keys still parses. A settings file
/// larger than this is not one we wrote, and defaults are the better answer
/// than a partial read of it.
const max_file_size = 16 * 1024;
/// Ample for a version, a scheme, and a startup list of any length someone
/// would sit through opening. A settings file larger than this is not one we
/// wrote, and defaults are a better answer than a partial read of it.
const max_file_size = 64 * 1024;
/// Which palette to paint the window with.
///
@@ -52,8 +58,77 @@ pub const Theme = enum {
}
};
/// One of a layout's parameters, and what a startup tab opens it with.
pub const Value = struct {
name: []const u8,
value: []const u8,
};
/// A tab to open at launch.
///
/// This is the answer to "restore my whole window" without storing a window's
/// worth of live state. A tab that came from a layout is already fully
/// described by the layout's name and the values it was opened with, and those
/// are three short strings — so the thing that gets saved is the *recipe*, and
/// launching re-runs it. A tab whose shells have wandered somewhere else since
/// is not reproduced, and that is the trade: what comes back is the arrangement
/// you set up, not the session you left.
pub const StartupTab = struct {
/// Saved layout to open, by name. Empty opens a plain shell — the same tab
/// the new-tab button makes.
layout: []const u8 = "",
/// Name pinned on the tab's row, as though it had been typed into the
/// rename entry. Empty leaves the label following the panes.
name: []const u8 = "",
/// Emoji shown in the row in place of the pane icon. Empty means none.
emoji: []const u8 = "",
/// What to fill the layout's parameters in with. A parameter this doesn't
/// name opens at its own default, so an entry only has to carry the values
/// that differ from what the layout already suggests.
parameters: []const Value = &.{},
};
arena: std.heap.ArenaAllocator,
theme: Theme = .system,
/// The tabs to open at launch, in order. Empty means a single plain shell,
/// which is what the app did before this setting existed.
startup: []const StartupTab = &.{},
// -------------------------------------------------------------------------
// The process-wide instance
var instance: ?Settings = null;
/// Read the settings file into the process-wide store. Called once, before the
/// first window is built.
pub fn init(alloc: std.mem.Allocator) void {
std.debug.assert(instance == null);
instance = load(alloc);
}
/// The settings this process is running with.
pub fn get() *Settings {
if (instance) |*self| return self;
// `init` runs from the application's activate handler, which is before
// anything that could ask.
unreachable;
}
/// Release the store. Safe to call without a matching `init`, so the shutdown
/// path doesn't have to know whether the app got as far as activating.
pub fn deinit() void {
if (instance) |*self| self.arena.deinit();
instance = null;
}
// -------------------------------------------------------------------------
// Loading
/// Read the settings file, falling back to defaults for anything missing.
///
/// Every failure below lands on the same behaviour — carry on with defaults —
@@ -61,8 +136,8 @@ theme: Theme = .system,
/// to report an error in, and refusing to start over an unreadable preferences
/// file would be a worse outcome than ignoring it. A malformed file is logged
/// rather than silently swallowed, since the next save overwrites it.
pub fn load() Settings {
var self: Settings = .{};
fn load(alloc: std.mem.Allocator) Settings {
var self: Settings = .{ .arena = .init(alloc) };
var path_buf: [max_path]u8 = undefined;
const path = configPath(&path_buf) orelse return self;
@@ -92,25 +167,27 @@ pub fn load() Settings {
self.parse(contents[0..length]) catch {
std.log.warn("could not parse {s}; using defaults", .{path});
return .{};
// Whatever the failed parse allocated goes with the arena, and a
// half-read startup list is worse than none: it would open some of the
// tabs and then be written back as though that was all there was.
_ = self.arena.reset(.free_all);
return .{ .arena = self.arena };
};
return self;
}
fn parse(self: *Settings, text: []const u8) !void {
// The parse tree only has to outlive this function: every value read out
// of it is copied into a scalar field, so a stack arena is enough and
// nothing here needs to reach the caller's allocator.
var buf: [max_file_size * 4]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&buf);
const ParseError = error{ OutOfMemory, Malformed };
const parsed = try std.json.parseFromSliceLeaky(
fn parse(self: *Settings, text: []const u8) ParseError!void {
// Parsed into the arena and left there: the startup list points into this
// tree's strings, so it has to outlive the call.
const parsed = std.json.parseFromSliceLeaky(
std.json.Value,
fba.allocator(),
self.arena.allocator(),
text,
.{},
);
) catch return error.Malformed;
const root = switch (parsed) {
.object => |o| o,
@@ -125,12 +202,137 @@ fn parse(self: *Settings, text: []const u8) !void {
if (std.meta.stringToEnum(Theme, value.string)) |t| self.theme = t;
}
}
if (root.get("startup")) |value| try self.parseStartup(value);
}
pub const SaveError = error{WriteFailed};
/// Read the startup list.
///
/// Entries that make no sense are skipped rather than failing the file. The
/// list is hand-editable — that is half the point of it being a plain array of
/// short objects — and one mistyped entry should cost that tab, not the theme
/// and every other tab along with it.
fn parseStartup(self: *Settings, raw: std.json.Value) ParseError!void {
const array = switch (raw) {
.array => |a| a,
else => {
std.log.warn("settings: \"startup\" is not a list; ignoring it", .{});
return;
},
};
const alloc = self.arena.allocator();
var tabs: std.ArrayListUnmanaged(StartupTab) = .empty;
try tabs.ensureTotalCapacity(alloc, array.items.len);
for (array.items) |item| {
const obj = switch (item) {
.object => |o| o,
else => {
std.log.warn("settings: skipping a startup entry that is not an object", .{});
continue;
},
};
var tab: StartupTab = .{
.layout = try self.dupeField(obj.get("layout")),
.name = try self.dupeField(obj.get("name")),
.emoji = try self.dupeField(obj.get("emoji")),
};
if (obj.get("parameters")) |params_raw| {
tab.parameters = try self.parseValues(params_raw);
}
tabs.appendAssumeCapacity(tab);
}
self.startup = try tabs.toOwnedSlice(alloc);
}
/// `{"name": "value"}` — an object rather than an array of pairs, because a
/// parameter can only be filled in once per tab and an object is the shape
/// that says so.
fn parseValues(self: *Settings, raw: std.json.Value) ParseError![]const Value {
const obj = switch (raw) {
.object => |o| o,
else => {
std.log.warn("settings: a startup entry's \"parameters\" is not an object", .{});
return &.{};
},
};
const alloc = self.arena.allocator();
var values: std.ArrayListUnmanaged(Value) = .empty;
try values.ensureTotalCapacity(alloc, obj.count());
var it = obj.iterator();
while (it.next()) |kv| {
values.appendAssumeCapacity(.{
.name = try alloc.dupe(u8, kv.key_ptr.*),
.value = try self.dupeField(kv.value_ptr.*),
});
}
return values.toOwnedSlice(alloc);
}
/// A string field, or an empty one for anything else — including a number or a
/// null where a string was expected, which is a typo rather than a reason to
/// throw the file away.
fn dupeField(self: *Settings, raw: ?std.json.Value) ParseError![]const u8 {
return switch (raw orelse return "") {
.string => |s| try self.arena.allocator().dupe(u8, s),
else => "",
};
}
// -------------------------------------------------------------------------
// Editing
/// Replace the startup list with a copy of `entries`.
///
/// Copied into a fresh arena which then takes the old one's place, rather than
/// resetting in place and copying into that. It costs one allocation and makes
/// the obvious call — hand back a lightly edited `settings.startup` — safe,
/// instead of freeing the strings being read from half way through.
pub fn setStartup(self: *Settings, entries: []const StartupTab) error{OutOfMemory}!void {
var next: std.heap.ArenaAllocator = .init(self.arena.child_allocator);
errdefer next.deinit();
const alloc = next.allocator();
const tabs = try alloc.alloc(StartupTab, entries.len);
for (entries, tabs) |from, *to| {
const values = try alloc.alloc(Value, from.parameters.len);
for (from.parameters, values) |v, *out| {
out.* = .{
.name = try alloc.dupe(u8, v.name),
.value = try alloc.dupe(u8, v.value),
};
}
to.* = .{
.layout = try alloc.dupe(u8, from.layout),
.name = try alloc.dupe(u8, from.name),
.emoji = try alloc.dupe(u8, from.emoji),
.parameters = values,
};
}
self.arena.deinit();
self.arena = next;
self.startup = tabs;
}
// -------------------------------------------------------------------------
// Saving
pub const SaveError = error{ OutOfMemory, WriteFailed };
/// Write the whole file back out, creating the config directory if needed.
pub fn save(self: Settings) SaveError!void {
pub fn save(self: *Settings) 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;
@@ -138,17 +340,8 @@ pub fn save(self: Settings) SaveError!void {
var path_buf: [max_path]u8 = undefined;
const path = configPath(&path_buf) orelse return error.WriteFailed;
// Written by hand rather than through the JSON emitter: this is a fixed
// two-line document with no user-supplied strings in it, so there is
// nothing here that needs escaping and nothing that needs an allocator.
var text_buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&text_buf,
\\{{
\\ "version": {d},
\\ "theme": "{s}"
\\}}
\\
, .{ format_version, @tagName(self.theme) }) catch 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) {
@@ -157,6 +350,61 @@ pub fn save(self: Settings) SaveError!void {
}
}
/// Through the JSON emitter rather than by hand, as this once was: the startup
/// list carries names and paths the user typed, and those need escaping.
fn serialize(self: *Settings) 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("theme");
try json.write(@tagName(self.theme));
// Written even when empty, so that the key is discoverable in a file
// someone has opened to see what they can put in it.
try json.objectField("startup");
try json.beginArray();
for (self.startup) |tab| {
try json.beginObject();
// Only what is set, so a hand-edited file stays readable.
if (tab.layout.len > 0) {
try json.objectField("layout");
try json.write(tab.layout);
}
if (tab.name.len > 0) {
try json.objectField("name");
try json.write(tab.name);
}
if (tab.emoji.len > 0) {
try json.objectField("emoji");
try json.write(tab.emoji);
}
if (tab.parameters.len > 0) {
try json.objectField("parameters");
try json.beginObject();
for (tab.parameters) |v| {
try json.objectField(v.name);
try json.write(v.value);
}
try json.endObject();
}
try json.endObject();
}
try json.endArray();
try json.endObject();
// A trailing newline, so the file behaves in an editor.
try out.writer.writeByte('\n');
return out.toOwnedSlice();
}
/// `$XDG_CONFIG_HOME/playpen/settings.json`, or
/// `~/.config/playpen/settings.json` when that isn't set.
fn configPath(buf: []u8) ?[:0]const u8 {
+609 -20
View File
@@ -9,36 +9,111 @@
//! Changes apply the moment you make them, with no confirm button. That is the
//! right shape for what is here: picking a scheme repaints the window behind
//! the dialog, so the setting *is* its own preview, and an OK button would
//! only offer to undo something you can see the result of.
//! only offer to undo something you can see the result of. The startup list has
//! no preview — it is a description of the *next* launch — but the same rule
//! reads better than one section of the page behaving differently from the
//! other, so it too takes effect as it is edited.
//!
//! Only one is open at a time. The window is remembered while it is up and
//! presented again rather than duplicated, since two settings pages
//! disagreeing about which scheme is selected is a bug with no upside.
//! What "takes effect" means is split, though, and deliberately. Every edit goes
//! into the in-memory settings immediately; the *file* is written on the edits
//! that are worth a write — adding, removing, reordering, changing a layout,
//! capturing the open tabs — and once more when the page is closed. Typing a tab
//! name is therefore free rather than an fsync per keystroke, and nothing can be
//! lost by it: anything else that saves the settings, a theme change included,
//! writes the current list along with it.
//!
//! Only one page is open at a time. The window is remembered while it is up and
//! presented again rather than duplicated, since two settings pages disagreeing
//! about which scheme is selected is a bug with no upside.
const std = @import("std");
const gio = @import("gio");
const gobject = @import("gobject");
const gtk = @import("gtk");
const Layouts = @import("Layouts.zig");
const Settings = @import("Settings.zig");
const appearance = @import("appearance.zig");
const SettingsDialog = @This();
/// Asked for when "use current tabs" is clicked. The window writes the open
/// tabs into the settings itself — it is the only thing that knows what each tab
/// was opened with — and this page then rebuilds its rows from the result.
pub const Capture = *const fn (ctx: ?*anyopaque) void;
pub const Options = struct {
/// The saved layouts, for the per-row layout picker. Borrowed for as long
/// as the page is up, which is why the window closes the page as it goes.
layouts: *Layouts,
on_capture: Capture,
ctx: ?*anyopaque,
};
/// The label on the first item of every layout picker. Not a layout name, so
/// `selectedLayout` answers with an empty string for it.
const plain_label = "Plain terminal";
/// One filled-in parameter of a startup row's layout.
///
/// The name is copied rather than borrowed from the layout it came from: the
/// layout store can be reloaded from disk from the layout menu while this page
/// is open, and a row holding pointers into the arena that reload frees would
/// be reading freed memory at the next keystroke.
const ParamField = struct {
name: []u8,
entry: *gtk.Entry,
};
/// One tab in the startup list: which layout, what to call it, and a field per
/// parameter of whichever layout is currently picked.
const StartupRow = struct {
dialog: *SettingsDialog,
/// The whole row — the header line and the parameter fields under it.
box: *gtk.Box,
dropdown: *gtk.DropDown,
name: *gtk.Entry,
emoji: *gtk.Entry,
/// Holds the parameter fields, so they can be rebuilt in place when the
/// picked layout changes.
params_box: *gtk.Box,
params: std.ArrayListUnmanaged(*ParamField) = .empty,
};
alloc: std.mem.Allocator,
window: *gtk.Window,
layouts: *Layouts,
on_capture: Capture,
ctx: ?*anyopaque,
/// One per `Settings.Theme`, in declaration order, so the selected one can be
/// re-checked without asking each button what it stands for.
theme_buttons: [std.enums.values(Settings.Theme).len]*gtk.ToggleButton,
/// Set while a click is being applied, so that the resulting `toggled` signals
/// on the other buttons in the group don't re-enter and undo it.
/// Container the startup rows live in, so rows can be added and removed after
/// the page is already on screen.
startup_box: *gtk.Box,
startup: std.ArrayListUnmanaged(*StartupRow) = .empty,
/// Shown when the settings file can't be written, rather than letting the page
/// look as though the edit landed.
error_label: *gtk.Label,
/// Set while a click or a rebuild is being applied, so that the signals it
/// provokes — the other toggles in the group, the entries being filled in —
/// don't re-enter and undo it.
updating: bool = false,
/// The open dialog, if there is one. A file-level singleton because "the
/// settings page" is a singular thing from the user's point of view.
var open: ?*SettingsDialog = null;
pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window) !void {
pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !void {
if (open) |existing| {
existing.window.present();
return;
@@ -50,31 +125,53 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window) !void {
self.* = .{
.alloc = alloc,
.window = gtk.Window.new(),
.layouts = opts.layouts,
.on_capture = opts.on_capture,
.ctx = opts.ctx,
.theme_buttons = undefined,
.startup_box = gtk.Box.new(.vertical, 6),
.error_label = gtk.Label.new(null),
};
self.window.setTitle("Settings");
self.window.setTransientFor(parent);
self.window.setModal(1);
self.window.setDefaultSize(460, -1);
self.window.setDefaultSize(560, 620);
self.window.as(gtk.Widget).addCssClass("playpen-dialog");
const content = gtk.Box.new(.vertical, 12);
content.as(gtk.Widget).addCssClass("playpen-dialog-content");
content.append(self.buildAppearance());
content.append(self.buildStartup());
self.error_label.setXalign(0);
self.error_label.setWrap(1);
self.error_label.as(gtk.Widget).addCssClass("playpen-dialog-error");
self.error_label.as(gtk.Widget).setVisible(0);
content.append(self.error_label.as(gtk.Widget));
// The startup list grows with the number of tabs someone opens at launch,
// so the page scrolls rather than pushing the close button off the bottom.
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("playpen-dialog-actions");
// "Close" rather than "OK": nothing here is pending, so there is nothing
// for a confirm button to confirm.
const close = gtk.Button.newWithLabel("Close");
_ = gtk.Button.signals.clicked.connect(close, *SettingsDialog, &onClose, self, .{});
buttons.append(close.as(gtk.Widget));
const close_button = gtk.Button.newWithLabel("Close");
_ = gtk.Button.signals.clicked.connect(close_button, *SettingsDialog, &onClose, self, .{});
buttons.append(close_button.as(gtk.Widget));
content.append(buttons.as(gtk.Widget));
self.window.setChild(content.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,
@@ -88,8 +185,18 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window) !void {
self.window.present();
}
/// The Appearance section. One group today; the box it returns is what a
/// second section would sit next to.
/// Close the page, if it is open.
///
/// Called as the window is destroyed: the page holds the window's layout store
/// and a pointer back to the window, both of which are about to be freed.
pub fn close() void {
if (open) |existing| existing.window.destroy();
}
// -------------------------------------------------------------------------
// Appearance
/// The Appearance section: one row, the colour scheme.
fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
const group = gtk.Box.new(.vertical, 10);
group.as(gtk.Widget).addCssClass("playpen-settings-group");
@@ -110,11 +217,11 @@ fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
labels.append(name.as(gtk.Widget));
const hint = gtk.Label.new("System follows the desktop's light or dark setting.");
hint.setXalign(0);
hint.setWrap(1);
hint.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
labels.append(hint.as(gtk.Widget));
const hint_label = gtk.Label.new("System follows the desktop's light or dark setting.");
hint_label.setXalign(0);
hint_label.setWrap(1);
hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
labels.append(hint_label.as(gtk.Widget));
row.append(labels.as(gtk.Widget));
row.append(self.buildThemeChoice());
@@ -187,11 +294,493 @@ fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c)
appearance.setTheme(values[index]);
}
// -------------------------------------------------------------------------
// Startup
//
// One row per tab to open at launch: a layout to open, a name and emoji to pin
// on the tab, and a field for each of that layout's parameters. This is the
// answer to "put my window back the way I had it" — not by remembering a
// session, but by writing down the handful of layouts and values that produced
// it, which is a thing you can then read, edit and keep.
//
// "Use current tabs" is the shortcut for filling it in: arrange the window you
// want, then say that this is the window you always want.
fn buildStartup(self: *SettingsDialog) *gtk.Widget {
const group = gtk.Box.new(.vertical, 10);
group.as(gtk.Widget).addCssClass("playpen-settings-group");
const title = gtk.Label.new("Startup");
title.setXalign(0);
title.as(gtk.Widget).addCssClass("playpen-settings-title");
group.append(title.as(gtk.Widget));
group.append(hint(
"Tabs to open when Playpen starts, in order. " ++
"With none listed it opens a single shell.",
));
group.append(self.startup_box.as(gtk.Widget));
self.fillStartupRows();
const actions = gtk.Box.new(.horizontal, 8);
const add = gtk.Button.newWithLabel("Add tab");
add.as(gtk.Widget).addCssClass("flat");
_ = gtk.Button.signals.clicked.connect(add, *SettingsDialog, &onAddStartup, self, .{});
actions.append(add.as(gtk.Widget));
const capture = gtk.Button.newWithLabel("Use current tabs");
capture.as(gtk.Widget).addCssClass("flat");
capture.as(gtk.Widget).setTooltipText(
"Replace this list with the tabs open now, and what each was opened with",
);
_ = gtk.Button.signals.clicked.connect(capture, *SettingsDialog, &onCapture, self, .{});
actions.append(capture.as(gtk.Widget));
group.append(actions.as(gtk.Widget));
return group.as(gtk.Widget);
}
/// Build the rows from the settings, discarding whatever is there now.
///
/// The rows are a view of the saved list rather than a model of their own, so
/// anything that rewrites the list wholesale — reordering, capturing the open
/// tabs — saves and then comes back through here.
fn fillStartupRows(self: *SettingsDialog) void {
self.updating = true;
defer self.updating = false;
self.clearStartupRows();
for (Settings.get().startup) |entry| {
self.addStartupRow(entry) catch |err| {
std.log.err("could not show a startup tab: {s}", .{@errorName(err)});
return;
};
}
}
fn clearStartupRows(self: *SettingsDialog) void {
for (self.startup.items) |row| {
self.startup_box.remove(row.box.as(gtk.Widget));
self.freeRow(row);
}
self.startup.clearRetainingCapacity();
}
fn freeRow(self: *SettingsDialog, row: *StartupRow) void {
self.freeParams(row);
row.params.deinit(self.alloc);
self.alloc.destroy(row);
}
fn addStartupRow(self: *SettingsDialog, entry: Settings.StartupTab) !void {
const row = try self.alloc.create(StartupRow);
errdefer self.alloc.destroy(row);
row.* = .{
.dialog = self,
.box = gtk.Box.new(.vertical, 4),
.dropdown = self.newLayoutPicker(entry.layout),
.name = gtk.Entry.new(),
.emoji = gtk.Entry.new(),
.params_box = gtk.Box.new(.vertical, 4),
};
row.box.as(gtk.Widget).addCssClass("playpen-startup-row");
// ---- header line ---------------------------------------------------
const header = gtk.Box.new(.horizontal, 6);
row.dropdown.as(gtk.Widget).setTooltipText("Which saved layout this tab opens");
_ = gobject.Object.signals.notify.connect(
row.dropdown,
*StartupRow,
&onLayoutPicked,
row,
.{ .detail = "selected" },
);
header.append(row.dropdown.as(gtk.Widget));
row.name.setPlaceholderText("tab name (optional)");
row.name.as(gtk.Widget).setHexpand(1);
setEntryText(row.name, entry.name);
_ = gtk.Editable.signals.changed.connect(row.name, *StartupRow, &onRowEdited, row, .{});
header.append(row.name.as(gtk.Widget));
// Narrow, because it holds one glyph. Pasted rather than picked: the picker
// is two thousand glyphs deep and belongs to a tab, not to a list of them.
row.emoji.setPlaceholderText("🙂");
row.emoji.as(gtk.Editable).setMaxWidthChars(3);
row.emoji.as(gtk.Widget).setTooltipText("Emoji for the tab's row — paste one");
setEntryText(row.emoji, entry.emoji);
_ = gtk.Editable.signals.changed.connect(row.emoji, *StartupRow, &onRowEdited, row, .{});
header.append(row.emoji.as(gtk.Widget));
header.append(rowButton("go-up-symbolic", "Open this tab earlier", &onMoveUp, row));
header.append(rowButton("go-down-symbolic", "Open this tab later", &onMoveDown, row));
header.append(rowButton("list-remove-symbolic", "Don't open this tab", &onRemoveStartup, row));
row.box.append(header.as(gtk.Widget));
// ---- parameters ----------------------------------------------------
row.params_box.as(gtk.Widget).addCssClass("playpen-startup-params");
row.box.append(row.params_box.as(gtk.Widget));
try self.startup.append(self.alloc, row);
self.startup_box.append(row.box.as(gtk.Widget));
try self.fillParams(row, entry.parameters);
}
fn rowButton(
icon: [:0]const u8,
tooltip: [:0]const u8,
handler: *const fn (*gtk.Button, *StartupRow) callconv(.c) void,
row: *StartupRow,
) *gtk.Widget {
const button = gtk.Button.newFromIconName(icon);
button.as(gtk.Widget).addCssClass("flat");
button.as(gtk.Widget).setTooltipText(tooltip);
_ = gtk.Button.signals.clicked.connect(button, *StartupRow, handler, row, .{});
return button.as(gtk.Widget);
}
/// A picker holding "Plain terminal", then every saved layout.
///
/// A layout named by the settings but no longer saved is appended so that it can
/// still be selected. Without that, opening this page would quietly re-file the
/// entry as a plain terminal the moment anything else was edited — a renamed
/// layout would cost you the values you had typed for it, which is precisely
/// when you would want them back.
fn newLayoutPicker(self: *SettingsDialog, selected: []const u8) *gtk.DropDown {
const model = gtk.StringList.new(null);
model.append(plain_label);
// Counted as they go in rather than taken from the layout list's length: a
// name too long to print is skipped, and an index derived from the count
// would then point at the wrong layout for every row after it.
var count: u32 = 1;
var index: u32 = 0;
for (self.layouts.items.items) |layout| {
var buf: [256]u8 = undefined;
const name = std.fmt.bufPrintZ(&buf, "{s}", .{layout.name}) catch continue;
model.append(name.ptr);
if (std.mem.eql(u8, layout.name, selected)) index = count;
count += 1;
}
if (selected.len > 0 and index == 0) {
var buf: [256]u8 = undefined;
if (std.fmt.bufPrintZ(&buf, "{s}", .{selected})) |name| {
model.append(name.ptr);
index = count;
} else |_| {}
}
const dropdown = gtk.DropDown.new(model.as(gio.ListModel), null);
dropdown.setSelected(index);
return dropdown;
}
/// The layout a row is set to, or an empty string for a plain terminal.
///
/// Read back out of the picker's own model rather than by indexing a list of
/// names kept alongside it: the model is the one thing guaranteed to still agree
/// with what is on screen, including the entry for a layout that has since been
/// renamed away.
fn selectedLayout(row: *StartupRow) []const u8 {
if (row.dropdown.getSelected() == 0) return "";
const item = row.dropdown.getSelectedItem() orelse return "";
const string = gobject.ext.cast(gtk.StringObject, item) orelse return "";
return std.mem.span(string.getString());
}
// -------------------------------------------------------------------------
// Parameter fields
/// One entry per parameter of the row's layout, prefilled with the value the
/// entry gives it, or with the parameter's own default when it gives none —
/// which is the same rule the tab itself opens under.
fn fillParams(
self: *SettingsDialog,
row: *StartupRow,
values: []const Settings.Value,
) !void {
const layout_name = selectedLayout(row);
if (layout_name.len == 0) return;
const layout = self.layouts.find(layout_name) orelse {
row.params_box.append(hint("This layout is no longer saved."));
return;
};
if (layout.parameters.len == 0) return;
const grid = gtk.Grid.new();
grid.setRowSpacing(4);
grid.setColumnSpacing(8);
for (layout.parameters, 0..) |param, i| {
const field = try self.alloc.create(ParamField);
errdefer self.alloc.destroy(field);
field.* = .{
.name = try self.alloc.dupe(u8, param.name),
.entry = gtk.Entry.new(),
};
errdefer self.alloc.free(field.name);
var label_buf: [128]u8 = undefined;
const text = std.fmt.bufPrintZ(&label_buf, "{s}", .{
if (param.description.len > 0) param.description else param.name,
}) catch "parameter";
const label = gtk.Label.new(text);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
grid.attach(label.as(gtk.Widget), 0, @intCast(i), 1, 1);
var hint_buf: [128]u8 = undefined;
field.entry.setPlaceholderText(
std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null,
);
field.entry.as(gtk.Widget).setHexpand(1);
setEntryText(field.entry, valueOf(values, param.name) orelse param.default);
_ = gtk.Editable.signals.changed.connect(
field.entry,
*StartupRow,
&onRowEdited,
row,
.{},
);
grid.attach(field.entry.as(gtk.Widget), 1, @intCast(i), 1, 1);
try row.params.append(self.alloc, field);
}
row.params_box.append(grid.as(gtk.Widget));
}
fn freeParams(self: *SettingsDialog, row: *StartupRow) void {
for (row.params.items) |field| {
self.alloc.free(field.name);
self.alloc.destroy(field);
}
row.params.clearRetainingCapacity();
}
fn valueOf(values: []const Settings.Value, name: []const u8) ?[]const u8 {
for (values) |v| {
if (std.mem.eql(u8, v.name, name)) return v.value;
}
return null;
}
// -------------------------------------------------------------------------
// Startup handlers
fn onAddStartup(_: *gtk.Button, self: *SettingsDialog) callconv(.c) void {
{
self.updating = true;
defer self.updating = false;
self.addStartupRow(.{}) catch |err| {
std.log.err("could not add a startup tab: {s}", .{@errorName(err)});
return;
};
}
self.persist();
}
fn onRemoveStartup(_: *gtk.Button, row: *StartupRow) callconv(.c) void {
const self = row.dialog;
for (self.startup.items, 0..) |candidate, i| {
if (candidate == row) {
_ = self.startup.orderedRemove(i);
break;
}
}
self.startup_box.remove(row.box.as(gtk.Widget));
self.freeRow(row);
self.persist();
}
fn onMoveUp(_: *gtk.Button, row: *StartupRow) callconv(.c) void {
row.dialog.move(row, -1);
}
fn onMoveDown(_: *gtk.Button, row: *StartupRow) callconv(.c) void {
row.dialog.move(row, 1);
}
/// Reorder by writing the new order out and rebuilding from it, rather than by
/// shuffling widgets. The rows are already a view of the saved list, so this is
/// the one path that can't leave the two disagreeing.
fn move(self: *SettingsDialog, row: *StartupRow, delta: isize) void {
const from = for (self.startup.items, 0..) |candidate, i| {
if (candidate == row) break i;
} else return;
const to = @as(isize, @intCast(from)) + delta;
if (to < 0 or to >= @as(isize, @intCast(self.startup.items.len))) return;
std.mem.swap(*StartupRow, &self.startup.items[from], &self.startup.items[@intCast(to)]);
self.persist();
self.fillStartupRows();
}
fn onRowEdited(_: *gtk.Entry, row: *StartupRow) callconv(.c) void {
// Typing is committed to memory but not to disk — see the note at the top
// of the file on why the file is written on the structural edits instead.
row.dialog.commit();
}
/// The picked layout changed, so the parameter fields under it are no longer the
/// right ones. They are rebuilt at the new layout's defaults rather than carried
/// across by name: two layouts that happen to share a parameter name rarely mean
/// the same thing by it, and a default is a better guess than a value typed for
/// something else.
fn onLayoutPicked(_: *gtk.DropDown, _: *gobject.ParamSpec, row: *StartupRow) callconv(.c) void {
const self = row.dialog;
if (self.updating) return;
{
self.updating = true;
defer self.updating = false;
self.freeParams(row);
while (row.params_box.as(gtk.Widget).getFirstChild()) |child| {
row.params_box.remove(child);
}
self.fillParams(row, &.{}) catch |err| {
std.log.err("could not show a layout's parameters: {s}", .{@errorName(err)});
};
}
self.persist();
}
fn onCapture(_: *gtk.Button, self: *SettingsDialog) callconv(.c) void {
// The window writes the settings itself, since only it knows what each tab
// was opened with; this page then reflects whatever that produced.
self.on_capture(self.ctx);
self.fillStartupRows();
self.showError(null);
}
// -------------------------------------------------------------------------
// Committing
/// Read the rows into the settings, in the order they are on screen.
fn commit(self: *SettingsDialog) void {
if (self.updating) return;
var entries: std.ArrayListUnmanaged(Settings.StartupTab) = .empty;
defer {
for (entries.items) |entry| self.alloc.free(entry.parameters);
entries.deinit(self.alloc);
}
for (self.startup.items) |row| {
const values = self.alloc.alloc(Settings.Value, row.params.items.len) catch return;
for (row.params.items, values) |field, *out| {
out.* = .{ .name = field.name, .value = entryText(field.entry) };
}
entries.append(self.alloc, .{
.layout = selectedLayout(row),
.name = trimmed(entryText(row.name)),
.emoji = trimmed(entryText(row.emoji)),
.parameters = values,
}) catch {
self.alloc.free(values);
return;
};
}
// Everything above borrows from the widgets, and `setStartup` copies, so
// nothing outlives this call.
Settings.get().setStartup(entries.items) catch |err| {
std.log.err("could not update the startup tabs: {s}", .{@errorName(err)});
};
}
/// Commit, then write the file.
fn persist(self: *SettingsDialog) void {
self.commit();
Settings.get().save() catch {
self.showError("Could not write the settings file.");
return;
};
self.showError(null);
}
/// Write the file without reading the rows first.
///
/// This is the closing path, and it cannot commit: `destroy` reaches us from
/// `gtk_widget_dispose`, by which point the entries this page was built from
/// have already been disposed and asking one for its text is a crash. It doesn't
/// need to — every edit is committed to memory as it is made, so what is in the
/// settings is already what was on screen.
fn saveQuietly() void {
Settings.get().save() catch {
std.log.err("failed to save settings", .{});
};
}
fn showError(self: *SettingsDialog, message: ?[:0]const u8) void {
if (message) |text| {
self.error_label.setText(text);
self.error_label.as(gtk.Widget).setVisible(1);
} else {
self.error_label.as(gtk.Widget).setVisible(0);
}
}
fn onClose(_: *gtk.Button, self: *SettingsDialog) callconv(.c) void {
self.window.destroy();
}
fn onDestroy(_: *gtk.Window, self: *SettingsDialog) callconv(.c) void {
if (open == self) open = null;
// The last write, catching whatever was typed since the previous one.
saveQuietly();
for (self.startup.items) |row| self.freeRow(row);
self.startup.deinit(self.alloc);
self.alloc.destroy(self);
}
// -------------------------------------------------------------------------
// Small widget helpers
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("playpen-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);
}
fn entryText(entry: *gtk.Entry) []const u8 {
return std.mem.span(entry.as(gtk.Editable).getText());
}
fn trimmed(text: []const u8) []const u8 {
return std.mem.trim(u8, text, &std.ascii.whitespace);
}
+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);
+7 -7
View File
@@ -39,16 +39,15 @@ const css_light = @embedFile("palette-light.css") ++ style;
/// doesn't know are colour-dependent — the Cairo-drawn terminal grids.
pub const Callback = *const fn (ctx: ?*anyopaque) void;
var settings: Settings = .{};
var provider: ?*gtk.CssProvider = null;
var on_changed: ?Callback = null;
var on_changed_ctx: ?*anyopaque = null;
/// Load the saved preference, install the stylesheet, and start following the
/// resolved scheme. Called once, before the first window is built.
/// Install the stylesheet and start following the resolved scheme. Called once,
/// before the first window is built, and after `Settings.init` — the preference
/// is read from the process-wide settings rather than loaded again here, so that
/// saving a theme can't overwrite the rest of the file.
pub fn init() void {
settings = .load();
if (gdk.Display.getDefault()) |display| {
const css = gtk.CssProvider.new();
provider = css;
@@ -72,7 +71,7 @@ pub fn init() void {
}
pub fn currentTheme() Settings.Theme {
return settings.theme;
return Settings.get().theme;
}
/// Change the preference, persist it, and repaint.
@@ -80,6 +79,7 @@ pub fn currentTheme() Settings.Theme {
/// The write is best-effort: a settings file we can't write is worth a log
/// line, but it is not a reason to refuse the change for this session.
pub fn setTheme(to: Settings.Theme) void {
const settings = Settings.get();
if (settings.theme == to) return;
settings.theme = to;
@@ -95,7 +95,7 @@ pub fn setTheme(to: Settings.Theme) void {
/// before it returns — or later, when the desktop changes under a `system`
/// preference.
fn applyPreference() void {
adw.StyleManager.getDefault().setColorScheme(switch (settings.theme) {
adw.StyleManager.getDefault().setColorScheme(switch (Settings.get().theme) {
.system => .default,
.light => .force_light,
.dark => .force_dark,
+15
View File
@@ -49,6 +49,21 @@ pub fn matches(emoji: Emoji, query: []const u8) bool {
return true;
}
/// The table's own copy of `glyph`, or null if it isn't one of ours.
///
/// What comes back is static, so a caller can hold it for as long as it likes
/// with nothing to free — which is what lets a glyph read out of a config file
/// be handed to something that wants a pointer it can keep. A glyph the table
/// doesn't have reads as no choice at all: the picker is where these come from,
/// so anything else is a hand-edited file naming something this build can't
/// draw at the size the row wants.
pub fn lookup(glyph: []const u8) ?[:0]const u8 {
for (table) |entry| {
if (std.mem.eql(u8, entry.glyph, glyph)) return entry.glyph;
}
return null;
}
/// Every emoji the picker offers, in Unicode's order.
pub const table = [_]Emoji{
// ---- Smileys & Emotion -------------------------------------------
+9
View File
@@ -9,6 +9,7 @@ const std = @import("std");
const adw = @import("adw");
const gio = @import("gio");
const Settings = @import("Settings.zig");
const Window = @import("Window.zig");
const appearance = @import("appearance.zig");
@@ -23,6 +24,10 @@ var gpa: std.heap.DebugAllocator(.{}) = .init;
pub fn main() u8 {
defer _ = gpa.deinit();
// Before the allocator's own teardown, since the settings arena comes out
// of it. A no-op if the app never got as far as activating.
defer Settings.deinit();
// Non-unique so every launch is its own process. The default GApplication
// behavior hands off to an already-running instance over D-Bus, which for
// a terminal means a second launch silently does nothing visible here and
@@ -37,6 +42,10 @@ pub fn main() u8 {
}
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
// The file both of the next two read from: the scheme, and the tabs the
// window opens itself with.
Settings.init(gpa.allocator());
// Before the window, so that the first frame is drawn in the scheme the
// user chose rather than repainted into it a moment later.
appearance.init();
+19
View File
@@ -545,6 +545,25 @@ button.playpen-header-button:hover,
border-color: @pp_accent_strong;
}
/* -------------------------------------------------------------------------
The startup list
One card per tab the app opens itself with. The border is doing real work: a
row's parameter fields sit *under* its header line, and without something
drawing the boundary they read as belonging to the row below them instead. */
.playpen-startup-row {
padding: 8px;
border: 1px solid @pp_border;
border-radius: 8px;
}
/* Indented under the header line, which is the other half of the same grouping:
these fields are a property of the layout picked above them. */
.playpen-startup-params {
margin-left: 10px;
}
/* -------------------------------------------------------------------------
The emoji picker
+15
View File
@@ -344,6 +344,21 @@ pub fn matches(emoji: Emoji, query: []const u8) bool {
return true;
}
/// The table's own copy of `glyph`, or null if it isn't one of ours.
///
/// What comes back is static, so a caller can hold it for as long as it likes
/// with nothing to free which is what lets a glyph read out of a config file
/// be handed to something that wants a pointer it can keep. A glyph the table
/// doesn't have reads as no choice at all: the picker is where these come from,
/// so anything else is a hand-edited file naming something this build can't
/// draw at the size the row wants.
pub fn lookup(glyph: []const u8) ?[:0]const u8 {
for (table) |entry| {
if (std.mem.eql(u8, entry.glyph, glyph)) return entry.glyph;
}
return null;
}
/// Every emoji the picker offers, in Unicode's order.
pub const table = [_]Emoji{
'''