Add custom layout creation.
This commit is contained in:
@@ -112,6 +112,9 @@ Pane.zig content plus its header, drag source, and drop target
|
|||||||
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
|
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
|
||||||
Browser.zig WebKitWebView plus a back/forward/reload/address bar
|
Browser.zig WebKitWebView plus a back/forward/reload/address bar
|
||||||
webkit.zig hand-written bindings for the WebKitGTK calls we make
|
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
|
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
||||||
Pty.zig openpt/fork/exec, controlling terminal setup
|
Pty.zig openpt/fork/exec, controlling terminal setup
|
||||||
key.zig GDK keyval -> libghostty-vt key mapping
|
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
|
moves, so the rest of the tree is unchanged and re-inserting it beside its
|
||||||
original sibling restores the original shape.
|
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:
|
Two design choices worth calling out:
|
||||||
|
|
||||||
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
|
**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
|
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
|
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
|
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
|
### 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,
|
nothing else: no bookmarks, history, downloads, devtools, or find-in-page,
|
||||||
and each pane uses WebKit's default context, so nothing is persisted between
|
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.
|
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.
|
- **Restoring a session.** Layouts save arrangements you open deliberately;
|
||||||
- **Kitty graphics, hyperlinks, tab reordering, config file.**
|
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
|
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
|
||||||
`ghostty`, since we don't install a terminfo entry.
|
`ghostty`, since we don't install a terminfo entry.
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -49,7 +49,17 @@ on_exit: *const fn (ctx: ?*anyopaque) void,
|
|||||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||||
ctx: ?*anyopaque = null,
|
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);
|
const self = try alloc.create(Browser);
|
||||||
errdefer alloc.destroy(self);
|
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.box.as(gtk.Widget).addController(focus.as(gtk.EventController));
|
||||||
|
|
||||||
self.syncNav();
|
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;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +162,13 @@ pub fn title(self: *Browser) []const u8 {
|
|||||||
return "web";
|
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.
|
/// Load an address, applying the same interpretation the address bar does.
|
||||||
pub fn navigate(self: *Browser, input: []const u8) void {
|
pub fn navigate(self: *Browser, input: []const u8) void {
|
||||||
const uri = self.resolve(input) orelse return;
|
const uri = self.resolve(input) orelse return;
|
||||||
|
|||||||
@@ -157,6 +157,22 @@ pub fn remove(self: *Layout, node: *Node) void {
|
|||||||
self.alloc.destroy(parent);
|
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(
|
fn makeSplit(
|
||||||
self: *Layout,
|
self: *Layout,
|
||||||
orientation: gtk.Orientation,
|
orientation: gtk.Orientation,
|
||||||
|
|||||||
+573
@@ -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..] });
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
/// 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.
|
/// pane can wire either one up with the same handlers.
|
||||||
pub const Callbacks = struct {
|
pub const Callbacks = struct {
|
||||||
@@ -108,10 +128,12 @@ label: *gtk.Label,
|
|||||||
/// Latest title reported by the content, kept NUL-terminated for GTK.
|
/// Latest title reported by the content, kept NUL-terminated for GTK.
|
||||||
title: [128:0]u8 = @splat(0),
|
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);
|
const self = try alloc.create(Pane);
|
||||||
errdefer alloc.destroy(self);
|
errdefer alloc.destroy(self);
|
||||||
|
|
||||||
|
const kind = spec.kind();
|
||||||
|
|
||||||
self.* = .{
|
self.* = .{
|
||||||
.alloc = alloc,
|
.alloc = alloc,
|
||||||
.view = view,
|
.view = view,
|
||||||
@@ -124,16 +146,16 @@ pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane {
|
|||||||
setTitle(self, kind.initialTitle());
|
setTitle(self, kind.initialTitle());
|
||||||
|
|
||||||
// Both constructors take the same callbacks, so the only thing that
|
// 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 = .{
|
const callbacks: Callbacks = .{
|
||||||
.on_title = &onContentTitle,
|
.on_title = &onContentTitle,
|
||||||
.on_exit = &onContentExit,
|
.on_exit = &onContentExit,
|
||||||
.on_focus = &onContentFocus,
|
.on_focus = &onContentFocus,
|
||||||
.ctx = self,
|
.ctx = self,
|
||||||
};
|
};
|
||||||
self.content = switch (kind) {
|
self.content = switch (spec) {
|
||||||
.terminal => .{ .terminal = try .create(alloc, callbacks) },
|
.terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) },
|
||||||
.web => .{ .web = try .create(alloc, callbacks) },
|
.web => |opts| .{ .web = try .create(alloc, opts, callbacks) },
|
||||||
};
|
};
|
||||||
errdefer self.content.destroy();
|
errdefer self.content.destroy();
|
||||||
|
|
||||||
@@ -396,13 +418,13 @@ fn onContentFocus(ctx: ?*anyopaque) void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) 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)});
|
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
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)});
|
std.log.err("failed to open web view: {s}", .{@errorName(err)});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-1
@@ -27,6 +27,8 @@ const c = struct {
|
|||||||
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
|
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
|
||||||
extern "c" fn setsid() pid_t;
|
extern "c" fn setsid() pid_t;
|
||||||
extern "c" fn fork() 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 close(fd: fd_t) c_int;
|
||||||
extern "c" fn dup2(old: fd_t, new: 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;
|
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
|
/// 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
|
/// them together would pass "-zsh" as an ordinary argument instead, which
|
||||||
/// shells either misparse or reject outright.
|
/// 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(
|
pub fn create(
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
path: [:0]const u8,
|
path: [:0]const u8,
|
||||||
argv: []const [:0]const u8,
|
argv: []const [:0]const u8,
|
||||||
|
dir: ?[:0]const u8,
|
||||||
size: Winsize,
|
size: Winsize,
|
||||||
) !Pty {
|
) !Pty {
|
||||||
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
|
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
|
||||||
@@ -120,7 +127,7 @@ pub fn create(
|
|||||||
const pid = c.fork();
|
const pid = c.fork();
|
||||||
if (pid < 0) return Error.ForkFailed;
|
if (pid < 0) return Error.ForkFailed;
|
||||||
if (pid == 0) {
|
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
|
// childExec only returns on failure, and a forked child has no
|
||||||
// sensible way to report that back to us.
|
// sensible way to report that back to us.
|
||||||
c._exit(127);
|
c._exit(127);
|
||||||
@@ -136,9 +143,17 @@ fn childExec(
|
|||||||
path: [:0]const u8,
|
path: [:0]const u8,
|
||||||
argv: [:null]const ?[*:0]const u8,
|
argv: [:null]const ?[*:0]const u8,
|
||||||
envp: [:null]const ?[*:0]const u8,
|
envp: [:null]const ?[*:0]const u8,
|
||||||
|
dir: ?[:0]const u8,
|
||||||
) void {
|
) void {
|
||||||
_ = c.close(master);
|
_ = 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
|
// A new session detaches us from the parent's controlling terminal so
|
||||||
// that we can claim the slave as our own below.
|
// that we can claim the slave as our own below.
|
||||||
if (c.setsid() < 0) return;
|
if (c.setsid() < 0) return;
|
||||||
@@ -211,6 +226,28 @@ fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
|
|||||||
alloc.free(envp);
|
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
|
/// 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.
|
/// idea of the terminal size and delivers SIGWINCH to the foreground group.
|
||||||
pub fn setSize(self: Pty, size: Winsize) void {
|
pub fn setSize(self: Pty, size: Winsize) void {
|
||||||
|
|||||||
@@ -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
@@ -37,6 +37,10 @@ watch: c_uint = 0,
|
|||||||
/// True once the child process has exited and the PTY hung up.
|
/// True once the child process has exited and the PTY hung up.
|
||||||
exited: bool = false,
|
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.
|
/// Called after terminal state changes, so the owner can queue a redraw.
|
||||||
on_damage: *const fn (ctx: ?*anyopaque) void,
|
on_damage: *const fn (ctx: ?*anyopaque) void,
|
||||||
|
|
||||||
@@ -55,10 +59,21 @@ pub const Callbacks = struct {
|
|||||||
ctx: ?*anyopaque,
|
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(
|
pub fn create(
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
|
opts: Options,
|
||||||
cbs: Callbacks,
|
cbs: Callbacks,
|
||||||
) !*Session {
|
) !*Session {
|
||||||
const self = try alloc.create(Session);
|
const self = try alloc.create(Session);
|
||||||
@@ -103,12 +118,22 @@ pub fn create(
|
|||||||
}, 0);
|
}, 0);
|
||||||
defer alloc.free(argv0);
|
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_row = rows,
|
||||||
.ws_col = cols,
|
.ws_col = cols,
|
||||||
});
|
});
|
||||||
errdefer self.pty.deinit();
|
errdefer self.pty.deinit();
|
||||||
|
|
||||||
|
if (opts.command.len > 0) {
|
||||||
|
self.startup_command = try alloc.dupe(u8, opts.command);
|
||||||
|
}
|
||||||
|
|
||||||
self.watch = glibunix.fdAdd(
|
self.watch = glibunix.fdAdd(
|
||||||
self.pty.master,
|
self.pty.master,
|
||||||
.{ .in = true, .hup = true, .err = true },
|
.{ .in = true, .hup = true, .err = true },
|
||||||
@@ -120,6 +145,7 @@ pub fn create(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn destroy(self: *Session) void {
|
pub fn destroy(self: *Session) void {
|
||||||
|
if (self.startup_command) |cmd| self.alloc.free(cmd);
|
||||||
if (self.watch != 0) _ = glib.Source.remove(self.watch);
|
if (self.watch != 0) _ = glib.Source.remove(self.watch);
|
||||||
self.pty.deinit();
|
self.pty.deinit();
|
||||||
self.stream.deinit();
|
self.stream.deinit();
|
||||||
@@ -161,6 +187,7 @@ fn onReadable(
|
|||||||
var buf: [read_buf_size]u8 = undefined;
|
var buf: [read_buf_size]u8 = undefined;
|
||||||
if (self.pty.read(&buf)) |n| {
|
if (self.pty.read(&buf)) |n| {
|
||||||
self.stream.nextSlice(buf[0..n]);
|
self.stream.nextSlice(buf[0..n]);
|
||||||
|
self.flushStartupCommand();
|
||||||
self.on_damage(self.ctx);
|
self.on_damage(self.ctx);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -173,6 +200,27 @@ fn onReadable(
|
|||||||
return 0;
|
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.
|
/// Effect callback: the terminal wants to send bytes back to the child.
|
||||||
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
|
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
|
||||||
const self = fromHandler(handler);
|
const self = fromHandler(handler);
|
||||||
|
|||||||
+9
-2
@@ -54,7 +54,14 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
|
|||||||
|
|
||||||
ctx: ?*anyopaque = null,
|
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);
|
const self = try alloc.create(Terminal);
|
||||||
errdefer alloc.destroy(self);
|
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
|
// The grid size follows the widget size, but we need a starting point
|
||||||
// for the session before the widget has ever been allocated.
|
// 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_damage = &onDamage,
|
||||||
.on_title = &onSessionTitle,
|
.on_title = &onSessionTitle,
|
||||||
.on_exit = &onSessionExit,
|
.on_exit = &onSessionExit,
|
||||||
|
|||||||
+138
-2
@@ -12,6 +12,7 @@ const std = @import("std");
|
|||||||
const gtk = @import("gtk");
|
const gtk = @import("gtk");
|
||||||
|
|
||||||
const Layout = @import("Layout.zig");
|
const Layout = @import("Layout.zig");
|
||||||
|
const Layouts = @import("Layouts.zig");
|
||||||
const Pane = @import("Pane.zig");
|
const Pane = @import("Pane.zig");
|
||||||
const Terminal = @import("Terminal.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
|
/// Add a pane, splitting the focused one so the new pane appears beside
|
||||||
/// whatever you were working in.
|
/// whatever you were working in.
|
||||||
pub fn addPane(self: *View, kind: Kind) !void {
|
pub fn addPane(self: *View, spec: Pane.Spec) !void {
|
||||||
const pane = try Pane.create(self.alloc, self, kind);
|
const pane = try Pane.create(self.alloc, self, spec);
|
||||||
errdefer pane.destroy();
|
errdefer pane.destroy();
|
||||||
|
|
||||||
const node = try self.layout.newLeaf(pane);
|
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);
|
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
|
// Rearranging
|
||||||
|
|
||||||
|
|||||||
+276
-4
@@ -14,6 +14,9 @@ const gobject = @import("gobject");
|
|||||||
const gtk = @import("gtk");
|
const gtk = @import("gtk");
|
||||||
const vt = @import("ghostty-vt");
|
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 Terminal = @import("Terminal.zig");
|
||||||
const View = @import("View.zig");
|
const View = @import("View.zig");
|
||||||
|
|
||||||
@@ -43,6 +46,17 @@ updating: bool = false,
|
|||||||
/// doesn't try to close a tab we're already destroying.
|
/// doesn't try to close a tab we're already destroying.
|
||||||
closing: bool = false,
|
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
|
/// A single tab: a view of one or more panes, plus the sidebar row that
|
||||||
/// selects it.
|
/// selects it.
|
||||||
const Tab = struct {
|
const Tab = struct {
|
||||||
@@ -76,7 +90,11 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
|||||||
.window = window,
|
.window = window,
|
||||||
.stack = gtk.Stack.new(),
|
.stack = gtk.Stack.new(),
|
||||||
.list = gtk.ListBox.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");
|
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));
|
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));
|
sidebar.append(header.as(gtk.Widget));
|
||||||
|
|
||||||
self.list.setSelectionMode(.single);
|
self.list.setSelectionMode(.single);
|
||||||
@@ -158,6 +186,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try self.newTab();
|
try self.newTab();
|
||||||
|
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +201,25 @@ pub fn present(self: *Window) void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open a new tab and switch to it.
|
/// 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 {
|
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);
|
const tab = try self.alloc.create(Tab);
|
||||||
errdefer self.alloc.destroy(tab);
|
errdefer self.alloc.destroy(tab);
|
||||||
|
|
||||||
@@ -222,14 +269,234 @@ pub fn newTab(self: *Window) !void {
|
|||||||
|
|
||||||
try self.tabs.append(self.alloc, tab);
|
try self.tabs.append(self.alloc, tab);
|
||||||
|
|
||||||
// Only now is `tab` complete enough for the view's callbacks to use, so
|
return tab;
|
||||||
// this is the first safe moment to give the view its first pane.
|
}
|
||||||
try view.addPane(.terminal);
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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.refreshLabel(tab);
|
||||||
self.select(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.
|
/// Make `tab` the visible one.
|
||||||
fn select(self: *Window, tab: *Tab) void {
|
fn select(self: *Window, tab: *Tab) void {
|
||||||
self.updating = true;
|
self.updating = true;
|
||||||
@@ -333,6 +600,11 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
|
|||||||
self.alloc.destroy(tab);
|
self.alloc.destroy(tab);
|
||||||
}
|
}
|
||||||
self.tabs.deinit(self.alloc);
|
self.tabs.deinit(self.alloc);
|
||||||
|
|
||||||
|
self.freeLayoutRows();
|
||||||
|
self.layout_rows.deinit(self.alloc);
|
||||||
|
self.layouts.deinit();
|
||||||
|
|
||||||
self.alloc.destroy(self);
|
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.
|
/// Split the visible tab's focused pane, adding a pane of the given kind.
|
||||||
fn addPane(self: *Window, kind: View.Kind) void {
|
fn addPane(self: *Window, kind: View.Kind) void {
|
||||||
const tab = self.activeTab() orelse return;
|
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) });
|
std.log.err("failed to open {s} pane: {s}", .{ @tagName(kind), @errorName(err) });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub fn main() u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
|
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
|
||||||
|
forceDark();
|
||||||
loadCss();
|
loadCss();
|
||||||
|
|
||||||
const window = Window.create(gpa.allocator(), app) catch |err| {
|
const window = Window.create(gpa.allocator(), app) catch |err| {
|
||||||
@@ -48,6 +49,15 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
|
|||||||
window.present();
|
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 {
|
fn loadCss() void {
|
||||||
const display = gdk.Display.getDefault() orelse return;
|
const display = gdk.Display.getDefault() orelse return;
|
||||||
const provider = gtk.CssProvider.new();
|
const provider = gtk.CssProvider.new();
|
||||||
|
|||||||
@@ -171,6 +171,67 @@
|
|||||||
border-color: #b29df5;
|
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. */
|
/* Divider between panes. Wide enough to grab without hunting for it. */
|
||||||
.vtabs-view paned > separator {
|
.vtabs-view paned > separator {
|
||||||
background-color: #0f0d14;
|
background-color: #0f0d14;
|
||||||
|
|||||||
Reference in New Issue
Block a user