Replaces the flat pane list with a binary split tree, so a view can hold layouts a single shared orientation could not express. Three panes in a row with the rightmost dragged to the bottom now gives two on top and one spanning the full width beneath them. Each split node owns a GtkPaned, which brings draggable dividers. Positions are stored as ratios and re-applied whenever the available space changes, so proportions survive window resizes while user drags still update them. Drop placement depends on proximity to the view's own border: near an edge the pane is placed against the whole layout and spans it, anywhere else it splits only the pane under the pointer. Drags now rearrange live rather than on release, so the layout under the cursor is always the result. Cancelling restores the original arrangement, which works because only the dragged pane moves: the rest of the tree keeps its shape, so re-inserting beside the original sibling is enough. Fixes a latent ownership bug the tree exposed: panes did not hold a reference to their own widget, so detaching one during a rebuild dropped the last reference and finalized it, producing GTK_IS_WIDGET assertion failures. Panes and split nodes now each own a reference to their widget.
501 lines
16 KiB
Zig
501 lines
16 KiB
Zig
//! The application window: a vertical tab strip down the left side and the
|
|
//! active terminal filling the rest.
|
|
//!
|
|
//! The layout follows Zen Browser's vertical tabs — a persistent sidebar
|
|
//! column holding the window controls, a "New Tab" affordance, and one row
|
|
//! per tab, with the content pane inset to its right.
|
|
|
|
const std = @import("std");
|
|
const adw = @import("adw");
|
|
const gdk = @import("gdk");
|
|
const gio = @import("gio");
|
|
const glib = @import("glib");
|
|
const gobject = @import("gobject");
|
|
const gtk = @import("gtk");
|
|
const vt = @import("ghostty-vt");
|
|
|
|
const View = @import("View.zig");
|
|
|
|
const Window = @This();
|
|
|
|
const sidebar_width = 220;
|
|
|
|
alloc: std.mem.Allocator,
|
|
window: *adw.ApplicationWindow,
|
|
|
|
/// Holds one page per tab; the visible page is the active terminal.
|
|
stack: *gtk.Stack,
|
|
|
|
/// One row per tab, in the same order as `tabs`.
|
|
list: *gtk.ListBox,
|
|
|
|
tabs: std.ArrayListUnmanaged(*Tab) = .empty,
|
|
|
|
/// Monotonic counter so every tab gets a distinct GtkStack page name.
|
|
next_id: u32 = 0,
|
|
|
|
/// Set while we're programmatically changing the selection, so that the
|
|
/// resulting `row-selected` signal doesn't recurse.
|
|
updating: bool = false,
|
|
|
|
/// Set once teardown has begun, so that a session exiting mid-teardown
|
|
/// doesn't try to close a tab we're already destroying.
|
|
closing: bool = false,
|
|
|
|
/// A single tab: a view of one or more terminals, plus the sidebar row that
|
|
/// selects it.
|
|
const Tab = struct {
|
|
window: *Window,
|
|
view: *View,
|
|
row: *gtk.ListBoxRow,
|
|
label: *gtk.Label,
|
|
name: [16]u8,
|
|
name_len: usize,
|
|
|
|
fn pageName(self: *const Tab) [:0]const u8 {
|
|
return self.name[0..self.name_len :0];
|
|
}
|
|
};
|
|
|
|
pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
|
const self = try alloc.create(Window);
|
|
errdefer alloc.destroy(self);
|
|
|
|
const window = adw.ApplicationWindow.new(app.as(gtk.Application));
|
|
window.as(gtk.Window).setTitle("vtabs");
|
|
window.as(gtk.Window).setDefaultSize(1100, 720);
|
|
|
|
self.* = .{
|
|
.alloc = alloc,
|
|
.window = window,
|
|
.stack = gtk.Stack.new(),
|
|
.list = gtk.ListBox.new(),
|
|
};
|
|
|
|
window.as(gtk.Widget).addCssClass("vtabs-window");
|
|
|
|
// ---- sidebar -------------------------------------------------------
|
|
const sidebar = gtk.Box.new(.vertical, 0);
|
|
sidebar.as(gtk.Widget).addCssClass("vtabs-sidebar");
|
|
sidebar.as(gtk.Widget).setSizeRequest(sidebar_width, -1);
|
|
|
|
// The header bar lives inside the sidebar rather than spanning the
|
|
// window, which is what gives the Zen-style look. It also carries the
|
|
// window controls, which we still need since GTK draws its own
|
|
// decorations on Wayland.
|
|
const header = adw.HeaderBar.new();
|
|
header.setShowTitle(0);
|
|
header.as(gtk.Widget).addCssClass("flat");
|
|
|
|
const new_tab_button = gtk.Button.newFromIconName("tab-new-symbolic");
|
|
new_tab_button.as(gtk.Widget).setTooltipText("New tab (Ctrl+Shift+T)");
|
|
_ = gtk.Button.signals.clicked.connect(
|
|
new_tab_button,
|
|
*Window,
|
|
&onNewTabClicked,
|
|
self,
|
|
.{},
|
|
);
|
|
header.packEnd(new_tab_button.as(gtk.Widget));
|
|
sidebar.append(header.as(gtk.Widget));
|
|
|
|
self.list.setSelectionMode(.single);
|
|
self.list.as(gtk.Widget).addCssClass("navigation-sidebar");
|
|
self.list.as(gtk.Widget).addCssClass("vtabs-list");
|
|
_ = gtk.ListBox.signals.row_selected.connect(
|
|
self.list,
|
|
*Window,
|
|
&onRowSelected,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
const scroller = gtk.ScrolledWindow.new();
|
|
scroller.setPolicy(.never, .automatic);
|
|
scroller.as(gtk.Widget).setVexpand(1);
|
|
scroller.setChild(self.list.as(gtk.Widget));
|
|
sidebar.append(scroller.as(gtk.Widget));
|
|
|
|
// ---- content -------------------------------------------------------
|
|
self.stack.as(gtk.Widget).setHexpand(1);
|
|
self.stack.as(gtk.Widget).setVexpand(1);
|
|
self.stack.as(gtk.Widget).addCssClass("vtabs-content");
|
|
|
|
const content = gtk.Box.new(.horizontal, 0);
|
|
content.append(sidebar.as(gtk.Widget));
|
|
content.append(self.stack.as(gtk.Widget));
|
|
|
|
window.setContent(content.as(gtk.Widget));
|
|
|
|
// Window-level shortcuts run in the capture phase so they are handled
|
|
// before the focused terminal turns the key into a VT sequence.
|
|
const shortcuts = gtk.EventControllerKey.new();
|
|
shortcuts.as(gtk.EventController).setPropagationPhase(.capture);
|
|
_ = gtk.EventControllerKey.signals.key_pressed.connect(
|
|
shortcuts,
|
|
*Window,
|
|
&onShortcut,
|
|
self,
|
|
.{},
|
|
);
|
|
window.as(gtk.Widget).addController(shortcuts.as(gtk.EventController));
|
|
|
|
// Free our own state once GTK is done with the window. Doing this on
|
|
// `destroy` rather than `close-request` means no further events can
|
|
// arrive for widgets whose user data we're about to free.
|
|
_ = gtk.Widget.signals.destroy.connect(
|
|
window,
|
|
*Window,
|
|
&onDestroy,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
try self.newTab();
|
|
return self;
|
|
}
|
|
|
|
pub fn present(self: *Window) void {
|
|
self.window.as(gtk.Window).present();
|
|
|
|
// Focus has to be grabbed after the window is presented. Calling
|
|
// grabFocus during construction silently does nothing because the
|
|
// widget is not yet realized, which would send the first keystroke to
|
|
// the sidebar instead of the terminal.
|
|
if (self.activeTab()) |tab| tab.view.focus();
|
|
}
|
|
|
|
/// Open a new tab and switch to it.
|
|
pub fn newTab(self: *Window) !void {
|
|
const tab = try self.alloc.create(Tab);
|
|
errdefer self.alloc.destroy(tab);
|
|
|
|
const view = try View.create(self.alloc, .{
|
|
.on_empty = &onViewEmpty,
|
|
.on_title = &onViewTitle,
|
|
.ctx = tab,
|
|
});
|
|
errdefer view.destroy();
|
|
|
|
const id = self.next_id;
|
|
self.next_id += 1;
|
|
|
|
tab.* = .{
|
|
.window = self,
|
|
.view = view,
|
|
.row = gtk.ListBoxRow.new(),
|
|
.label = gtk.Label.new("shell"),
|
|
.name = undefined,
|
|
.name_len = 0,
|
|
};
|
|
const printed = std.fmt.bufPrintZ(&tab.name, "tab{d}", .{id}) catch unreachable;
|
|
tab.name_len = printed.len;
|
|
|
|
// ---- sidebar row ---------------------------------------------------
|
|
const row_box = gtk.Box.new(.horizontal, 6);
|
|
row_box.as(gtk.Widget).addCssClass("vtabs-row");
|
|
|
|
const icon = gtk.Image.newFromIconName("utilities-terminal-symbolic");
|
|
row_box.append(icon.as(gtk.Widget));
|
|
|
|
tab.label.setXalign(0);
|
|
tab.label.setEllipsize(.end);
|
|
tab.label.as(gtk.Widget).setHexpand(1);
|
|
row_box.append(tab.label.as(gtk.Widget));
|
|
|
|
const close = gtk.Button.newFromIconName("window-close-symbolic");
|
|
close.as(gtk.Widget).addCssClass("flat");
|
|
close.as(gtk.Widget).addCssClass("vtabs-close");
|
|
_ = gtk.Button.signals.clicked.connect(close, *Tab, &onCloseClicked, tab, .{});
|
|
row_box.append(close.as(gtk.Widget));
|
|
|
|
tab.row.setChild(row_box.as(gtk.Widget));
|
|
self.list.append(tab.row.as(gtk.Widget));
|
|
|
|
_ = self.stack.addNamed(view.widget(), tab.pageName());
|
|
|
|
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 terminal.
|
|
try view.addPane();
|
|
|
|
self.refreshLabel(tab);
|
|
self.select(tab);
|
|
}
|
|
|
|
/// Make `tab` the visible one.
|
|
fn select(self: *Window, tab: *Tab) void {
|
|
self.updating = true;
|
|
defer self.updating = false;
|
|
|
|
self.stack.setVisibleChildName(tab.pageName());
|
|
self.list.selectRow(tab.row);
|
|
tab.view.focus();
|
|
}
|
|
|
|
fn indexOf(self: *Window, tab: *Tab) ?usize {
|
|
for (self.tabs.items, 0..) |t, i| if (t == tab) return i;
|
|
return null;
|
|
}
|
|
|
|
/// Close a tab, and the window along with it if it was the last one.
|
|
fn closeTab(self: *Window, tab: *Tab) void {
|
|
if (self.closing) return;
|
|
const index = self.indexOf(tab) orelse return;
|
|
|
|
self.stack.remove(tab.view.widget());
|
|
self.list.remove(tab.row.as(gtk.Widget));
|
|
_ = self.tabs.orderedRemove(index);
|
|
|
|
tab.view.destroy();
|
|
self.alloc.destroy(tab);
|
|
|
|
if (self.tabs.items.len == 0) {
|
|
// Teardown of our own state happens in onDestroy.
|
|
self.window.as(gtk.Window).close();
|
|
return;
|
|
}
|
|
|
|
// Prefer the tab that took the closed one's place, else the new last.
|
|
const next = @min(index, self.tabs.items.len - 1);
|
|
self.select(self.tabs.items[next]);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Signal handlers
|
|
|
|
fn onNewTabClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
|
|
self.newTab() catch |err| {
|
|
std.log.err("failed to open tab: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
|
|
fn onCloseClicked(_: *gtk.Button, tab: *Tab) callconv(.c) void {
|
|
tab.window.closeTab(tab);
|
|
}
|
|
|
|
fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv(.c) void {
|
|
if (self.updating) return;
|
|
const selected = row orelse return;
|
|
for (self.tabs.items) |tab| {
|
|
if (tab.row == selected) {
|
|
self.select(tab);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Refresh a sidebar row from its view's current state.
|
|
fn refreshLabel(self: *Window, tab: *Tab) void {
|
|
_ = self;
|
|
|
|
// GTK needs a NUL-terminated string, and titles come from the terminal so
|
|
// they can be any length; clamp to what a sidebar row can show.
|
|
var scratch: [192]u8 = undefined;
|
|
const text = tab.view.label(scratch[0 .. scratch.len - 1]);
|
|
|
|
var buf: [192]u8 = undefined;
|
|
@memcpy(buf[0..text.len], text);
|
|
buf[text.len] = 0;
|
|
|
|
tab.label.setText(buf[0..text.len :0]);
|
|
tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
|
|
}
|
|
|
|
fn onViewTitle(ctx: ?*anyopaque) void {
|
|
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
|
tab.window.refreshLabel(tab);
|
|
}
|
|
|
|
/// The view lost its last pane, so the tab goes with it.
|
|
fn onViewEmpty(ctx: ?*anyopaque) void {
|
|
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
|
tab.window.closeTab(tab);
|
|
}
|
|
|
|
/// GTK has finished with the window: release everything we allocated.
|
|
fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
|
|
if (self.closing) return;
|
|
self.closing = true;
|
|
|
|
// Each terminal owns a session, which owns a PTY and its child process.
|
|
// Dropping them here reaps the children rather than orphaning them.
|
|
for (self.tabs.items) |tab| {
|
|
tab.view.destroy();
|
|
self.alloc.destroy(tab);
|
|
}
|
|
self.tabs.deinit(self.alloc);
|
|
self.alloc.destroy(self);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Shortcuts
|
|
|
|
/// The tab whose terminal is currently visible.
|
|
fn activeTab(self: *Window) ?*Tab {
|
|
const name = self.stack.getVisibleChildName() orelse return null;
|
|
const span = std.mem.span(name);
|
|
for (self.tabs.items) |tab| {
|
|
if (std.mem.eql(u8, tab.pageName(), span)) return tab;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn selectIndex(self: *Window, index: usize) void {
|
|
if (index >= self.tabs.items.len) return;
|
|
self.select(self.tabs.items[index]);
|
|
}
|
|
|
|
/// Move the selection by `delta`, wrapping around the ends.
|
|
fn cycle(self: *Window, delta: isize) void {
|
|
if (self.tabs.items.len == 0) return;
|
|
const current = self.indexOf(self.activeTab() orelse return) orelse return;
|
|
const len: isize = @intCast(self.tabs.items.len);
|
|
const next = @mod(@as(isize, @intCast(current)) + delta + len, len);
|
|
self.selectIndex(@intCast(next));
|
|
}
|
|
|
|
fn onShortcut(
|
|
_: *gtk.EventControllerKey,
|
|
keyval: c_uint,
|
|
_: c_uint,
|
|
state: gdk.ModifierType,
|
|
self: *Window,
|
|
) callconv(.c) c_int {
|
|
const ctrl = state.control_mask;
|
|
const shift = state.shift_mask;
|
|
const alt = state.alt_mask;
|
|
|
|
if (ctrl and shift) {
|
|
switch (keyval) {
|
|
gdk.KEY_T, gdk.KEY_t => {
|
|
self.newTab() catch |err| {
|
|
std.log.err("failed to open tab: {s}", .{@errorName(err)});
|
|
};
|
|
return 1;
|
|
},
|
|
gdk.KEY_W, gdk.KEY_w => {
|
|
// Closes the focused terminal. The view raises on_empty when
|
|
// its last pane goes, which is what closes the tab.
|
|
if (self.activeTab()) |tab| {
|
|
if (tab.view.focusedPane()) |pane| tab.view.closePane(pane);
|
|
}
|
|
return 1;
|
|
},
|
|
gdk.KEY_E, gdk.KEY_e => {
|
|
if (self.activeTab()) |tab| {
|
|
tab.view.addPane() catch |err| {
|
|
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
return 1;
|
|
},
|
|
gdk.KEY_V, gdk.KEY_v => {
|
|
self.paste();
|
|
return 1;
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// Ctrl+Shift+arrows rearrange the focused terminal within its view. This
|
|
// is the keyboard route to the same rearranging that dragging a pane's
|
|
// header does.
|
|
if (ctrl and shift) {
|
|
const side: ?View.Side = switch (keyval) {
|
|
gdk.KEY_Left => .left,
|
|
gdk.KEY_Right => .right,
|
|
gdk.KEY_Up => .top,
|
|
gdk.KEY_Down => .bottom,
|
|
else => null,
|
|
};
|
|
if (side) |s| {
|
|
if (self.activeTab()) |tab| tab.view.moveFocused(s);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals.
|
|
if (ctrl and !shift) {
|
|
switch (keyval) {
|
|
gdk.KEY_Page_Up => {
|
|
self.cycle(-1);
|
|
return 1;
|
|
},
|
|
gdk.KEY_Page_Down => {
|
|
self.cycle(1);
|
|
return 1;
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// Alt+1..9 jumps straight to a tab; Alt+9 is "last tab" by convention.
|
|
if (alt and !ctrl) {
|
|
if (keyval >= gdk.KEY_1 and keyval <= gdk.KEY_9) {
|
|
const n = keyval - gdk.KEY_1;
|
|
if (n == 8) {
|
|
self.selectIndex(self.tabs.items.len -| 1);
|
|
} else {
|
|
self.selectIndex(@intCast(n));
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Paste
|
|
//
|
|
// GTK4's clipboard API is asynchronous, so the read completes on a later
|
|
// main loop turn. We resolve the destination tab at completion time rather
|
|
// than capturing it, so closing a tab mid-paste can't leave a dangling
|
|
// pointer.
|
|
|
|
fn paste(self: *Window) void {
|
|
const clipboard = self.window.as(gtk.Widget).getClipboard();
|
|
clipboard.readTextAsync(null, &onPasteReady, self);
|
|
}
|
|
|
|
fn onPasteReady(
|
|
source: ?*gobject.Object,
|
|
result: *gio.AsyncResult,
|
|
data: ?*anyopaque,
|
|
) callconv(.c) void {
|
|
const self: *Window = @ptrCast(@alignCast(data.?));
|
|
const clipboard: *gdk.Clipboard = @ptrCast(@alignCast(source.?));
|
|
|
|
var err: ?*glib.Error = null;
|
|
const text = clipboard.readTextFinish(result, &err) orelse {
|
|
if (err) |e| {
|
|
std.log.warn("paste failed: {s}", .{e.f_message orelse "unknown"});
|
|
e.free();
|
|
}
|
|
return;
|
|
};
|
|
defer glib.free(text);
|
|
|
|
const tab = self.activeTab() orelse return;
|
|
const terminal = tab.view.focusedTerminal() orelse return;
|
|
const session = terminal.session;
|
|
// Coerce to a plain slice: encodePaste dispatches on the exact type.
|
|
const span: []const u8 = std.mem.span(text);
|
|
|
|
// Refuse pastes containing control characters that would execute on
|
|
// arrival (a newline in unbracketed mode runs the command immediately).
|
|
const opts: vt.input.PasteOptions = .fromTerminal(&session.term);
|
|
if (!vt.input.isSafePaste(span)) {
|
|
std.log.warn("refusing unsafe paste", .{});
|
|
return;
|
|
}
|
|
|
|
const parts = vt.input.encodePaste(span, opts) catch |e| {
|
|
std.log.warn("paste encode failed: {s}", .{@errorName(e)});
|
|
return;
|
|
};
|
|
for (parts) |part| session.write(part);
|
|
}
|