diff --git a/README.md b/README.md index 5020156..aba5f0a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,9 @@ Pane.zig content plus its header, drag source, and drop target Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling Browser.zig WebKitWebView plus a back/forward/reload/address bar webkit.zig hand-written bindings for the WebKitGTK calls we make +Layouts.zig saved tab templates: model, JSON on disk, {{substitution}} +OpenLayoutDialog.zig prompts for a layout's parameters +SaveLayoutDialog.zig turns the current tab into a saved layout Session.zig libghostty-vt Terminal + parser, fed by the PTY Pty.zig openpt/fork/exec, controlling terminal setup key.zig GDK keyval -> libghostty-vt key mapping @@ -154,6 +157,88 @@ preview. Cancelling a drag puts the pane back: only the dragged pane ever moves, so the rest of the tree is unchanged and re-inserting it beside its original sibling restores the original shape. +## Layouts + +A **layout** is a saved tab: an arrangement of panes, each with a directory and +a script, that opens in one go. Layouts take **parameters**, so one layout +serves any number of projects. + +The layout button in the sidebar lists them. Picking one asks for its +parameters — prefilled with their defaults — and opens a new tab. A layout with +no parameters skips the prompt. The plain new-tab button is untouched: it still +opens one shell, immediately. + +**You author layouts by arranging a tab.** Split it, drag panes around, drag the +dividers, then *Save tab as layout…*. That captures the tree, the split +orientations and the ratios exactly as they are on screen, and asks only for +what it can't infer: a name, the parameters, and each pane's script. There is +deliberately no separate layout builder — the split tree already is one. + +The save dialog prefills what it can read off the live tab: each terminal's +current directory, straight out of `/proc//cwd`, and each web pane's +current page. So the usual flow is to get a tab set up the way you like, +save it, and replace the literal paths with `{{parameters}}`. + +**Editing** a saved layout opens the same dialog on the stored one, so its +name, parameters and per-pane scripts can be changed without opening it. +Renaming moves the layout rather than copying it, and renaming onto a name +another layout already has is refused instead of quietly replacing it. Nothing +is written until you confirm, so cancelling leaves the layout untouched. To +change the *shape* of a layout, open it, rearrange the tab, and save over it +under the same name — the same tools you used to build it in the first place. + +Every `cwd`, `command` and `url` goes through `{{name}}` substitution, and a +leading `~` is expanded afterwards. An unknown `{{name}}` is left as written +rather than blanked, so a typo shows up in the pane instead of silently +producing an empty path. + +**Scripts are typed into the shell, not run instead of it.** A pane starts your +login shell as usual, and the script is fed to it once it is ready. The shell +is still there when the script finishes, with your environment loaded and the +command in history. The wait matters: writing at spawn time loses the input, +because shells that set up line editing discard whatever was buffered while +they were initializing. The shell's first output is the signal that it is +reading, so that is when the script goes in. + +Layouts live in `~/.config/vtabs/layouts.json` (or `$XDG_CONFIG_HOME`), and the +file is meant to be edited by hand as well — *Reload from disk* picks up +changes. It is JSON because the app writes it too, and a format that +round-trips without a hand-written emitter is worth more here than a prettier +one. + +```json +{ + "version": 1, + "layouts": [ + { + "name": "Project", + "parameters": [ + { "name": "path", "description": "Project directory", "default": "~" } + ], + "root": { + "split": "horizontal", + "ratio": 0.55, + "first": { + "kind": "terminal", + "cwd": "{{path}}", + "command": "git status" + }, + "second": { + "split": "vertical", + "ratio": 0.5, + "first": { "kind": "terminal", "cwd": "{{path}}", "command": "nvim ." }, + "second": { "kind": "web", "url": "https://github.com" } + } + } + } + ] +} +``` + +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. + Two design choices worth calling out: **No IO thread.** The PTY is read on the GLib main loop through a unix fd @@ -187,6 +272,9 @@ in principle, but a terminal grid is small. dragging around exactly like they do: back/forward/reload, an address bar that takes a URL or falls back to a search, a load-progress indicator in the entry, and the page title feeding the pane header and tab label +- **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) ### Shortcuts @@ -225,8 +313,9 @@ 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. -- **Saved layouts.** A view's arrangement lives only as long as the tab. -- **Kitty graphics, hyperlinks, tab reordering, config file.** +- **Restoring a session.** Layouts save arrangements you open deliberately; + nothing restores the tabs you happened to have open when the app closed. +- **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/Browser.zig b/src/Browser.zig index f28fac1..8c518e0 100644 --- a/src/Browser.zig +++ b/src/Browser.zig @@ -49,7 +49,17 @@ on_exit: *const fn (ctx: ?*anyopaque) void, on_focus: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque = null, -pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser { +/// What a layout can specify for a web pane. +pub const Options = struct { + /// Page to open with. Empty leaves the pane on its address bar. + url: []const u8 = "", +}; + +pub fn create( + alloc: std.mem.Allocator, + opts: Options, + cbs: Pane.Callbacks, +) !*Browser { const self = try alloc.create(Browser); errdefer alloc.destroy(self); @@ -99,6 +109,11 @@ pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser { self.box.as(gtk.Widget).addController(focus.as(gtk.EventController)); self.syncNav(); + + // Navigating here rather than after the widget is realized is fine: the + // load runs on the main loop and the view catches up when it appears. + if (opts.url.len > 0) self.navigate(opts.url); + return self; } @@ -147,6 +162,13 @@ pub fn title(self: *Browser) []const u8 { return "web"; } +/// The page currently loaded, or an empty slice if there isn't one. Borrowed +/// from WebKit, so copy it if it needs to outlive the next navigation. +pub fn currentUrl(self: *Browser) []const u8 { + const uri = self.view.getUri() orelse return ""; + return std.mem.span(uri); +} + /// Load an address, applying the same interpretation the address bar does. pub fn navigate(self: *Browser, input: []const u8) void { const uri = self.resolve(input) orelse return; diff --git a/src/Layout.zig b/src/Layout.zig index b79bcdf..4130bbb 100644 --- a/src/Layout.zig +++ b/src/Layout.zig @@ -157,6 +157,22 @@ pub fn remove(self: *Layout, node: *Node) void { self.alloc.destroy(parent); } +/// Build a split holding two existing subtrees. +/// +/// `insert` grows a tree one pane at a time, which is the right shape for +/// interactive splitting but can't express an arbitrary arrangement with +/// per-split ratios. Opening a saved layout needs exactly that, so it builds +/// the tree bottom-up through this instead. +pub fn newSplit( + self: *Layout, + orientation: gtk.Orientation, + a: *Node, + b: *Node, + ratio: f64, +) !*Node { + return self.makeSplit(orientation, a, b, ratio); +} + fn makeSplit( self: *Layout, orientation: gtk.Orientation, diff --git a/src/Layouts.zig b/src/Layouts.zig new file mode 100644 index 0000000..7b1240a --- /dev/null +++ b/src/Layouts.zig @@ -0,0 +1,573 @@ +//! Saved layouts: named tab templates that open a whole arrangement of panes +//! at once, each with a directory and a script to run. +//! +//! A layout is the shape of a tab (the same split tree a view uses) plus, per +//! pane, what to start it with. Layouts take **parameters** — declared by name, +//! filled in when you open one — and every `cwd`, `command` and `url` is run +//! through `{{name}}` substitution first. That is what makes one layout usable +//! against any number of projects. +//! +//! Everything lives in a single JSON file under the user's config directory. +//! JSON because the app writes this file as well as reading it: layouts are +//! authored in the app, and a format we can round-trip without a hand-written +//! emitter is worth more here than a prettier one. +//! +//! All strings are owned by `arena`, so loading, adding and removing layouts +//! never has to track individual allocations — the whole set is freed or +//! replaced at once. +//! +//! File access goes through GLib rather than `std.fs`. GLib is already linked, +//! it knows the XDG config directory, and `g_file_set_contents` writes to a +//! temporary and renames — so an interrupted save leaves the previous layouts +//! intact instead of a truncated file that won't parse. Zig's own filesystem +//! API moved behind `std.Io` in 0.16 and would mean carrying an `Io` around +//! for two calls. + +const std = @import("std"); +const glib = @import("glib"); + +const Layouts = @This(); + +/// Bumped only if the on-disk shape changes incompatibly. Read but not yet +/// acted on: there is nothing older to migrate from. +pub const format_version = 1; + +/// Enough for any path this builds, without pulling in a std constant that +/// has been moving between namespaces. +const max_path = 4096; + +pub const Orientation = enum { horizontal, vertical }; + +/// What a pane can hold, mirroring `Pane.Kind`. Duplicated rather than +/// imported so this module stays free of GTK and of the widget tree. +pub const Kind = enum { terminal, web }; + +/// A value the user supplies when opening a layout. +pub const Parameter = struct { + name: []const u8, + description: []const u8 = "", + default: []const u8 = "", +}; + +/// One pane's starting state. Which fields matter depends on `kind`; the +/// others are kept as empty strings so a round-trip through the editor never +/// silently drops something the user typed. +pub const Pane = struct { + kind: Kind = .terminal, + + /// Directory to start in. A leading `~` is expanded at open time. + cwd: []const u8 = "", + + /// Script to run once the shell is up. Empty means "just a shell". + command: []const u8 = "", + + /// Page to load, for web panes. + url: []const u8 = "", +}; + +pub const Node = union(enum) { + pane: Pane, + split: Split, +}; + +pub const Split = struct { + orientation: Orientation, + ratio: f64 = 0.5, + first: *Node, + second: *Node, +}; + +pub const Layout = struct { + name: []const u8, + parameters: []const Parameter = &.{}, + root: *Node, +}; + +arena: std.heap.ArenaAllocator, +items: std.ArrayListUnmanaged(*Layout) = .empty, + +/// Set when the config file existed but could not be understood, so the UI can +/// say so instead of silently presenting an empty list and then overwriting +/// the file on the next save. +load_error: ?[]const u8 = null, + +pub fn init(alloc: std.mem.Allocator) Layouts { + return .{ .arena = .init(alloc) }; +} + +pub fn deinit(self: *Layouts) void { + const child = self.arena.child_allocator; + self.items.deinit(child); + self.arena.deinit(); +} + +fn allocator(self: *Layouts) std.mem.Allocator { + return self.arena.allocator(); +} + +pub fn find(self: *Layouts, name: []const u8) ?*Layout { + for (self.items.items) |layout| { + if (std.mem.eql(u8, layout.name, name)) return layout; + } + return null; +} + +// ------------------------------------------------------------------------- +// Paths + +/// `$XDG_CONFIG_HOME/vtabs/layouts.json`, or `~/.config/vtabs/layouts.json` +/// when that isn't set — whichever GLib reports as the user's config dir. +pub fn configPath(buf: []u8) ?[:0]const u8 { + const dir = std.mem.span(glib.getUserConfigDir()); + return std.fmt.bufPrintZ(buf, "{s}/vtabs/layouts.json", .{dir}) catch null; +} + +/// The directory `configPath` lives in. +fn configDir(buf: []u8) ?[:0]const u8 { + const dir = std.mem.span(glib.getUserConfigDir()); + return std.fmt.bufPrintZ(buf, "{s}/vtabs", .{dir}) catch null; +} + +// ------------------------------------------------------------------------- +// Loading + +/// Replace the in-memory set with what is on disk. A missing file is not an +/// error — it just means no layouts have been saved yet. +pub fn load(self: *Layouts) void { + self.items.clearRetainingCapacity(); + self.load_error = null; + _ = self.arena.reset(.retain_capacity); + + var path_buf: [max_path]u8 = undefined; + const path = configPath(&path_buf) orelse return; + + var contents: [*]u8 = undefined; + var length: usize = 0; + var err: ?*glib.Error = null; + + if (glib.fileGetContents(path.ptr, &contents, &length, &err) == 0) { + defer if (err) |e| e.free(); + // No file yet is the normal state before the first layout is saved, + // not something to complain about. + if (err) |e| { + if (e.f_domain == glib.fileErrorQuark() and e.f_code == @intFromEnum(glib.FileError.noent)) return; + self.setLoadError("{s}", .{e.f_message orelse "could not read layouts"}); + } + return; + } + defer glib.free(contents); + + self.parse(contents[0..length]) catch |parse_err| { + self.setLoadError("could not parse {s}: {s}", .{ path, @errorName(parse_err) }); + self.items.clearRetainingCapacity(); + }; +} + +fn setLoadError(self: *Layouts, comptime fmt: []const u8, args: anytype) void { + self.load_error = std.fmt.allocPrint(self.allocator(), fmt, args) catch "layouts failed to load"; +} + +const ParseError = error{ OutOfMemory, Malformed }; + +fn parse(self: *Layouts, text: []const u8) ParseError!void { + // Parsed into the arena and left there: every string we keep points into + // this tree, so it has to outlive the call. + const parsed = std.json.parseFromSliceLeaky( + std.json.Value, + self.allocator(), + text, + .{}, + ) catch return error.Malformed; + + const root = switch (parsed) { + .object => |o| o, + else => return error.Malformed, + }; + + const list = switch (root.get("layouts") orelse return) { + .array => |a| a, + else => return error.Malformed, + }; + + for (list.items) |entry| { + const obj = switch (entry) { + .object => |o| o, + else => return error.Malformed, + }; + try self.parseLayout(obj); + } +} + +fn parseLayout(self: *Layouts, obj: std.json.ObjectMap) ParseError!void { + const alloc = self.allocator(); + + const layout = try alloc.create(Layout); + layout.* = .{ + .name = try self.dupeString(obj.get("name") orelse return error.Malformed), + .root = try self.parseNode(obj.get("root") orelse return error.Malformed), + }; + + if (obj.get("parameters")) |raw| { + const array = switch (raw) { + .array => |a| a, + else => return error.Malformed, + }; + const params = try alloc.alloc(Parameter, array.items.len); + for (array.items, 0..) |item, i| { + const p = switch (item) { + .object => |o| o, + else => return error.Malformed, + }; + params[i] = .{ + .name = try self.dupeString(p.get("name") orelse return error.Malformed), + .description = try self.optionalString(p.get("description")), + .default = try self.optionalString(p.get("default")), + }; + } + layout.parameters = params; + } + + try self.items.append(self.arena.child_allocator, layout); +} + +fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node { + const obj = switch (raw) { + .object => |o| o, + else => return error.Malformed, + }; + + const node = try self.allocator().create(Node); + + // A node is a split if it says which way it splits; otherwise it is a leaf. + if (obj.get("split")) |split_raw| { + const orientation = std.meta.stringToEnum( + Orientation, + switch (split_raw) { + .string => |s| s, + else => return error.Malformed, + }, + ) orelse return error.Malformed; + + node.* = .{ .split = .{ + .orientation = orientation, + .ratio = clampRatio(numberOr(obj.get("ratio"), 0.5)), + .first = try self.parseNode(obj.get("first") orelse return error.Malformed), + .second = try self.parseNode(obj.get("second") orelse return error.Malformed), + } }; + return node; + } + + node.* = .{ .pane = .{ + .kind = if (obj.get("kind")) |k| switch (k) { + .string => |s| std.meta.stringToEnum(Kind, s) orelse return error.Malformed, + else => return error.Malformed, + } else .terminal, + .cwd = try self.optionalString(obj.get("cwd")), + .command = try self.optionalString(obj.get("command")), + .url = try self.optionalString(obj.get("url")), + } }; + return node; +} + +/// Keep dividers away from the extremes, where a pane would open with no +/// usable area at all. +fn clampRatio(value: f64) f64 { + if (!std.math.isFinite(value)) return 0.5; + return @min(@max(value, 0.05), 0.95); +} + +fn numberOr(raw: ?std.json.Value, fallback: f64) f64 { + return switch (raw orelse return fallback) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + else => fallback, + }; +} + +fn dupeString(self: *Layouts, raw: std.json.Value) ParseError![]const u8 { + return switch (raw) { + .string => |s| try self.allocator().dupe(u8, s), + else => error.Malformed, + }; +} + +fn optionalString(self: *Layouts, raw: ?std.json.Value) ParseError![]const u8 { + return switch (raw orelse return "") { + .string => |s| try self.allocator().dupe(u8, s), + .null => "", + else => error.Malformed, + }; +} + +// ------------------------------------------------------------------------- +// Saving + +pub const SaveError = error{ OutOfMemory, WriteFailed }; + +/// Write the whole set back out, creating the config directory if needed. +pub fn save(self: *Layouts) 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; + + var path_buf: [max_path]u8 = undefined; + const path = configPath(&path_buf) orelse 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) { + if (err) |e| e.free(); + return error.WriteFailed; + } +} + +fn serialize(self: *Layouts) 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("layouts"); + try json.beginArray(); + for (self.items.items) |layout| try writeLayout(&json, layout); + try json.endArray(); + try json.endObject(); + + // A trailing newline, so the file behaves in an editor. + try out.writer.writeByte('\n'); + return out.toOwnedSlice(); +} + +fn writeLayout(json: *std.json.Stringify, layout: *const Layout) SaveError!void { + try json.beginObject(); + try json.objectField("name"); + try json.write(layout.name); + + if (layout.parameters.len > 0) { + try json.objectField("parameters"); + try json.beginArray(); + for (layout.parameters) |p| { + try json.beginObject(); + try json.objectField("name"); + try json.write(p.name); + if (p.description.len > 0) { + try json.objectField("description"); + try json.write(p.description); + } + if (p.default.len > 0) { + try json.objectField("default"); + try json.write(p.default); + } + try json.endObject(); + } + try json.endArray(); + } + + try json.objectField("root"); + try writeNode(json, layout.root); + try json.endObject(); +} + +fn writeNode(json: *std.json.Stringify, node: *const Node) SaveError!void { + try json.beginObject(); + switch (node.*) { + .split => |s| { + try json.objectField("split"); + try json.write(@tagName(s.orientation)); + try json.objectField("ratio"); + try json.write(s.ratio); + try json.objectField("first"); + try writeNode(json, s.first); + try json.objectField("second"); + try writeNode(json, s.second); + }, + .pane => |p| { + try json.objectField("kind"); + try json.write(@tagName(p.kind)); + // Only what is set, so a hand-edited file stays readable. + if (p.cwd.len > 0) { + try json.objectField("cwd"); + try json.write(p.cwd); + } + if (p.command.len > 0) { + try json.objectField("command"); + try json.write(p.command); + } + if (p.url.len > 0) { + try json.objectField("url"); + try json.write(p.url); + } + }, + } + try json.endObject(); +} + +// ------------------------------------------------------------------------- +// Editing +// +// Layouts are built in the app, so these take plain slices and copy everything +// into the arena. Callers hand over borrowed strings and forget about them. + +pub const Builder = struct { + layouts: *Layouts, + + pub fn node(self: Builder, value: Node) !*Node { + const n = try self.layouts.allocator().create(Node); + n.* = value; + return n; + } + + pub fn pane(self: Builder, spec: Pane) !*Node { + return self.node(.{ .pane = .{ + .kind = spec.kind, + .cwd = try self.dupe(spec.cwd), + .command = try self.dupe(spec.command), + .url = try self.dupe(spec.url), + } }); + } + + pub fn split( + self: Builder, + orientation: Orientation, + ratio: f64, + first: *Node, + second: *Node, + ) !*Node { + return self.node(.{ .split = .{ + .orientation = orientation, + .ratio = clampRatio(ratio), + .first = first, + .second = second, + } }); + } + + pub fn dupe(self: Builder, text: []const u8) ![]const u8 { + return self.layouts.allocator().dupe(u8, text); + } +}; + +pub fn builder(self: *Layouts) Builder { + return .{ .layouts = self }; +} + +/// Add a layout, replacing any existing one with the same name. +/// +/// Replacing in place keeps the menu order stable when you re-save a layout +/// you have just tweaked, which is the common case. +pub fn put( + self: *Layouts, + name: []const u8, + parameters: []const Parameter, + root: *Node, +) !void { + const alloc = self.allocator(); + + const params = try alloc.alloc(Parameter, parameters.len); + for (parameters, 0..) |p, i| { + params[i] = .{ + .name = try alloc.dupe(u8, p.name), + .description = try alloc.dupe(u8, p.description), + .default = try alloc.dupe(u8, p.default), + }; + } + + if (self.find(name)) |existing| { + existing.parameters = params; + existing.root = root; + return; + } + + const layout = try alloc.create(Layout); + layout.* = .{ + .name = try alloc.dupe(u8, name), + .parameters = params, + .root = root, + }; + try self.items.append(self.arena.child_allocator, layout); +} + +pub fn remove(self: *Layouts, name: []const u8) void { + for (self.items.items, 0..) |layout, i| { + if (std.mem.eql(u8, layout.name, name)) { + _ = self.items.orderedRemove(i); + return; + } + } +} + +// ------------------------------------------------------------------------- +// Parameter substitution + +/// One parameter's value for a single opening of a layout. +pub const Binding = struct { + name: []const u8, + value: []const u8, +}; + +/// Replace every `{{name}}` in `template` with its bound value. +/// +/// An unknown name is left exactly as written rather than blanked out: a +/// literal `{{` in a script is far more likely to be a typo than a deliberate +/// erasure, and leaving it visible makes that obvious in the pane. +pub fn expand( + alloc: std.mem.Allocator, + template: []const u8, + bindings: []const Binding, +) ![]u8 { + var out: std.ArrayListUnmanaged(u8) = .empty; + errdefer out.deinit(alloc); + + var rest = template; + while (std.mem.indexOf(u8, rest, "{{")) |open| { + const after = rest[open + 2 ..]; + const close = std.mem.indexOf(u8, after, "}}") orelse break; + + const name = std.mem.trim(u8, after[0..close], &std.ascii.whitespace); + try out.appendSlice(alloc, rest[0..open]); + + if (lookup(bindings, name)) |value| { + try out.appendSlice(alloc, value); + } else { + try out.appendSlice(alloc, rest[open .. open + 2 + close + 2]); + } + + rest = after[close + 2 ..]; + } + try out.appendSlice(alloc, rest); + + return out.toOwnedSlice(alloc); +} + +fn lookup(bindings: []const Binding, name: []const u8) ?[]const u8 { + for (bindings) |b| { + if (std.mem.eql(u8, b.name, name)) return b.value; + } + return null; +} + +/// Expand a path the way a shell would treat it: `{{...}}` first, then a +/// leading `~`. Returns an empty slice for an empty template, meaning +/// "wherever the app was started". +pub fn expandPath( + alloc: std.mem.Allocator, + template: []const u8, + bindings: []const Binding, +) ![]u8 { + const expanded = try expand(alloc, template, bindings); + if (!std.mem.startsWith(u8, expanded, "~")) return expanded; + if (expanded.len > 1 and expanded[1] != '/') return expanded; + + const home = std.mem.span(glib.getHomeDir()); + if (home.len == 0) return expanded; + defer alloc.free(expanded); + + return std.fmt.allocPrint(alloc, "{s}{s}", .{ home, expanded[1..] }); +} diff --git a/src/OpenLayoutDialog.zig b/src/OpenLayoutDialog.zig new file mode 100644 index 0000000..7a56306 --- /dev/null +++ b/src/OpenLayoutDialog.zig @@ -0,0 +1,170 @@ +//! "Open layout": one field per parameter, prefilled with its default. +//! +//! A layout with no parameters never gets here — the caller opens it straight +//! away rather than showing an empty dialog with nothing to fill in. +//! +//! The values are handed back as borrowed slices pointing into the entries, +//! which is safe because the callback runs while the dialog is still up and +//! the view copies everything during substitution. + +const std = @import("std"); +const gtk = @import("gtk"); + +const Layouts = @import("Layouts.zig"); + +const OpenLayoutDialog = @This(); + +pub const Callback = *const fn ( + ctx: ?*anyopaque, + layout: *Layouts.Layout, + bindings: []const Layouts.Binding, +) void; + +alloc: std.mem.Allocator, +window: *gtk.Window, +layout: *Layouts.Layout, + +/// One entry per parameter, in the layout's own order. +entries: []*gtk.Entry, + +on_open: Callback, +ctx: ?*anyopaque, + +pub fn present( + alloc: std.mem.Allocator, + parent: *gtk.Window, + layout: *Layouts.Layout, + on_open: Callback, + ctx: ?*anyopaque, +) !void { + const self = try alloc.create(OpenLayoutDialog); + errdefer alloc.destroy(self); + + const entries = try alloc.alloc(*gtk.Entry, layout.parameters.len); + errdefer alloc.free(entries); + + self.* = .{ + .alloc = alloc, + .window = gtk.Window.new(), + .layout = layout, + .entries = entries, + .on_open = on_open, + .ctx = ctx, + }; + + var title_buf: [128]u8 = undefined; + const title = std.fmt.bufPrintZ(&title_buf, "Open “{s}”", .{layout.name}) catch "Open layout"; + self.window.setTitle(title); + self.window.setTransientFor(parent); + self.window.setModal(1); + self.window.setDefaultSize(480, -1); + self.window.as(gtk.Widget).addCssClass("vtabs-dialog"); + + const content = gtk.Box.new(.vertical, 12); + content.as(gtk.Widget).addCssClass("vtabs-dialog-content"); + + const fields = gtk.Grid.new(); + fields.setRowSpacing(8); + fields.setColumnSpacing(12); + + for (layout.parameters, 0..) |param, i| { + const label = gtk.Label.new(null); + // The description is what the author wrote for a human; the bare name + // is the fallback so a parameter is never unlabelled. + var label_buf: [128]u8 = undefined; + const text = if (param.description.len > 0) param.description else param.name; + label.setText(std.fmt.bufPrintZ(&label_buf, "{s}", .{text}) catch "parameter"); + label.setXalign(0); + label.as(gtk.Widget).addCssClass("vtabs-dialog-label"); + fields.attach(label.as(gtk.Widget), 0, @intCast(i), 1, 1); + + const entry = gtk.Entry.new(); + entry.as(gtk.Widget).setHexpand(1); + setEntryText(entry, param.default); + + var hint_buf: [128]u8 = undefined; + entry.setPlaceholderText( + std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null, + ); + + // Enter anywhere in the form opens the layout, so a one-parameter + // layout is type-and-go. + _ = gtk.Entry.signals.activate.connect( + entry, + *OpenLayoutDialog, + &onEntryActivate, + self, + .{}, + ); + + fields.attach(entry.as(gtk.Widget), 1, @intCast(i), 1, 1); + entries[i] = entry; + } + content.append(fields.as(gtk.Widget)); + + const buttons = gtk.Box.new(.horizontal, 8); + buttons.as(gtk.Widget).setHalign(.end); + + const cancel = gtk.Button.newWithLabel("Cancel"); + _ = gtk.Button.signals.clicked.connect(cancel, *OpenLayoutDialog, &onCancel, self, .{}); + buttons.append(cancel.as(gtk.Widget)); + + const open = gtk.Button.newWithLabel("Open"); + open.as(gtk.Widget).addCssClass("suggested-action"); + _ = gtk.Button.signals.clicked.connect(open, *OpenLayoutDialog, &onOpen, self, .{}); + buttons.append(open.as(gtk.Widget)); + + content.append(buttons.as(gtk.Widget)); + self.window.setChild(content.as(gtk.Widget)); + + _ = gtk.Widget.signals.destroy.connect( + self.window, + *OpenLayoutDialog, + &onDestroy, + self, + .{}, + ); + + self.window.present(); + if (entries.len > 0) _ = entries[0].as(gtk.Widget).grabFocus(); +} + +fn setEntryText(entry: *gtk.Entry, text: []const u8) void { + var buf: [1024]u8 = undefined; + const z = std.fmt.bufPrintZ(&buf, "{s}", .{text}) catch return; + entry.as(gtk.Editable).setText(z); +} + +fn submit(self: *OpenLayoutDialog) void { + var storage: [16]Layouts.Binding = undefined; + const count = @min(self.entries.len, storage.len); + + for (self.entries[0..count], 0..) |entry, i| { + storage[i] = .{ + .name = self.layout.parameters[i].name, + .value = std.mem.span(entry.as(gtk.Editable).getText()), + }; + } + + // The callback builds the tab while this window is still alive, so the + // borrowed entry text stays valid for the whole substitution pass. + self.on_open(self.ctx, self.layout, storage[0..count]); + self.window.destroy(); +} + +fn onOpen(_: *gtk.Button, self: *OpenLayoutDialog) callconv(.c) void { + self.submit(); +} + +fn onEntryActivate(_: *gtk.Entry, self: *OpenLayoutDialog) callconv(.c) void { + self.submit(); +} + +fn onCancel(_: *gtk.Button, self: *OpenLayoutDialog) callconv(.c) void { + self.window.destroy(); +} + +fn onDestroy(_: *gtk.Window, self: *OpenLayoutDialog) callconv(.c) void { + self.alloc.free(self.entries); + self.alloc.destroy(self); +} diff --git a/src/Pane.zig b/src/Pane.zig index 92fa61e..acdfe01 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -49,6 +49,26 @@ pub const Kind = enum { } }; +/// Everything needed to open a pane: which kind, and what that kind needs to +/// start with. A pane opened the ordinary way uses the defaults; one opened +/// from a layout carries that layout's directory, script or URL. +pub const Spec = union(Kind) { + terminal: Terminal.Options, + web: Browser.Options, + + /// A plain pane of the given kind, with nothing preloaded. + pub fn plain(of: Kind) Spec { + return switch (of) { + .terminal => .{ .terminal = .{} }, + .web => .{ .web = .{} }, + }; + } + + pub fn kind(self: Spec) Kind { + return self; + } +}; + /// What a content kind reports back to its pane. Shared by both kinds so the /// pane can wire either one up with the same handlers. pub const Callbacks = struct { @@ -108,10 +128,12 @@ label: *gtk.Label, /// Latest title reported by the content, kept NUL-terminated for GTK. title: [128:0]u8 = @splat(0), -pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane { +pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane { const self = try alloc.create(Pane); errdefer alloc.destroy(self); + const kind = spec.kind(); + self.* = .{ .alloc = alloc, .view = view, @@ -124,16 +146,16 @@ pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane { setTitle(self, kind.initialTitle()); // Both constructors take the same callbacks, so the only thing that - // varies between kinds is which one is called. + // varies between kinds is which one is called and what it is given. const callbacks: Callbacks = .{ .on_title = &onContentTitle, .on_exit = &onContentExit, .on_focus = &onContentFocus, .ctx = self, }; - self.content = switch (kind) { - .terminal => .{ .terminal = try .create(alloc, callbacks) }, - .web => .{ .web = try .create(alloc, callbacks) }, + self.content = switch (spec) { + .terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) }, + .web => |opts| .{ .web = try .create(alloc, opts, callbacks) }, }; errdefer self.content.destroy(); @@ -396,13 +418,13 @@ fn onContentFocus(ctx: ?*anyopaque) void { } fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void { - self.view.addPane(.terminal) catch |err| { + self.view.addPane(.plain(.terminal)) catch |err| { std.log.err("failed to open terminal: {s}", .{@errorName(err)}); }; } fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void { - self.view.addPane(.web) catch |err| { + self.view.addPane(.plain(.web)) catch |err| { std.log.err("failed to open web view: {s}", .{@errorName(err)}); }; } diff --git a/src/Pty.zig b/src/Pty.zig index b4de210..421eaf2 100644 --- a/src/Pty.zig +++ b/src/Pty.zig @@ -27,6 +27,8 @@ const c = struct { extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int; extern "c" fn setsid() pid_t; extern "c" fn fork() pid_t; + extern "c" fn chdir(path: [*:0]const u8) c_int; + extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsiz: usize) isize; extern "c" fn close(fd: fd_t) c_int; extern "c" fn dup2(old: fd_t, new: fd_t) c_int; extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int; @@ -86,10 +88,15 @@ pub const Error = error{ /// started by running e.g. /usr/bin/zsh with an argv[0] of "-zsh"; folding /// them together would pass "-zsh" as an ordinary argument instead, which /// shells either misparse or reject outright. +/// +/// `dir` is the directory the child starts in. A null, or a directory that +/// cannot be entered, leaves it wherever the app was started — a layout +/// naming a path that no longer exists should still give you a usable shell. pub fn create( alloc: std.mem.Allocator, path: [:0]const u8, argv: []const [:0]const u8, + dir: ?[:0]const u8, size: Winsize, ) !Pty { const master = c.posix_openpt(O_RDWR | O_NOCTTY); @@ -120,7 +127,7 @@ pub fn create( const pid = c.fork(); if (pid < 0) return Error.ForkFailed; if (pid == 0) { - childExec(master, slave_path_z, path, argv_z, envp_z); + childExec(master, slave_path_z, path, argv_z, envp_z, dir); // childExec only returns on failure, and a forked child has no // sensible way to report that back to us. c._exit(127); @@ -136,9 +143,17 @@ fn childExec( path: [:0]const u8, argv: [:null]const ?[*:0]const u8, envp: [:null]const ?[*:0]const u8, + dir: ?[:0]const u8, ) void { _ = c.close(master); + // Deliberately unchecked: a layout pointing at a directory that has since + // been moved or deleted should still open a shell, in the directory the + // app was started from, rather than a pane that dies on arrival. + if (dir) |d| if (d.len > 0) { + _ = c.chdir(d.ptr); + }; + // A new session detaches us from the parent's controlling terminal so // that we can claim the slave as our own below. if (c.setsid() < 0) return; @@ -211,6 +226,28 @@ fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void { alloc.free(envp); } +/// The child's current working directory, copied into `buf`, or null if it +/// can't be read. +/// +/// This is the shell's directory as of right now, not the one it started in, +/// which is what makes "save this tab as a layout" record where you actually +/// are rather than where you began. Read straight out of procfs: there is no +/// portable API for it, and this app is Linux-only regardless. +pub fn cwd(self: Pty, buf: []u8) ?[]const u8 { + var link_buf: [64]u8 = undefined; + const link = std.fmt.bufPrintZ(&link_buf, "/proc/{d}/cwd", .{self.pid}) catch return null; + + const n = c.readlink(link.ptr, buf.ptr, buf.len); + if (n <= 0) return null; + + const len: usize = @intCast(n); + // readlink truncates rather than failing, and a truncated path is worse + // than none: it would silently point somewhere else. + if (len >= buf.len) return null; + + return buf[0..len]; +} + /// Tell the child its window changed size. This both updates the kernel's /// idea of the terminal size and delivers SIGWINCH to the foreground group. pub fn setSize(self: Pty, size: Winsize) void { diff --git a/src/SaveLayoutDialog.zig b/src/SaveLayoutDialog.zig new file mode 100644 index 0000000..cf53359 --- /dev/null +++ b/src/SaveLayoutDialog.zig @@ -0,0 +1,481 @@ +//! The layout editor, used both for saving the current tab as a layout and for +//! editing one that is already saved. Either way the job is the same: name the +//! arrangement, declare its parameters, and say what each pane starts with. +//! +//! The shape is not edited here. When saving, it was captured from the live tab +//! before this opened, splits and ratios and all; when editing, it is whatever +//! the layout already had. That is why there is no layout builder in this app — +//! you build a layout by arranging a tab the way you already do, and reshape an +//! existing one by opening it, rearranging, and saving over it. What is left is +//! the part the app cannot infer: the scripts. +//! +//! The tree is allocated in the layout store's arena in both cases, so saving +//! writes the edited strings straight back into its leaves. Nothing is written +//! until the confirm button, so cancelling an edit leaves the layout alone. + +const std = @import("std"); +const gtk = @import("gtk"); + +const Layouts = @import("Layouts.zig"); + +const SaveLayoutDialog = @This(); + +pub const Callback = *const fn (ctx: ?*anyopaque) void; + +/// A parameter being edited. Rows can be added and removed, so each one owns +/// its widgets and is tracked by pointer rather than by index. +const ParamRow = struct { + dialog: *SaveLayoutDialog, + box: *gtk.Box, + name: *gtk.Entry, + description: *gtk.Entry, + default: *gtk.Entry, +}; + +/// One captured pane and the fields that fill it in. +const PaneRow = struct { + node: *Layouts.Node, + + /// Directory and script, for a terminal pane. + cwd: ?*gtk.Entry = null, + command: ?*gtk.Entry = null, + + /// Page to open, for a web pane. + url: ?*gtk.Entry = null, +}; + +/// How the editor was opened. The two differ only in wording and in whether +/// there is an existing entry to replace. +pub const Options = struct { + /// Window title. + title: [:0]const u8, + + /// Verb on the confirm button. + confirm: [:0]const u8, + + /// What the name field starts as. + name: []const u8, + + /// Parameter rows to start with, for editing a layout that has some. + parameters: []const Layouts.Parameter = &.{}, + + /// The name this layout is currently filed under, when editing. Renaming + /// then has to drop the old entry, or the edit would leave the original + /// sitting in the menu alongside its replacement. + original_name: ?[]const u8 = null, +}; + +alloc: std.mem.Allocator, +layouts: *Layouts, +window: *gtk.Window, + +root: *Layouts.Node, +original_name: ?[]const u8, + +name_entry: *gtk.Entry, + +/// Container the parameter rows live in, so rows can be appended after the +/// dialog is already on screen. +params_box: *gtk.Box, +params: std.ArrayListUnmanaged(*ParamRow) = .empty, + +panes: []PaneRow, + +/// Shown instead of silently doing nothing when the name is empty or the +/// write fails. +error_label: *gtk.Label, + +on_saved: Callback, +ctx: ?*anyopaque, + +pub fn present( + alloc: std.mem.Allocator, + parent: *gtk.Window, + layouts: *Layouts, + root: *Layouts.Node, + opts: Options, + on_saved: Callback, + ctx: ?*anyopaque, +) !void { + const self = try alloc.create(SaveLayoutDialog); + errdefer alloc.destroy(self); + + self.* = .{ + .alloc = alloc, + .layouts = layouts, + .window = gtk.Window.new(), + .root = root, + .original_name = opts.original_name, + .name_entry = gtk.Entry.new(), + .params_box = gtk.Box.new(.vertical, 6), + .panes = undefined, + .error_label = gtk.Label.new(null), + .on_saved = on_saved, + .ctx = ctx, + }; + + self.window.setTitle(opts.title); + self.window.setTransientFor(parent); + self.window.setModal(1); + self.window.setDefaultSize(560, 620); + self.window.as(gtk.Widget).addCssClass("vtabs-dialog"); + + const content = gtk.Box.new(.vertical, 12); + content.as(gtk.Widget).addCssClass("vtabs-dialog-content"); + + // ---- name ---------------------------------------------------------- + content.append(heading("Name")); + setEntryText(self.name_entry, opts.name); + self.name_entry.setPlaceholderText("Layout name"); + _ = gtk.Entry.signals.activate.connect( + self.name_entry, + *SaveLayoutDialog, + &onNameActivate, + self, + .{}, + ); + content.append(self.name_entry.as(gtk.Widget)); + + // ---- parameters ---------------------------------------------------- + content.append(heading("Parameters")); + content.append(hint( + "Referred to as {{name}} in any directory, script or address below.", + )); + content.append(self.params_box.as(gtk.Widget)); + + for (opts.parameters) |param| try self.addParamRow(param); + + const add = gtk.Button.newWithLabel("Add parameter"); + add.as(gtk.Widget).setHalign(.start); + add.as(gtk.Widget).addCssClass("flat"); + _ = gtk.Button.signals.clicked.connect(add, *SaveLayoutDialog, &onAddParam, self, .{}); + content.append(add.as(gtk.Widget)); + + // ---- panes ----------------------------------------------------------- + content.append(heading("Panes")); + + // Leaves in tree order, which reads top-left to bottom-right on screen. + var leaves: std.ArrayListUnmanaged(*Layouts.Node) = .empty; + defer leaves.deinit(alloc); + try collectLeaves(alloc, root, &leaves); + + self.panes = try alloc.alloc(PaneRow, leaves.items.len); + errdefer alloc.free(self.panes); + + for (leaves.items, 0..) |leaf, i| { + self.panes[i] = .{ .node = leaf }; + content.append(self.buildPaneSection(i, leaf)); + } + + // ---- footer ---------------------------------------------------------- + self.error_label.setXalign(0); + self.error_label.as(gtk.Widget).addCssClass("vtabs-dialog-error"); + self.error_label.as(gtk.Widget).setVisible(0); + content.append(self.error_label.as(gtk.Widget)); + + 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("vtabs-dialog-actions"); + + const cancel = gtk.Button.newWithLabel("Cancel"); + _ = gtk.Button.signals.clicked.connect(cancel, *SaveLayoutDialog, &onCancel, self, .{}); + buttons.append(cancel.as(gtk.Widget)); + + const save = gtk.Button.newWithLabel(opts.confirm); + save.as(gtk.Widget).addCssClass("suggested-action"); + _ = gtk.Button.signals.clicked.connect(save, *SaveLayoutDialog, &onSave, self, .{}); + buttons.append(save.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, + *SaveLayoutDialog, + &onDestroy, + self, + .{}, + ); + + self.window.present(); + _ = self.name_entry.as(gtk.Widget).grabFocus(); +} + +/// The fields for one captured pane, prefilled with whatever could be read off +/// the live pane: a terminal's current directory, a web pane's current page. +fn buildPaneSection(self: *SaveLayoutDialog, index: usize, leaf: *Layouts.Node) *gtk.Widget { + const spec = leaf.pane; + + const box = gtk.Box.new(.vertical, 4); + box.as(gtk.Widget).addCssClass("vtabs-dialog-pane"); + + var title_buf: [64]u8 = undefined; + const title = std.fmt.bufPrintZ(&title_buf, "Pane {d} — {s}", .{ + index + 1, + @tagName(spec.kind), + }) catch "Pane"; + const label = gtk.Label.new(title); + label.setXalign(0); + label.as(gtk.Widget).addCssClass("vtabs-dialog-label"); + box.append(label.as(gtk.Widget)); + + const grid = gtk.Grid.new(); + grid.setRowSpacing(4); + grid.setColumnSpacing(8); + + switch (spec.kind) { + .terminal => { + const cwd = field(grid, 0, "Directory", spec.cwd, "~/projects/{{path}}"); + const command = field(grid, 1, "Script", spec.command, "npm run dev"); + self.panes[index].cwd = cwd; + self.panes[index].command = command; + }, + .web => { + const url = field(grid, 0, "Address", spec.url, "https://example.com"); + self.panes[index].url = url; + }, + } + + box.append(grid.as(gtk.Widget)); + return box.as(gtk.Widget); +} + +fn field( + grid: *gtk.Grid, + row: c_int, + label_text: [:0]const u8, + value: []const u8, + placeholder: [:0]const u8, +) *gtk.Entry { + const label = gtk.Label.new(label_text); + label.setXalign(0); + label.as(gtk.Widget).addCssClass("vtabs-dialog-sublabel"); + grid.attach(label.as(gtk.Widget), 0, row, 1, 1); + + const entry = gtk.Entry.new(); + entry.as(gtk.Widget).setHexpand(1); + entry.setPlaceholderText(placeholder); + setEntryText(entry, value); + grid.attach(entry.as(gtk.Widget), 1, row, 1, 1); + + return entry; +} + +fn collectLeaves( + alloc: std.mem.Allocator, + node: *Layouts.Node, + out: *std.ArrayListUnmanaged(*Layouts.Node), +) !void { + switch (node.*) { + .pane => try out.append(alloc, node), + .split => |s| { + try collectLeaves(alloc, s.first, out); + try collectLeaves(alloc, s.second, out); + }, + } +} + +// ------------------------------------------------------------------------- +// Parameter rows + +fn addParamRow(self: *SaveLayoutDialog, param: Layouts.Parameter) !void { + const row = try self.alloc.create(ParamRow); + errdefer self.alloc.destroy(row); + + row.* = .{ + .dialog = self, + .box = gtk.Box.new(.horizontal, 6), + .name = gtk.Entry.new(), + .description = gtk.Entry.new(), + .default = gtk.Entry.new(), + }; + + row.name.setPlaceholderText("name"); + setEntryText(row.name, param.name); + row.box.append(row.name.as(gtk.Widget)); + + row.description.setPlaceholderText("what it means"); + row.description.as(gtk.Widget).setHexpand(1); + setEntryText(row.description, param.description); + row.box.append(row.description.as(gtk.Widget)); + + row.default.setPlaceholderText("default"); + setEntryText(row.default, param.default); + row.box.append(row.default.as(gtk.Widget)); + + const remove = gtk.Button.newFromIconName("list-remove-symbolic"); + remove.as(gtk.Widget).addCssClass("flat"); + _ = gtk.Button.signals.clicked.connect(remove, *ParamRow, &onRemoveParam, row, .{}); + row.box.append(remove.as(gtk.Widget)); + + try self.params.append(self.alloc, row); + self.params_box.append(row.box.as(gtk.Widget)); +} + +fn onAddParam(_: *gtk.Button, self: *SaveLayoutDialog) callconv(.c) void { + self.addParamRow(.{ .name = "" }) catch |err| { + std.log.err("failed to add parameter row: {s}", .{@errorName(err)}); + }; +} + +fn onRemoveParam(_: *gtk.Button, row: *ParamRow) callconv(.c) void { + const self = row.dialog; + for (self.params.items, 0..) |candidate, i| { + if (candidate == row) { + _ = self.params.orderedRemove(i); + break; + } + } + self.params_box.remove(row.box.as(gtk.Widget)); + self.alloc.destroy(row); +} + +// ------------------------------------------------------------------------- +// Saving + +fn onSave(_: *gtk.Button, self: *SaveLayoutDialog) callconv(.c) void { + self.commit(); +} + +fn onNameActivate(_: *gtk.Entry, self: *SaveLayoutDialog) callconv(.c) void { + self.commit(); +} + +fn commit(self: *SaveLayoutDialog) void { + const name = std.mem.trim( + u8, + std.mem.span(self.name_entry.as(gtk.Editable).getText()), + &std.ascii.whitespace, + ); + if (name.len == 0) { + self.showError("Give the layout a name."); + return; + } + + // Everything that can refuse the save happens before anything is written. + // Editing writes straight into the live layout's own nodes, so bailing out + // half way would leave it altered on screen but unaltered on disk. + // + // Renaming *onto* another layout is refused rather than silently replacing + // it. Saving over a name is a deliberate act when you type it into the save + // dialog; reaching it by renaming something else is a mistake, and the + // layout it would destroy isn't the one on screen. + const renaming = if (self.original_name) |original| + !std.mem.eql(u8, original, name) + else + false; + + if (renaming and self.layouts.find(name) != null) { + self.showError("Another layout already has that name."); + return; + } + + const builder = self.layouts.builder(); + + // Write the edited fields back into the captured tree. Everything is + // copied into the store's arena, so it outlives these widgets. + for (self.panes) |row| { + const spec = &row.node.pane; + if (row.cwd) |e| spec.cwd = builder.dupe(entryText(e)) catch { + self.showError("Out of memory."); + return; + }; + if (row.command) |e| spec.command = builder.dupe(entryText(e)) catch { + self.showError("Out of memory."); + return; + }; + if (row.url) |e| spec.url = builder.dupe(entryText(e)) catch { + self.showError("Out of memory."); + return; + }; + } + + var params: std.ArrayListUnmanaged(Layouts.Parameter) = .empty; + defer params.deinit(self.alloc); + + for (self.params.items) |row| { + const param_name = std.mem.trim(u8, entryText(row.name), &std.ascii.whitespace); + // A row left blank is someone who clicked Add and changed their mind, + // not a parameter called "". + if (param_name.len == 0) continue; + params.append(self.alloc, .{ + .name = param_name, + .description = entryText(row.description), + .default = entryText(row.default), + }) catch { + self.showError("Out of memory."); + return; + }; + } + + // A rename moves the layout rather than cloning it. Dropping the old entry + // first also means `put` appends under the new name instead of finding a + // stale match. + if (renaming) self.layouts.remove(self.original_name.?); + + self.layouts.put(name, params.items, self.root) catch { + self.showError("Out of memory."); + return; + }; + + self.layouts.save() catch { + // The layout is in memory but not on disk. Say so rather than closing + // as though it worked; reopening the app would lose it. + self.showError("Could not write the layouts file."); + return; + }; + + self.on_saved(self.ctx); + self.window.destroy(); +} + +fn entryText(entry: *gtk.Entry) []const u8 { + return std.mem.span(entry.as(gtk.Editable).getText()); +} + +fn showError(self: *SaveLayoutDialog, message: [:0]const u8) void { + self.error_label.setText(message); + self.error_label.as(gtk.Widget).setVisible(1); +} + +fn onCancel(_: *gtk.Button, self: *SaveLayoutDialog) callconv(.c) void { + self.window.destroy(); +} + +fn onDestroy(_: *gtk.Window, self: *SaveLayoutDialog) callconv(.c) void { + for (self.params.items) |row| self.alloc.destroy(row); + self.params.deinit(self.alloc); + self.alloc.free(self.panes); + self.alloc.destroy(self); +} + +// ------------------------------------------------------------------------- +// Small widget helpers + +fn heading(text: [:0]const u8) *gtk.Widget { + const label = gtk.Label.new(text); + label.setXalign(0); + label.as(gtk.Widget).addCssClass("vtabs-dialog-heading"); + return label.as(gtk.Widget); +} + +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("vtabs-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); +} diff --git a/src/Session.zig b/src/Session.zig index 7e0fd6e..4b32299 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -37,6 +37,10 @@ watch: c_uint = 0, /// True once the child process has exited and the PTY hung up. exited: bool = false, +/// A script to run once the shell is ready, from the layout this pane came +/// from. Held rather than written at spawn time — see `flushStartupCommand`. +startup_command: ?[]u8 = null, + /// Called after terminal state changes, so the owner can queue a redraw. on_damage: *const fn (ctx: ?*anyopaque) void, @@ -55,10 +59,21 @@ pub const Callbacks = struct { ctx: ?*anyopaque, }; +/// What a layout can ask for beyond a plain shell. Both are empty for a +/// terminal opened the ordinary way. +pub const Options = struct { + /// Directory the shell starts in. + cwd: []const u8 = "", + + /// Script fed to the shell once it is up. + command: []const u8 = "", +}; + pub fn create( alloc: std.mem.Allocator, cols: u16, rows: u16, + opts: Options, cbs: Callbacks, ) !*Session { const self = try alloc.create(Session); @@ -103,12 +118,22 @@ pub fn create( }, 0); defer alloc.free(argv0); - self.pty = try .create(alloc, shell, &.{argv0}, .{ + const cwd_z: ?[:0]const u8 = if (opts.cwd.len > 0) + try alloc.dupeZ(u8, opts.cwd) + else + null; + defer if (cwd_z) |z| alloc.free(z); + + self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{ .ws_row = rows, .ws_col = cols, }); errdefer self.pty.deinit(); + if (opts.command.len > 0) { + self.startup_command = try alloc.dupe(u8, opts.command); + } + self.watch = glibunix.fdAdd( self.pty.master, .{ .in = true, .hup = true, .err = true }, @@ -120,6 +145,7 @@ pub fn create( } pub fn destroy(self: *Session) void { + if (self.startup_command) |cmd| self.alloc.free(cmd); if (self.watch != 0) _ = glib.Source.remove(self.watch); self.pty.deinit(); self.stream.deinit(); @@ -161,6 +187,7 @@ fn onReadable( var buf: [read_buf_size]u8 = undefined; if (self.pty.read(&buf)) |n| { self.stream.nextSlice(buf[0..n]); + self.flushStartupCommand(); self.on_damage(self.ctx); return 1; } @@ -173,6 +200,27 @@ fn onReadable( return 0; } +/// Type a layout's script into the shell, once, as soon as the shell has +/// shown that it is alive. +/// +/// The wait matters. Writing at spawn time puts the script into the tty input +/// buffer before the shell has started, and shells that set up line editing +/// (zsh's ZLE, bash's readline) can discard whatever was buffered while they +/// were initializing, so the command silently vanishes. The shell's first +/// output — its prompt — is proof that it has finished starting and is reading +/// input, which is exactly the moment this becomes safe. +/// +/// It is typed rather than executed for us, so the shell is still there +/// afterwards with the script sitting in its history. +fn flushStartupCommand(self: *Session) void { + const command = self.startup_command orelse return; + self.startup_command = null; + defer self.alloc.free(command); + + self.pty.writeAll(command); + self.pty.writeAll("\n"); +} + /// Effect callback: the terminal wants to send bytes back to the child. fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void { const self = fromHandler(handler); diff --git a/src/Terminal.zig b/src/Terminal.zig index 1160baf..fb89096 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -54,7 +54,14 @@ on_focus: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque = null, -pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal { +/// What a layout can specify for a terminal pane. +pub const Options = Session.Options; + +pub fn create( + alloc: std.mem.Allocator, + opts: Options, + cbs: Pane.Callbacks, +) !*Terminal { const self = try alloc.create(Terminal); errdefer alloc.destroy(self); @@ -76,7 +83,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal { // The grid size follows the widget size, but we need a starting point // for the session before the widget has ever been allocated. - self.session = try .create(alloc, 80, 24, .{ + self.session = try .create(alloc, 80, 24, opts, .{ .on_damage = &onDamage, .on_title = &onSessionTitle, .on_exit = &onSessionExit, diff --git a/src/View.zig b/src/View.zig index 9b97589..76494ed 100644 --- a/src/View.zig +++ b/src/View.zig @@ -12,6 +12,7 @@ const std = @import("std"); const gtk = @import("gtk"); const Layout = @import("Layout.zig"); +const Layouts = @import("Layouts.zig"); const Pane = @import("Pane.zig"); const Terminal = @import("Terminal.zig"); @@ -152,8 +153,8 @@ pub fn focus(self: *View) void { /// Add a pane, splitting the focused one so the new pane appears beside /// whatever you were working in. -pub fn addPane(self: *View, kind: Kind) !void { - const pane = try Pane.create(self.alloc, self, kind); +pub fn addPane(self: *View, spec: Pane.Spec) !void { + const pane = try Pane.create(self.alloc, self, spec); errdefer pane.destroy(); const node = try self.layout.newLeaf(pane); @@ -228,6 +229,141 @@ pub fn paneTitleChanged(self: *View, pane: *Pane) void { if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx); } +// ------------------------------------------------------------------------- +// Layouts + +/// Fill an empty view from a saved layout, substituting `bindings` into every +/// directory, script and URL as the panes are created. +/// +/// Only valid on a view that has no panes yet — a layout describes a whole +/// tab, not an addition to one. +pub fn applyLayout( + self: *View, + spec: *const Layouts.Node, + bindings: []const Layouts.Binding, +) !void { + std.debug.assert(self.panes.items.len == 0); + + const root = try self.buildNode(spec, bindings); + self.layout.root = root; + root.parent = null; + self.layout.materialize(self.box); + + // The first pane in tree order is the top-left one, which is where you + // would start reading the tab and so where focus belongs. + if (self.panes.items.len > 0) { + self.setFocused(self.panes.items[0]); + self.panes.items[0].grabFocus(); + } + self.on_title(self.ctx); +} + +/// Build one subtree. Panes are appended to `self.panes` as they are created, +/// so a failure part-way leaves them owned by the view and torn down with it +/// rather than leaked. +fn buildNode( + self: *View, + spec: *const Layouts.Node, + bindings: []const Layouts.Binding, +) !*Layout.Node { + switch (spec.*) { + .pane => |p| { + const pane_spec = try self.paneSpec(p, bindings); + defer freePaneSpec(self.alloc, pane_spec); + + const pane = try Pane.create(self.alloc, self, pane_spec); + errdefer pane.destroy(); + + const node = try self.layout.newLeaf(pane); + errdefer self.alloc.destroy(node); + + try self.panes.append(self.alloc, pane); + return node; + }, + .split => |s| { + const first = try self.buildNode(s.first, bindings); + const second = try self.buildNode(s.second, bindings); + return self.layout.newSplit( + switch (s.orientation) { + .horizontal => .horizontal, + .vertical => .vertical, + }, + first, + second, + s.ratio, + ); + }, + } +} + +/// Turn a layout's pane description into the spec `Pane.create` wants, with +/// parameters substituted. The strings are owned by the caller. +fn paneSpec( + self: *View, + p: Layouts.Pane, + bindings: []const Layouts.Binding, +) !Pane.Spec { + return switch (p.kind) { + .terminal => blk: { + const cwd = try Layouts.expandPath(self.alloc, p.cwd, bindings); + errdefer self.alloc.free(cwd); + const command = try Layouts.expand(self.alloc, p.command, bindings); + break :blk .{ .terminal = .{ .cwd = cwd, .command = command } }; + }, + .web => .{ .web = .{ + .url = try Layouts.expand(self.alloc, p.url, bindings), + } }, + }; +} + +fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void { + switch (spec) { + .terminal => |o| { + alloc.free(o.cwd); + alloc.free(o.command); + }, + .web => |o| alloc.free(o.url), + } +} + +/// Capture this view's arrangement as a layout tree, for "save tab as layout". +/// +/// The shape, split orientations and divider ratios come across exactly as +/// they are on screen. What each pane should *run* can't be known from a live +/// pane, so terminals come back with their current directory and an empty +/// command for the user to fill in; web panes bring their current URL. +pub fn capture(self: *View, builder: Layouts.Builder) !?*Layouts.Node { + const root = self.layout.root orelse return null; + return try self.captureNode(root, builder); +} + +fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layouts.Node { + return switch (node.kind) { + .leaf => |pane| switch (pane.content) { + .terminal => |t| blk: { + var buf: [std.fs.max_path_bytes]u8 = undefined; + break :blk try builder.pane(.{ + .kind = .terminal, + .cwd = t.session.pty.cwd(&buf) orelse "", + }); + }, + .web => |b| try builder.pane(.{ + .kind = .web, + .url = b.currentUrl(), + }), + }, + .split => |s| try builder.split( + switch (s.paned.as(gtk.Orientable).getOrientation()) { + .vertical => .vertical, + else => .horizontal, + }, + s.ratio, + try self.captureNode(s.a, builder), + try self.captureNode(s.b, builder), + ), + }; +} + // ------------------------------------------------------------------------- // Rearranging diff --git a/src/Window.zig b/src/Window.zig index 1e721fa..835ca4d 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -14,6 +14,9 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const vt = @import("ghostty-vt"); +const Layouts = @import("Layouts.zig"); +const OpenLayoutDialog = @import("OpenLayoutDialog.zig"); +const SaveLayoutDialog = @import("SaveLayoutDialog.zig"); const Terminal = @import("Terminal.zig"); const View = @import("View.zig"); @@ -43,6 +46,17 @@ updating: bool = false, /// doesn't try to close a tab we're already destroying. closing: bool = false, +/// Saved tab templates, read from the config file at startup. +layouts: Layouts, + +/// The popover listing them. Rebuilt whenever the set changes, since its +/// contents are one row per layout. +layout_popover: *gtk.Popover, + +/// Per-row context for the popover's handlers, owned for as long as the rows +/// they belong to are on screen. +layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty, + /// A single tab: a view of one or more panes, plus the sidebar row that /// selects it. const Tab = struct { @@ -76,7 +90,11 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { .window = window, .stack = gtk.Stack.new(), .list = gtk.ListBox.new(), + .layouts = .init(alloc), + .layout_popover = gtk.Popover.new(), }; + self.layouts.load(); + if (self.layouts.load_error) |message| std.log.warn("{s}", .{message}); window.as(gtk.Widget).addCssClass("vtabs-window"); @@ -103,6 +121,16 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { .{}, ); header.packEnd(new_tab_button.as(gtk.Widget)); + + // Layouts sit behind their own button rather than replacing the plain + // new-tab one: opening an ordinary shell stays a single click. + const layout_button = gtk.MenuButton.new(); + layout_button.setIconName("view-grid-symbolic"); + layout_button.as(gtk.Widget).setTooltipText("Open a saved layout"); + layout_button.setPopover(self.layout_popover); + self.layout_popover.as(gtk.Widget).addCssClass("vtabs-layout-popover"); + self.refreshLayoutMenu(); + header.packEnd(layout_button.as(gtk.Widget)); sidebar.append(header.as(gtk.Widget)); self.list.setSelectionMode(.single); @@ -158,6 +186,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { ); try self.newTab(); + return self; } @@ -172,7 +201,25 @@ pub fn present(self: *Window) void { } /// Open a new tab and switch to it. +/// Open a new tab holding a single terminal — the plain case, unchanged by +/// layouts existing. pub fn newTab(self: *Window) !void { + const tab = try self.newTabEmpty(); + + // Only once the tab is in `self.tabs` is it complete enough for the + // view's callbacks to use, so this is the first safe moment to give the + // view its first pane. + try tab.view.addPane(.plain(.terminal)); + + self.refreshLabel(tab); + self.select(tab); +} + +/// The tab and its sidebar row, with no panes in the view yet. +/// +/// Split out from `newTab` because a layout fills the view itself, with a +/// whole tree rather than one pane, and the tab has to exist first. +fn newTabEmpty(self: *Window) !*Tab { const tab = try self.alloc.create(Tab); errdefer self.alloc.destroy(tab); @@ -222,14 +269,234 @@ pub fn newTab(self: *Window) !void { try self.tabs.append(self.alloc, tab); - // Only now is `tab` complete enough for the view's callbacks to use, so - // this is the first safe moment to give the view its first pane. - try view.addPane(.terminal); + return tab; +} + +// ------------------------------------------------------------------------- +// Layouts + +/// Per-row context for the layout menu. One is allocated per row and freed +/// when the menu is rebuilt, so a row's handler always knows which layout it +/// belongs to without indexing into a list that may have changed. +const LayoutRow = struct { + window: *Window, + name: []const u8, +}; + +/// Rebuild the popover: one row per saved layout, then the actions. +fn refreshLayoutMenu(self: *Window) void { + self.freeLayoutRows(); + + const box = gtk.Box.new(.vertical, 2); + box.as(gtk.Widget).addCssClass("vtabs-layout-menu"); + + if (self.layouts.items.items.len == 0) { + const empty = gtk.Label.new(if (self.layouts.load_error != null) + "Layouts file could not be read" + else + "No saved layouts yet"); + empty.as(gtk.Widget).addCssClass("vtabs-layout-empty"); + box.append(empty.as(gtk.Widget)); + } + + for (self.layouts.items.items) |layout| { + const row = self.alloc.create(LayoutRow) catch continue; + row.* = .{ .window = self, .name = layout.name }; + self.layout_rows.append(self.alloc, row) catch { + self.alloc.destroy(row); + continue; + }; + + const line = gtk.Box.new(.horizontal, 4); + + var label_buf: [128]u8 = undefined; + const text = std.fmt.bufPrintZ(&label_buf, "{s}", .{layout.name}) catch continue; + + const open = gtk.Button.newWithLabel(text); + open.as(gtk.Widget).addCssClass("flat"); + open.as(gtk.Widget).setHexpand(1); + open.setHasFrame(0); + if (open.getChild()) |child| child.setHalign(.start); + _ = gtk.Button.signals.clicked.connect(open, *LayoutRow, &onLayoutClicked, row, .{}); + line.append(open.as(gtk.Widget)); + + const edit = gtk.Button.newFromIconName("document-edit-symbolic"); + edit.as(gtk.Widget).addCssClass("flat"); + edit.as(gtk.Widget).setTooltipText("Edit this layout"); + _ = gtk.Button.signals.clicked.connect(edit, *LayoutRow, &onLayoutEdit, row, .{}); + line.append(edit.as(gtk.Widget)); + + const delete = gtk.Button.newFromIconName("user-trash-symbolic"); + delete.as(gtk.Widget).addCssClass("flat"); + delete.as(gtk.Widget).setTooltipText("Delete this layout"); + _ = gtk.Button.signals.clicked.connect(delete, *LayoutRow, &onLayoutDelete, row, .{}); + line.append(delete.as(gtk.Widget)); + + box.append(line.as(gtk.Widget)); + } + + box.append(gtk.Separator.new(.horizontal).as(gtk.Widget)); + + const save = gtk.Button.newWithLabel("Save tab as layout…"); + save.as(gtk.Widget).addCssClass("flat"); + save.setHasFrame(0); + if (save.getChild()) |child| child.setHalign(.start); + _ = gtk.Button.signals.clicked.connect(save, *Window, &onSaveLayoutClicked, self, .{}); + box.append(save.as(gtk.Widget)); + + const reload = gtk.Button.newWithLabel("Reload from disk"); + reload.as(gtk.Widget).addCssClass("flat"); + reload.setHasFrame(0); + if (reload.getChild()) |child| child.setHalign(.start); + _ = gtk.Button.signals.clicked.connect(reload, *Window, &onReloadLayouts, self, .{}); + box.append(reload.as(gtk.Widget)); + + self.layout_popover.setChild(box.as(gtk.Widget)); +} + +fn freeLayoutRows(self: *Window) void { + for (self.layout_rows.items) |row| self.alloc.destroy(row); + self.layout_rows.clearRetainingCapacity(); +} + +fn onLayoutClicked(_: *gtk.Button, row: *LayoutRow) callconv(.c) void { + const self = row.window; + self.layout_popover.popdown(); + + const layout = self.layouts.find(row.name) orelse return; + + // Nothing to ask for, so skip straight past the dialog. + if (layout.parameters.len == 0) { + openLayout(self, layout, &.{}); + return; + } + + OpenLayoutDialog.present( + self.alloc, + self.window.as(gtk.Window), + layout, + &onLayoutParameters, + self, + ) catch |err| { + std.log.err("failed to open layout dialog: {s}", .{@errorName(err)}); + }; +} + +fn onLayoutParameters( + ctx: ?*anyopaque, + layout: *Layouts.Layout, + bindings: []const Layouts.Binding, +) void { + const self: *Window = @ptrCast(@alignCast(ctx.?)); + openLayout(self, layout, bindings); +} + +/// Open a layout in a new tab. +fn openLayout(self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding) void { + const tab = self.newTabEmpty() catch |err| { + std.log.err("failed to open tab: {s}", .{@errorName(err)}); + return; + }; + + tab.view.applyLayout(layout.root, bindings) catch |err| { + std.log.err("failed to build layout: {s}", .{@errorName(err)}); + // A half-built view has no panes to work in and no shell to close, + // so drop the tab rather than leave an empty one behind. + if (tab.view.panes.items.len == 0) { + self.closeTab(tab); + return; + } + }; self.refreshLabel(tab); self.select(tab); } +/// Edit a saved layout in place: its name, parameters and per-pane scripts. +/// +/// The arrangement itself isn't editable here — to reshape one, open it, +/// rearrange the tab, and save over it under the same name. +fn onLayoutEdit(_: *gtk.Button, row: *LayoutRow) callconv(.c) void { + const self = row.window; + self.layout_popover.popdown(); + + const layout = self.layouts.find(row.name) orelse return; + + SaveLayoutDialog.present( + self.alloc, + self.window.as(gtk.Window), + &self.layouts, + layout.root, + .{ + .title = "Edit layout", + .confirm = "Save", + .name = layout.name, + .parameters = layout.parameters, + .original_name = layout.name, + }, + &onLayoutSaved, + self, + ) catch |err| { + std.log.err("failed to open layout editor: {s}", .{@errorName(err)}); + }; +} + +fn onLayoutDelete(_: *gtk.Button, row: *LayoutRow) callconv(.c) void { + const self = row.window; + self.layouts.remove(row.name); + self.layouts.save() catch { + std.log.err("failed to write layouts file", .{}); + }; + // Rebuilding frees `row`, so nothing may touch it after this. + self.refreshLayoutMenu(); +} + +fn onSaveLayoutClicked(_: *gtk.Button, self: *Window) callconv(.c) void { + self.layout_popover.popdown(); + self.saveCurrentTabAsLayout(); +} + +fn saveCurrentTabAsLayout(self: *Window) void { + const tab = self.activeTab() orelse return; + + const root = tab.view.capture(self.layouts.builder()) catch |err| { + std.log.err("failed to capture layout: {s}", .{@errorName(err)}); + return; + } orelse return; + + // The tab's own label is the obvious first guess at a name. + var name_buf: [128]u8 = undefined; + const suggested = tab.view.label(&name_buf); + + SaveLayoutDialog.present( + self.alloc, + self.window.as(gtk.Window), + &self.layouts, + root, + .{ + .title = "Save tab as layout", + .confirm = "Save", + .name = suggested, + }, + &onLayoutSaved, + self, + ) catch |err| { + std.log.err("failed to open save dialog: {s}", .{@errorName(err)}); + }; +} + +fn onLayoutSaved(ctx: ?*anyopaque) void { + const self: *Window = @ptrCast(@alignCast(ctx.?)); + self.refreshLayoutMenu(); +} + +fn onReloadLayouts(_: *gtk.Button, self: *Window) callconv(.c) void { + self.layout_popover.popdown(); + self.layouts.load(); + if (self.layouts.load_error) |message| std.log.warn("{s}", .{message}); + self.refreshLayoutMenu(); +} + /// Make `tab` the visible one. fn select(self: *Window, tab: *Tab) void { self.updating = true; @@ -333,6 +600,11 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { self.alloc.destroy(tab); } self.tabs.deinit(self.alloc); + + self.freeLayoutRows(); + self.layout_rows.deinit(self.alloc); + self.layouts.deinit(); + self.alloc.destroy(self); } @@ -358,7 +630,7 @@ fn focusedTerminal(self: *Window) ?*Terminal { /// Split the visible tab's focused pane, adding a pane of the given kind. fn addPane(self: *Window, kind: View.Kind) void { const tab = self.activeTab() orelse return; - tab.view.addPane(kind) catch |err| { + tab.view.addPane(.plain(kind)) catch |err| { std.log.err("failed to open {s} pane: {s}", .{ @tagName(kind), @errorName(err) }); }; } diff --git a/src/main.zig b/src/main.zig index 121f27d..30e11df 100644 --- a/src/main.zig +++ b/src/main.zig @@ -39,6 +39,7 @@ pub fn main() u8 { } fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void { + forceDark(); loadCss(); const window = Window.create(gpa.allocator(), app) catch |err| { @@ -48,6 +49,15 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void { window.present(); } +/// The window's own chrome is dark by hand in `style.css`, but stock widgets — +/// popovers, dialogs, text entries — follow the desktop's colour scheme and +/// would come up light against it. Layouts brought the first real dialogs into +/// the app, which is where that mismatch became visible. +fn forceDark() void { + const manager = adw.StyleManager.getDefault(); + manager.setColorScheme(.force_dark); +} + fn loadCss() void { const display = gdk.Display.getDefault() orelse return; const provider = gtk.CssProvider.new(); diff --git a/src/style.css b/src/style.css index c932f30..31ad33e 100644 --- a/src/style.css +++ b/src/style.css @@ -171,6 +171,67 @@ border-color: #b29df5; } +/* Saved-layout menu, hanging off the sidebar header. */ +.vtabs-layout-menu { + min-width: 260px; +} + +.vtabs-layout-menu button { + padding: 4px 8px; + border-radius: 6px; +} + +.vtabs-layout-empty { + padding: 8px; + color: #8f87a3; + font-size: 0.9em; +} + +/* Layout dialogs: opening one, and saving the current tab as one. */ +.vtabs-dialog { + background-color: #16141c; +} + +.vtabs-dialog-content { + padding: 16px; +} + +.vtabs-dialog-actions { + padding: 12px 16px; + border-top: 1px solid #262133; +} + +.vtabs-dialog-heading { + margin-top: 8px; + font-weight: bold; + color: #ded7ef; +} + +.vtabs-dialog-label { + color: #b6afc7; +} + +.vtabs-dialog-sublabel { + font-size: 0.88em; + color: #8f87a3; +} + +.vtabs-dialog-hint { + font-size: 0.85em; + color: #8f87a3; +} + +.vtabs-dialog-error { + color: #f2a0a0; +} + +/* Each captured pane in the save dialog, so the sections read apart. */ +.vtabs-dialog-pane { + padding: 8px; + border: 1px solid #2a2536; + border-radius: 8px; +} + /* Divider between panes. Wide enough to grab without hunting for it. */ .vtabs-view paned > separator { background-color: #0f0d14;