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
|
||||
Browser.zig WebKitWebView plus a back/forward/reload/address bar
|
||||
webkit.zig hand-written bindings for the WebKitGTK calls we make
|
||||
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
|
||||
OpenLayoutDialog.zig prompts for a layout's parameters
|
||||
SaveLayoutDialog.zig turns the current tab into a saved layout
|
||||
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
||||
Pty.zig openpt/fork/exec, controlling terminal setup
|
||||
key.zig GDK keyval -> libghostty-vt key mapping
|
||||
@@ -154,6 +157,88 @@ preview. Cancelling a drag puts the pane back: only the dragged pane ever
|
||||
moves, so the rest of the tree is unchanged and re-inserting it beside its
|
||||
original sibling restores the original shape.
|
||||
|
||||
## Layouts
|
||||
|
||||
A **layout** is a saved tab: an arrangement of panes, each with a directory and
|
||||
a script, that opens in one go. Layouts take **parameters**, so one layout
|
||||
serves any number of projects.
|
||||
|
||||
The layout button in the sidebar lists them. Picking one asks for its
|
||||
parameters — prefilled with their defaults — and opens a new tab. A layout with
|
||||
no parameters skips the prompt. The plain new-tab button is untouched: it still
|
||||
opens one shell, immediately.
|
||||
|
||||
**You author layouts by arranging a tab.** Split it, drag panes around, drag the
|
||||
dividers, then *Save tab as layout…*. That captures the tree, the split
|
||||
orientations and the ratios exactly as they are on screen, and asks only for
|
||||
what it can't infer: a name, the parameters, and each pane's script. There is
|
||||
deliberately no separate layout builder — the split tree already is one.
|
||||
|
||||
The save dialog prefills what it can read off the live tab: each terminal's
|
||||
current directory, straight out of `/proc/<pid>/cwd`, and each web pane's
|
||||
current page. So the usual flow is to get a tab set up the way you like,
|
||||
save it, and replace the literal paths with `{{parameters}}`.
|
||||
|
||||
**Editing** a saved layout opens the same dialog on the stored one, so its
|
||||
name, parameters and per-pane scripts can be changed without opening it.
|
||||
Renaming moves the layout rather than copying it, and renaming onto a name
|
||||
another layout already has is refused instead of quietly replacing it. Nothing
|
||||
is written until you confirm, so cancelling leaves the layout untouched. To
|
||||
change the *shape* of a layout, open it, rearrange the tab, and save over it
|
||||
under the same name — the same tools you used to build it in the first place.
|
||||
|
||||
Every `cwd`, `command` and `url` goes through `{{name}}` substitution, and a
|
||||
leading `~` is expanded afterwards. An unknown `{{name}}` is left as written
|
||||
rather than blanked, so a typo shows up in the pane instead of silently
|
||||
producing an empty path.
|
||||
|
||||
**Scripts are typed into the shell, not run instead of it.** A pane starts your
|
||||
login shell as usual, and the script is fed to it once it is ready. The shell
|
||||
is still there when the script finishes, with your environment loaded and the
|
||||
command in history. The wait matters: writing at spawn time loses the input,
|
||||
because shells that set up line editing discard whatever was buffered while
|
||||
they were initializing. The shell's first output is the signal that it is
|
||||
reading, so that is when the script goes in.
|
||||
|
||||
Layouts live in `~/.config/vtabs/layouts.json` (or `$XDG_CONFIG_HOME`), and the
|
||||
file is meant to be edited by hand as well — *Reload from disk* picks up
|
||||
changes. It is JSON because the app writes it too, and a format that
|
||||
round-trips without a hand-written emitter is worth more here than a prettier
|
||||
one.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"layouts": [
|
||||
{
|
||||
"name": "Project",
|
||||
"parameters": [
|
||||
{ "name": "path", "description": "Project directory", "default": "~" }
|
||||
],
|
||||
"root": {
|
||||
"split": "horizontal",
|
||||
"ratio": 0.55,
|
||||
"first": {
|
||||
"kind": "terminal",
|
||||
"cwd": "{{path}}",
|
||||
"command": "git status"
|
||||
},
|
||||
"second": {
|
||||
"split": "vertical",
|
||||
"ratio": 0.5,
|
||||
"first": { "kind": "terminal", "cwd": "{{path}}", "command": "nvim ." },
|
||||
"second": { "kind": "web", "url": "https://github.com" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A node is a split if it has a `split` key and a leaf otherwise. Saves are
|
||||
atomic — written to a temporary and renamed — so an interrupted write leaves
|
||||
the previous layouts intact rather than a file that won't parse.
|
||||
|
||||
Two design choices worth calling out:
|
||||
|
||||
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
|
||||
@@ -187,6 +272,9 @@ in principle, but a terminal grid is small.
|
||||
dragging around exactly like they do: back/forward/reload, an address bar
|
||||
that takes a URL or falls back to a search, a load-progress indicator in the
|
||||
entry, and the page title feeding the pane header and tab label
|
||||
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
|
||||
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
||||
arranging a tab and saving it. See [Layouts](#layouts)
|
||||
|
||||
### Shortcuts
|
||||
|
||||
@@ -225,8 +313,9 @@ This is a proof of concept, and the following are deliberately absent:
|
||||
nothing else: no bookmarks, history, downloads, devtools, or find-in-page,
|
||||
and each pane uses WebKit's default context, so nothing is persisted between
|
||||
runs. Links that ask for a new window are ignored rather than opening a pane.
|
||||
- **Saved layouts.** A view's arrangement lives only as long as the tab.
|
||||
- **Kitty graphics, hyperlinks, tab reordering, config file.**
|
||||
- **Restoring a session.** Layouts save arrangements you open deliberately;
|
||||
nothing restores the tabs you happened to have open when the app closed.
|
||||
- **Kitty graphics, hyperlinks, tab reordering.**
|
||||
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
|
||||
`ghostty`, since we don't install a terminfo entry.
|
||||
|
||||
|
||||
+23
-1
@@ -49,7 +49,17 @@ on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser {
|
||||
/// What a layout can specify for a web pane.
|
||||
pub const Options = struct {
|
||||
/// Page to open with. Empty leaves the pane on its address bar.
|
||||
url: []const u8 = "",
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
opts: Options,
|
||||
cbs: Pane.Callbacks,
|
||||
) !*Browser {
|
||||
const self = try alloc.create(Browser);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
@@ -99,6 +109,11 @@ pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser {
|
||||
self.box.as(gtk.Widget).addController(focus.as(gtk.EventController));
|
||||
|
||||
self.syncNav();
|
||||
|
||||
// Navigating here rather than after the widget is realized is fine: the
|
||||
// load runs on the main loop and the view catches up when it appears.
|
||||
if (opts.url.len > 0) self.navigate(opts.url);
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
@@ -147,6 +162,13 @@ pub fn title(self: *Browser) []const u8 {
|
||||
return "web";
|
||||
}
|
||||
|
||||
/// The page currently loaded, or an empty slice if there isn't one. Borrowed
|
||||
/// from WebKit, so copy it if it needs to outlive the next navigation.
|
||||
pub fn currentUrl(self: *Browser) []const u8 {
|
||||
const uri = self.view.getUri() orelse return "";
|
||||
return std.mem.span(uri);
|
||||
}
|
||||
|
||||
/// Load an address, applying the same interpretation the address bar does.
|
||||
pub fn navigate(self: *Browser, input: []const u8) void {
|
||||
const uri = self.resolve(input) orelse return;
|
||||
|
||||
@@ -157,6 +157,22 @@ pub fn remove(self: *Layout, node: *Node) void {
|
||||
self.alloc.destroy(parent);
|
||||
}
|
||||
|
||||
/// Build a split holding two existing subtrees.
|
||||
///
|
||||
/// `insert` grows a tree one pane at a time, which is the right shape for
|
||||
/// interactive splitting but can't express an arbitrary arrangement with
|
||||
/// per-split ratios. Opening a saved layout needs exactly that, so it builds
|
||||
/// the tree bottom-up through this instead.
|
||||
pub fn newSplit(
|
||||
self: *Layout,
|
||||
orientation: gtk.Orientation,
|
||||
a: *Node,
|
||||
b: *Node,
|
||||
ratio: f64,
|
||||
) !*Node {
|
||||
return self.makeSplit(orientation, a, b, ratio);
|
||||
}
|
||||
|
||||
fn makeSplit(
|
||||
self: *Layout,
|
||||
orientation: gtk.Orientation,
|
||||
|
||||
+573
@@ -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
|
||||
/// pane can wire either one up with the same handlers.
|
||||
pub const Callbacks = struct {
|
||||
@@ -108,10 +128,12 @@ label: *gtk.Label,
|
||||
/// Latest title reported by the content, kept NUL-terminated for GTK.
|
||||
title: [128:0]u8 = @splat(0),
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane {
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
||||
const self = try alloc.create(Pane);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
const kind = spec.kind();
|
||||
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
.view = view,
|
||||
@@ -124,16 +146,16 @@ pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane {
|
||||
setTitle(self, kind.initialTitle());
|
||||
|
||||
// Both constructors take the same callbacks, so the only thing that
|
||||
// varies between kinds is which one is called.
|
||||
// varies between kinds is which one is called and what it is given.
|
||||
const callbacks: Callbacks = .{
|
||||
.on_title = &onContentTitle,
|
||||
.on_exit = &onContentExit,
|
||||
.on_focus = &onContentFocus,
|
||||
.ctx = self,
|
||||
};
|
||||
self.content = switch (kind) {
|
||||
.terminal => .{ .terminal = try .create(alloc, callbacks) },
|
||||
.web => .{ .web = try .create(alloc, callbacks) },
|
||||
self.content = switch (spec) {
|
||||
.terminal => |opts| .{ .terminal = try .create(alloc, opts, callbacks) },
|
||||
.web => |opts| .{ .web = try .create(alloc, opts, callbacks) },
|
||||
};
|
||||
errdefer self.content.destroy();
|
||||
|
||||
@@ -396,13 +418,13 @@ fn onContentFocus(ctx: ?*anyopaque) void {
|
||||
}
|
||||
|
||||
fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.addPane(.terminal) catch |err| {
|
||||
self.view.addPane(.plain(.terminal)) catch |err| {
|
||||
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.addPane(.web) catch |err| {
|
||||
self.view.addPane(.plain(.web)) catch |err| {
|
||||
std.log.err("failed to open web view: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
+38
-1
@@ -27,6 +27,8 @@ const c = struct {
|
||||
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
|
||||
extern "c" fn setsid() pid_t;
|
||||
extern "c" fn fork() pid_t;
|
||||
extern "c" fn chdir(path: [*:0]const u8) c_int;
|
||||
extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsiz: usize) isize;
|
||||
extern "c" fn close(fd: fd_t) c_int;
|
||||
extern "c" fn dup2(old: fd_t, new: fd_t) c_int;
|
||||
extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int;
|
||||
@@ -86,10 +88,15 @@ pub const Error = error{
|
||||
/// started by running e.g. /usr/bin/zsh with an argv[0] of "-zsh"; folding
|
||||
/// them together would pass "-zsh" as an ordinary argument instead, which
|
||||
/// shells either misparse or reject outright.
|
||||
///
|
||||
/// `dir` is the directory the child starts in. A null, or a directory that
|
||||
/// cannot be entered, leaves it wherever the app was started — a layout
|
||||
/// naming a path that no longer exists should still give you a usable shell.
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
path: [:0]const u8,
|
||||
argv: []const [:0]const u8,
|
||||
dir: ?[:0]const u8,
|
||||
size: Winsize,
|
||||
) !Pty {
|
||||
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
|
||||
@@ -120,7 +127,7 @@ pub fn create(
|
||||
const pid = c.fork();
|
||||
if (pid < 0) return Error.ForkFailed;
|
||||
if (pid == 0) {
|
||||
childExec(master, slave_path_z, path, argv_z, envp_z);
|
||||
childExec(master, slave_path_z, path, argv_z, envp_z, dir);
|
||||
// childExec only returns on failure, and a forked child has no
|
||||
// sensible way to report that back to us.
|
||||
c._exit(127);
|
||||
@@ -136,9 +143,17 @@ fn childExec(
|
||||
path: [:0]const u8,
|
||||
argv: [:null]const ?[*:0]const u8,
|
||||
envp: [:null]const ?[*:0]const u8,
|
||||
dir: ?[:0]const u8,
|
||||
) void {
|
||||
_ = c.close(master);
|
||||
|
||||
// Deliberately unchecked: a layout pointing at a directory that has since
|
||||
// been moved or deleted should still open a shell, in the directory the
|
||||
// app was started from, rather than a pane that dies on arrival.
|
||||
if (dir) |d| if (d.len > 0) {
|
||||
_ = c.chdir(d.ptr);
|
||||
};
|
||||
|
||||
// A new session detaches us from the parent's controlling terminal so
|
||||
// that we can claim the slave as our own below.
|
||||
if (c.setsid() < 0) return;
|
||||
@@ -211,6 +226,28 @@ fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
|
||||
alloc.free(envp);
|
||||
}
|
||||
|
||||
/// The child's current working directory, copied into `buf`, or null if it
|
||||
/// can't be read.
|
||||
///
|
||||
/// This is the shell's directory as of right now, not the one it started in,
|
||||
/// which is what makes "save this tab as a layout" record where you actually
|
||||
/// are rather than where you began. Read straight out of procfs: there is no
|
||||
/// portable API for it, and this app is Linux-only regardless.
|
||||
pub fn cwd(self: Pty, buf: []u8) ?[]const u8 {
|
||||
var link_buf: [64]u8 = undefined;
|
||||
const link = std.fmt.bufPrintZ(&link_buf, "/proc/{d}/cwd", .{self.pid}) catch return null;
|
||||
|
||||
const n = c.readlink(link.ptr, buf.ptr, buf.len);
|
||||
if (n <= 0) return null;
|
||||
|
||||
const len: usize = @intCast(n);
|
||||
// readlink truncates rather than failing, and a truncated path is worse
|
||||
// than none: it would silently point somewhere else.
|
||||
if (len >= buf.len) return null;
|
||||
|
||||
return buf[0..len];
|
||||
}
|
||||
|
||||
/// Tell the child its window changed size. This both updates the kernel's
|
||||
/// idea of the terminal size and delivers SIGWINCH to the foreground group.
|
||||
pub fn setSize(self: Pty, size: Winsize) void {
|
||||
|
||||
@@ -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.
|
||||
exited: bool = false,
|
||||
|
||||
/// A script to run once the shell is ready, from the layout this pane came
|
||||
/// from. Held rather than written at spawn time — see `flushStartupCommand`.
|
||||
startup_command: ?[]u8 = null,
|
||||
|
||||
/// Called after terminal state changes, so the owner can queue a redraw.
|
||||
on_damage: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
@@ -55,10 +59,21 @@ pub const Callbacks = struct {
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
/// What a layout can ask for beyond a plain shell. Both are empty for a
|
||||
/// terminal opened the ordinary way.
|
||||
pub const Options = struct {
|
||||
/// Directory the shell starts in.
|
||||
cwd: []const u8 = "",
|
||||
|
||||
/// Script fed to the shell once it is up.
|
||||
command: []const u8 = "",
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
opts: Options,
|
||||
cbs: Callbacks,
|
||||
) !*Session {
|
||||
const self = try alloc.create(Session);
|
||||
@@ -103,12 +118,22 @@ pub fn create(
|
||||
}, 0);
|
||||
defer alloc.free(argv0);
|
||||
|
||||
self.pty = try .create(alloc, shell, &.{argv0}, .{
|
||||
const cwd_z: ?[:0]const u8 = if (opts.cwd.len > 0)
|
||||
try alloc.dupeZ(u8, opts.cwd)
|
||||
else
|
||||
null;
|
||||
defer if (cwd_z) |z| alloc.free(z);
|
||||
|
||||
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{
|
||||
.ws_row = rows,
|
||||
.ws_col = cols,
|
||||
});
|
||||
errdefer self.pty.deinit();
|
||||
|
||||
if (opts.command.len > 0) {
|
||||
self.startup_command = try alloc.dupe(u8, opts.command);
|
||||
}
|
||||
|
||||
self.watch = glibunix.fdAdd(
|
||||
self.pty.master,
|
||||
.{ .in = true, .hup = true, .err = true },
|
||||
@@ -120,6 +145,7 @@ pub fn create(
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Session) void {
|
||||
if (self.startup_command) |cmd| self.alloc.free(cmd);
|
||||
if (self.watch != 0) _ = glib.Source.remove(self.watch);
|
||||
self.pty.deinit();
|
||||
self.stream.deinit();
|
||||
@@ -161,6 +187,7 @@ fn onReadable(
|
||||
var buf: [read_buf_size]u8 = undefined;
|
||||
if (self.pty.read(&buf)) |n| {
|
||||
self.stream.nextSlice(buf[0..n]);
|
||||
self.flushStartupCommand();
|
||||
self.on_damage(self.ctx);
|
||||
return 1;
|
||||
}
|
||||
@@ -173,6 +200,27 @@ fn onReadable(
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Type a layout's script into the shell, once, as soon as the shell has
|
||||
/// shown that it is alive.
|
||||
///
|
||||
/// The wait matters. Writing at spawn time puts the script into the tty input
|
||||
/// buffer before the shell has started, and shells that set up line editing
|
||||
/// (zsh's ZLE, bash's readline) can discard whatever was buffered while they
|
||||
/// were initializing, so the command silently vanishes. The shell's first
|
||||
/// output — its prompt — is proof that it has finished starting and is reading
|
||||
/// input, which is exactly the moment this becomes safe.
|
||||
///
|
||||
/// It is typed rather than executed for us, so the shell is still there
|
||||
/// afterwards with the script sitting in its history.
|
||||
fn flushStartupCommand(self: *Session) void {
|
||||
const command = self.startup_command orelse return;
|
||||
self.startup_command = null;
|
||||
defer self.alloc.free(command);
|
||||
|
||||
self.pty.writeAll(command);
|
||||
self.pty.writeAll("\n");
|
||||
}
|
||||
|
||||
/// Effect callback: the terminal wants to send bytes back to the child.
|
||||
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
|
||||
const self = fromHandler(handler);
|
||||
|
||||
+9
-2
@@ -54,7 +54,14 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal {
|
||||
/// What a layout can specify for a terminal pane.
|
||||
pub const Options = Session.Options;
|
||||
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
opts: Options,
|
||||
cbs: Pane.Callbacks,
|
||||
) !*Terminal {
|
||||
const self = try alloc.create(Terminal);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
@@ -76,7 +83,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal {
|
||||
|
||||
// The grid size follows the widget size, but we need a starting point
|
||||
// for the session before the widget has ever been allocated.
|
||||
self.session = try .create(alloc, 80, 24, .{
|
||||
self.session = try .create(alloc, 80, 24, opts, .{
|
||||
.on_damage = &onDamage,
|
||||
.on_title = &onSessionTitle,
|
||||
.on_exit = &onSessionExit,
|
||||
|
||||
+138
-2
@@ -12,6 +12,7 @@ const std = @import("std");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const Layout = @import("Layout.zig");
|
||||
const Layouts = @import("Layouts.zig");
|
||||
const Pane = @import("Pane.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
|
||||
@@ -152,8 +153,8 @@ pub fn focus(self: *View) void {
|
||||
|
||||
/// Add a pane, splitting the focused one so the new pane appears beside
|
||||
/// whatever you were working in.
|
||||
pub fn addPane(self: *View, kind: Kind) !void {
|
||||
const pane = try Pane.create(self.alloc, self, kind);
|
||||
pub fn addPane(self: *View, spec: Pane.Spec) !void {
|
||||
const pane = try Pane.create(self.alloc, self, spec);
|
||||
errdefer pane.destroy();
|
||||
|
||||
const node = try self.layout.newLeaf(pane);
|
||||
@@ -228,6 +229,141 @@ pub fn paneTitleChanged(self: *View, pane: *Pane) void {
|
||||
if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Layouts
|
||||
|
||||
/// Fill an empty view from a saved layout, substituting `bindings` into every
|
||||
/// directory, script and URL as the panes are created.
|
||||
///
|
||||
/// Only valid on a view that has no panes yet — a layout describes a whole
|
||||
/// tab, not an addition to one.
|
||||
pub fn applyLayout(
|
||||
self: *View,
|
||||
spec: *const Layouts.Node,
|
||||
bindings: []const Layouts.Binding,
|
||||
) !void {
|
||||
std.debug.assert(self.panes.items.len == 0);
|
||||
|
||||
const root = try self.buildNode(spec, bindings);
|
||||
self.layout.root = root;
|
||||
root.parent = null;
|
||||
self.layout.materialize(self.box);
|
||||
|
||||
// The first pane in tree order is the top-left one, which is where you
|
||||
// would start reading the tab and so where focus belongs.
|
||||
if (self.panes.items.len > 0) {
|
||||
self.setFocused(self.panes.items[0]);
|
||||
self.panes.items[0].grabFocus();
|
||||
}
|
||||
self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
/// Build one subtree. Panes are appended to `self.panes` as they are created,
|
||||
/// so a failure part-way leaves them owned by the view and torn down with it
|
||||
/// rather than leaked.
|
||||
fn buildNode(
|
||||
self: *View,
|
||||
spec: *const Layouts.Node,
|
||||
bindings: []const Layouts.Binding,
|
||||
) !*Layout.Node {
|
||||
switch (spec.*) {
|
||||
.pane => |p| {
|
||||
const pane_spec = try self.paneSpec(p, bindings);
|
||||
defer freePaneSpec(self.alloc, pane_spec);
|
||||
|
||||
const pane = try Pane.create(self.alloc, self, pane_spec);
|
||||
errdefer pane.destroy();
|
||||
|
||||
const node = try self.layout.newLeaf(pane);
|
||||
errdefer self.alloc.destroy(node);
|
||||
|
||||
try self.panes.append(self.alloc, pane);
|
||||
return node;
|
||||
},
|
||||
.split => |s| {
|
||||
const first = try self.buildNode(s.first, bindings);
|
||||
const second = try self.buildNode(s.second, bindings);
|
||||
return self.layout.newSplit(
|
||||
switch (s.orientation) {
|
||||
.horizontal => .horizontal,
|
||||
.vertical => .vertical,
|
||||
},
|
||||
first,
|
||||
second,
|
||||
s.ratio,
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a layout's pane description into the spec `Pane.create` wants, with
|
||||
/// parameters substituted. The strings are owned by the caller.
|
||||
fn paneSpec(
|
||||
self: *View,
|
||||
p: Layouts.Pane,
|
||||
bindings: []const Layouts.Binding,
|
||||
) !Pane.Spec {
|
||||
return switch (p.kind) {
|
||||
.terminal => blk: {
|
||||
const cwd = try Layouts.expandPath(self.alloc, p.cwd, bindings);
|
||||
errdefer self.alloc.free(cwd);
|
||||
const command = try Layouts.expand(self.alloc, p.command, bindings);
|
||||
break :blk .{ .terminal = .{ .cwd = cwd, .command = command } };
|
||||
},
|
||||
.web => .{ .web = .{
|
||||
.url = try Layouts.expand(self.alloc, p.url, bindings),
|
||||
} },
|
||||
};
|
||||
}
|
||||
|
||||
fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void {
|
||||
switch (spec) {
|
||||
.terminal => |o| {
|
||||
alloc.free(o.cwd);
|
||||
alloc.free(o.command);
|
||||
},
|
||||
.web => |o| alloc.free(o.url),
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture this view's arrangement as a layout tree, for "save tab as layout".
|
||||
///
|
||||
/// The shape, split orientations and divider ratios come across exactly as
|
||||
/// they are on screen. What each pane should *run* can't be known from a live
|
||||
/// pane, so terminals come back with their current directory and an empty
|
||||
/// command for the user to fill in; web panes bring their current URL.
|
||||
pub fn capture(self: *View, builder: Layouts.Builder) !?*Layouts.Node {
|
||||
const root = self.layout.root orelse return null;
|
||||
return try self.captureNode(root, builder);
|
||||
}
|
||||
|
||||
fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layouts.Node {
|
||||
return switch (node.kind) {
|
||||
.leaf => |pane| switch (pane.content) {
|
||||
.terminal => |t| blk: {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
break :blk try builder.pane(.{
|
||||
.kind = .terminal,
|
||||
.cwd = t.session.pty.cwd(&buf) orelse "",
|
||||
});
|
||||
},
|
||||
.web => |b| try builder.pane(.{
|
||||
.kind = .web,
|
||||
.url = b.currentUrl(),
|
||||
}),
|
||||
},
|
||||
.split => |s| try builder.split(
|
||||
switch (s.paned.as(gtk.Orientable).getOrientation()) {
|
||||
.vertical => .vertical,
|
||||
else => .horizontal,
|
||||
},
|
||||
s.ratio,
|
||||
try self.captureNode(s.a, builder),
|
||||
try self.captureNode(s.b, builder),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rearranging
|
||||
|
||||
|
||||
+276
-4
@@ -14,6 +14,9 @@ const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
const vt = @import("ghostty-vt");
|
||||
|
||||
const Layouts = @import("Layouts.zig");
|
||||
const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
|
||||
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
|
||||
@@ -43,6 +46,17 @@ updating: bool = false,
|
||||
/// doesn't try to close a tab we're already destroying.
|
||||
closing: bool = false,
|
||||
|
||||
/// Saved tab templates, read from the config file at startup.
|
||||
layouts: Layouts,
|
||||
|
||||
/// The popover listing them. Rebuilt whenever the set changes, since its
|
||||
/// contents are one row per layout.
|
||||
layout_popover: *gtk.Popover,
|
||||
|
||||
/// Per-row context for the popover's handlers, owned for as long as the rows
|
||||
/// they belong to are on screen.
|
||||
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
|
||||
|
||||
/// A single tab: a view of one or more panes, plus the sidebar row that
|
||||
/// selects it.
|
||||
const Tab = struct {
|
||||
@@ -76,7 +90,11 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
||||
.window = window,
|
||||
.stack = gtk.Stack.new(),
|
||||
.list = gtk.ListBox.new(),
|
||||
.layouts = .init(alloc),
|
||||
.layout_popover = gtk.Popover.new(),
|
||||
};
|
||||
self.layouts.load();
|
||||
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
|
||||
|
||||
window.as(gtk.Widget).addCssClass("vtabs-window");
|
||||
|
||||
@@ -103,6 +121,16 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
||||
.{},
|
||||
);
|
||||
header.packEnd(new_tab_button.as(gtk.Widget));
|
||||
|
||||
// Layouts sit behind their own button rather than replacing the plain
|
||||
// new-tab one: opening an ordinary shell stays a single click.
|
||||
const layout_button = gtk.MenuButton.new();
|
||||
layout_button.setIconName("view-grid-symbolic");
|
||||
layout_button.as(gtk.Widget).setTooltipText("Open a saved layout");
|
||||
layout_button.setPopover(self.layout_popover);
|
||||
self.layout_popover.as(gtk.Widget).addCssClass("vtabs-layout-popover");
|
||||
self.refreshLayoutMenu();
|
||||
header.packEnd(layout_button.as(gtk.Widget));
|
||||
sidebar.append(header.as(gtk.Widget));
|
||||
|
||||
self.list.setSelectionMode(.single);
|
||||
@@ -158,6 +186,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
||||
);
|
||||
|
||||
try self.newTab();
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
@@ -172,7 +201,25 @@ pub fn present(self: *Window) void {
|
||||
}
|
||||
|
||||
/// Open a new tab and switch to it.
|
||||
/// Open a new tab holding a single terminal — the plain case, unchanged by
|
||||
/// layouts existing.
|
||||
pub fn newTab(self: *Window) !void {
|
||||
const tab = try self.newTabEmpty();
|
||||
|
||||
// Only once the tab is in `self.tabs` is it complete enough for the
|
||||
// view's callbacks to use, so this is the first safe moment to give the
|
||||
// view its first pane.
|
||||
try tab.view.addPane(.plain(.terminal));
|
||||
|
||||
self.refreshLabel(tab);
|
||||
self.select(tab);
|
||||
}
|
||||
|
||||
/// The tab and its sidebar row, with no panes in the view yet.
|
||||
///
|
||||
/// Split out from `newTab` because a layout fills the view itself, with a
|
||||
/// whole tree rather than one pane, and the tab has to exist first.
|
||||
fn newTabEmpty(self: *Window) !*Tab {
|
||||
const tab = try self.alloc.create(Tab);
|
||||
errdefer self.alloc.destroy(tab);
|
||||
|
||||
@@ -222,14 +269,234 @@ pub fn newTab(self: *Window) !void {
|
||||
|
||||
try self.tabs.append(self.alloc, tab);
|
||||
|
||||
// Only now is `tab` complete enough for the view's callbacks to use, so
|
||||
// this is the first safe moment to give the view its first pane.
|
||||
try view.addPane(.terminal);
|
||||
return tab;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Layouts
|
||||
|
||||
/// Per-row context for the layout menu. One is allocated per row and freed
|
||||
/// when the menu is rebuilt, so a row's handler always knows which layout it
|
||||
/// belongs to without indexing into a list that may have changed.
|
||||
const LayoutRow = struct {
|
||||
window: *Window,
|
||||
name: []const u8,
|
||||
};
|
||||
|
||||
/// Rebuild the popover: one row per saved layout, then the actions.
|
||||
fn refreshLayoutMenu(self: *Window) void {
|
||||
self.freeLayoutRows();
|
||||
|
||||
const box = gtk.Box.new(.vertical, 2);
|
||||
box.as(gtk.Widget).addCssClass("vtabs-layout-menu");
|
||||
|
||||
if (self.layouts.items.items.len == 0) {
|
||||
const empty = gtk.Label.new(if (self.layouts.load_error != null)
|
||||
"Layouts file could not be read"
|
||||
else
|
||||
"No saved layouts yet");
|
||||
empty.as(gtk.Widget).addCssClass("vtabs-layout-empty");
|
||||
box.append(empty.as(gtk.Widget));
|
||||
}
|
||||
|
||||
for (self.layouts.items.items) |layout| {
|
||||
const row = self.alloc.create(LayoutRow) catch continue;
|
||||
row.* = .{ .window = self, .name = layout.name };
|
||||
self.layout_rows.append(self.alloc, row) catch {
|
||||
self.alloc.destroy(row);
|
||||
continue;
|
||||
};
|
||||
|
||||
const line = gtk.Box.new(.horizontal, 4);
|
||||
|
||||
var label_buf: [128]u8 = undefined;
|
||||
const text = std.fmt.bufPrintZ(&label_buf, "{s}", .{layout.name}) catch continue;
|
||||
|
||||
const open = gtk.Button.newWithLabel(text);
|
||||
open.as(gtk.Widget).addCssClass("flat");
|
||||
open.as(gtk.Widget).setHexpand(1);
|
||||
open.setHasFrame(0);
|
||||
if (open.getChild()) |child| child.setHalign(.start);
|
||||
_ = gtk.Button.signals.clicked.connect(open, *LayoutRow, &onLayoutClicked, row, .{});
|
||||
line.append(open.as(gtk.Widget));
|
||||
|
||||
const edit = gtk.Button.newFromIconName("document-edit-symbolic");
|
||||
edit.as(gtk.Widget).addCssClass("flat");
|
||||
edit.as(gtk.Widget).setTooltipText("Edit this layout");
|
||||
_ = gtk.Button.signals.clicked.connect(edit, *LayoutRow, &onLayoutEdit, row, .{});
|
||||
line.append(edit.as(gtk.Widget));
|
||||
|
||||
const delete = gtk.Button.newFromIconName("user-trash-symbolic");
|
||||
delete.as(gtk.Widget).addCssClass("flat");
|
||||
delete.as(gtk.Widget).setTooltipText("Delete this layout");
|
||||
_ = gtk.Button.signals.clicked.connect(delete, *LayoutRow, &onLayoutDelete, row, .{});
|
||||
line.append(delete.as(gtk.Widget));
|
||||
|
||||
box.append(line.as(gtk.Widget));
|
||||
}
|
||||
|
||||
box.append(gtk.Separator.new(.horizontal).as(gtk.Widget));
|
||||
|
||||
const save = gtk.Button.newWithLabel("Save tab as layout…");
|
||||
save.as(gtk.Widget).addCssClass("flat");
|
||||
save.setHasFrame(0);
|
||||
if (save.getChild()) |child| child.setHalign(.start);
|
||||
_ = gtk.Button.signals.clicked.connect(save, *Window, &onSaveLayoutClicked, self, .{});
|
||||
box.append(save.as(gtk.Widget));
|
||||
|
||||
const reload = gtk.Button.newWithLabel("Reload from disk");
|
||||
reload.as(gtk.Widget).addCssClass("flat");
|
||||
reload.setHasFrame(0);
|
||||
if (reload.getChild()) |child| child.setHalign(.start);
|
||||
_ = gtk.Button.signals.clicked.connect(reload, *Window, &onReloadLayouts, self, .{});
|
||||
box.append(reload.as(gtk.Widget));
|
||||
|
||||
self.layout_popover.setChild(box.as(gtk.Widget));
|
||||
}
|
||||
|
||||
fn freeLayoutRows(self: *Window) void {
|
||||
for (self.layout_rows.items) |row| self.alloc.destroy(row);
|
||||
self.layout_rows.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
fn onLayoutClicked(_: *gtk.Button, row: *LayoutRow) callconv(.c) void {
|
||||
const self = row.window;
|
||||
self.layout_popover.popdown();
|
||||
|
||||
const layout = self.layouts.find(row.name) orelse return;
|
||||
|
||||
// Nothing to ask for, so skip straight past the dialog.
|
||||
if (layout.parameters.len == 0) {
|
||||
openLayout(self, layout, &.{});
|
||||
return;
|
||||
}
|
||||
|
||||
OpenLayoutDialog.present(
|
||||
self.alloc,
|
||||
self.window.as(gtk.Window),
|
||||
layout,
|
||||
&onLayoutParameters,
|
||||
self,
|
||||
) catch |err| {
|
||||
std.log.err("failed to open layout dialog: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onLayoutParameters(
|
||||
ctx: ?*anyopaque,
|
||||
layout: *Layouts.Layout,
|
||||
bindings: []const Layouts.Binding,
|
||||
) void {
|
||||
const self: *Window = @ptrCast(@alignCast(ctx.?));
|
||||
openLayout(self, layout, bindings);
|
||||
}
|
||||
|
||||
/// Open a layout in a new tab.
|
||||
fn openLayout(self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding) void {
|
||||
const tab = self.newTabEmpty() catch |err| {
|
||||
std.log.err("failed to open tab: {s}", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
|
||||
tab.view.applyLayout(layout.root, bindings) catch |err| {
|
||||
std.log.err("failed to build layout: {s}", .{@errorName(err)});
|
||||
// A half-built view has no panes to work in and no shell to close,
|
||||
// so drop the tab rather than leave an empty one behind.
|
||||
if (tab.view.panes.items.len == 0) {
|
||||
self.closeTab(tab);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.refreshLabel(tab);
|
||||
self.select(tab);
|
||||
}
|
||||
|
||||
/// Edit a saved layout in place: its name, parameters and per-pane scripts.
|
||||
///
|
||||
/// The arrangement itself isn't editable here — to reshape one, open it,
|
||||
/// rearrange the tab, and save over it under the same name.
|
||||
fn onLayoutEdit(_: *gtk.Button, row: *LayoutRow) callconv(.c) void {
|
||||
const self = row.window;
|
||||
self.layout_popover.popdown();
|
||||
|
||||
const layout = self.layouts.find(row.name) orelse return;
|
||||
|
||||
SaveLayoutDialog.present(
|
||||
self.alloc,
|
||||
self.window.as(gtk.Window),
|
||||
&self.layouts,
|
||||
layout.root,
|
||||
.{
|
||||
.title = "Edit layout",
|
||||
.confirm = "Save",
|
||||
.name = layout.name,
|
||||
.parameters = layout.parameters,
|
||||
.original_name = layout.name,
|
||||
},
|
||||
&onLayoutSaved,
|
||||
self,
|
||||
) catch |err| {
|
||||
std.log.err("failed to open layout editor: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onLayoutDelete(_: *gtk.Button, row: *LayoutRow) callconv(.c) void {
|
||||
const self = row.window;
|
||||
self.layouts.remove(row.name);
|
||||
self.layouts.save() catch {
|
||||
std.log.err("failed to write layouts file", .{});
|
||||
};
|
||||
// Rebuilding frees `row`, so nothing may touch it after this.
|
||||
self.refreshLayoutMenu();
|
||||
}
|
||||
|
||||
fn onSaveLayoutClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
|
||||
self.layout_popover.popdown();
|
||||
self.saveCurrentTabAsLayout();
|
||||
}
|
||||
|
||||
fn saveCurrentTabAsLayout(self: *Window) void {
|
||||
const tab = self.activeTab() orelse return;
|
||||
|
||||
const root = tab.view.capture(self.layouts.builder()) catch |err| {
|
||||
std.log.err("failed to capture layout: {s}", .{@errorName(err)});
|
||||
return;
|
||||
} orelse return;
|
||||
|
||||
// The tab's own label is the obvious first guess at a name.
|
||||
var name_buf: [128]u8 = undefined;
|
||||
const suggested = tab.view.label(&name_buf);
|
||||
|
||||
SaveLayoutDialog.present(
|
||||
self.alloc,
|
||||
self.window.as(gtk.Window),
|
||||
&self.layouts,
|
||||
root,
|
||||
.{
|
||||
.title = "Save tab as layout",
|
||||
.confirm = "Save",
|
||||
.name = suggested,
|
||||
},
|
||||
&onLayoutSaved,
|
||||
self,
|
||||
) catch |err| {
|
||||
std.log.err("failed to open save dialog: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onLayoutSaved(ctx: ?*anyopaque) void {
|
||||
const self: *Window = @ptrCast(@alignCast(ctx.?));
|
||||
self.refreshLayoutMenu();
|
||||
}
|
||||
|
||||
fn onReloadLayouts(_: *gtk.Button, self: *Window) callconv(.c) void {
|
||||
self.layout_popover.popdown();
|
||||
self.layouts.load();
|
||||
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
|
||||
self.refreshLayoutMenu();
|
||||
}
|
||||
|
||||
/// Make `tab` the visible one.
|
||||
fn select(self: *Window, tab: *Tab) void {
|
||||
self.updating = true;
|
||||
@@ -333,6 +600,11 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
|
||||
self.alloc.destroy(tab);
|
||||
}
|
||||
self.tabs.deinit(self.alloc);
|
||||
|
||||
self.freeLayoutRows();
|
||||
self.layout_rows.deinit(self.alloc);
|
||||
self.layouts.deinit();
|
||||
|
||||
self.alloc.destroy(self);
|
||||
}
|
||||
|
||||
@@ -358,7 +630,7 @@ fn focusedTerminal(self: *Window) ?*Terminal {
|
||||
/// Split the visible tab's focused pane, adding a pane of the given kind.
|
||||
fn addPane(self: *Window, kind: View.Kind) void {
|
||||
const tab = self.activeTab() orelse return;
|
||||
tab.view.addPane(kind) catch |err| {
|
||||
tab.view.addPane(.plain(kind)) catch |err| {
|
||||
std.log.err("failed to open {s} pane: {s}", .{ @tagName(kind), @errorName(err) });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn main() u8 {
|
||||
}
|
||||
|
||||
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
|
||||
forceDark();
|
||||
loadCss();
|
||||
|
||||
const window = Window.create(gpa.allocator(), app) catch |err| {
|
||||
@@ -48,6 +49,15 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// The window's own chrome is dark by hand in `style.css`, but stock widgets —
|
||||
/// popovers, dialogs, text entries — follow the desktop's colour scheme and
|
||||
/// would come up light against it. Layouts brought the first real dialogs into
|
||||
/// the app, which is where that mismatch became visible.
|
||||
fn forceDark() void {
|
||||
const manager = adw.StyleManager.getDefault();
|
||||
manager.setColorScheme(.force_dark);
|
||||
}
|
||||
|
||||
fn loadCss() void {
|
||||
const display = gdk.Display.getDefault() orelse return;
|
||||
const provider = gtk.CssProvider.new();
|
||||
|
||||
@@ -171,6 +171,67 @@
|
||||
border-color: #b29df5;
|
||||
}
|
||||
|
||||
/* Saved-layout menu, hanging off the sidebar header. */
|
||||
.vtabs-layout-menu {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.vtabs-layout-menu button {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.vtabs-layout-empty {
|
||||
padding: 8px;
|
||||
color: #8f87a3;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Layout dialogs: opening one, and saving the current tab as one. */
|
||||
.vtabs-dialog {
|
||||
background-color: #16141c;
|
||||
}
|
||||
|
||||
.vtabs-dialog-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.vtabs-dialog-actions {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #262133;
|
||||
}
|
||||
|
||||
.vtabs-dialog-heading {
|
||||
margin-top: 8px;
|
||||
font-weight: bold;
|
||||
color: #ded7ef;
|
||||
}
|
||||
|
||||
.vtabs-dialog-label {
|
||||
color: #b6afc7;
|
||||
}
|
||||
|
||||
.vtabs-dialog-sublabel {
|
||||
font-size: 0.88em;
|
||||
color: #8f87a3;
|
||||
}
|
||||
|
||||
.vtabs-dialog-hint {
|
||||
font-size: 0.85em;
|
||||
color: #8f87a3;
|
||||
}
|
||||
|
||||
.vtabs-dialog-error {
|
||||
color: #f2a0a0;
|
||||
}
|
||||
|
||||
/* Each captured pane in the save dialog, so the sections read apart. */
|
||||
.vtabs-dialog-pane {
|
||||
padding: 8px;
|
||||
border: 1px solid #2a2536;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Divider between panes. Wide enough to grab without hunting for it. */
|
||||
.vtabs-view paned > separator {
|
||||
background-color: #0f0d14;
|
||||
|
||||
Reference in New Issue
Block a user