Add custom layout creation.

This commit is contained in:
Greyson Parrelli
2026-08-11 13:57:54 -04:00
parent a7a9429d09
commit 3026bdcd4c
14 changed files with 1964 additions and 20 deletions
+91 -2
View File
@@ -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/<pid>/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.
+23 -1
View File
@@ -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;
+16
View File
@@ -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,
+573
View File
@@ -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..] });
}
+170
View File
@@ -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);
}
+29 -7
View File
@@ -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)});
};
}
+38 -1
View File
@@ -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 {
+481
View File
@@ -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);
}
+49 -1
View File
@@ -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);
+9 -2
View File
@@ -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,
+138 -2
View File
@@ -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
+276 -4
View File
@@ -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) });
};
}
+10
View File
@@ -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();
+61
View File
@@ -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;