diff --git a/README.md b/README.md index 4fbe15a..7339d4e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/Settings.zig b/src/Settings.zig index e1fa6f7..9971cdf 100644 --- a/src/Settings.zig +++ b/src/Settings.zig @@ -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 { diff --git a/src/SettingsDialog.zig b/src/SettingsDialog.zig index 6c129bd..fb1b46c 100644 --- a/src/SettingsDialog.zig +++ b/src/SettingsDialog.zig @@ -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); +} diff --git a/src/Window.zig b/src/Window.zig index b8ff244..d74a097 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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); diff --git a/src/appearance.zig b/src/appearance.zig index 5ee20b8..bf6b6eb 100644 --- a/src/appearance.zig +++ b/src/appearance.zig @@ -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, diff --git a/src/emoji.zig b/src/emoji.zig index 65380a1..4d69fe5 100644 --- a/src/emoji.zig +++ b/src/emoji.zig @@ -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 ------------------------------------------- diff --git a/src/main.zig b/src/main.zig index 781ac77..ae37714 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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(); diff --git a/src/style.css b/src/style.css index 4ccf7c6..76deae3 100644 --- a/src/style.css +++ b/src/style.css @@ -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 diff --git a/tools/gen-emoji.py b/tools/gen-emoji.py index 03afd91..3416da6 100755 --- a/tools/gen-emoji.py +++ b/tools/gen-emoji.py @@ -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{ '''