Multiple terminals per tab, rearrangeable by drag or keyboard

A tab is now a view holding an ordered list of panes rather than a single
terminal. New panes open side by side; moving one against a top or bottom
edge restacks the view, and against a side edge returns it to a row.

Layout is a flat list with one orientation per view rather than a split
tree. That covers a shell beside an agent without the structure a tree
needs, and it can be replaced once mixed layouts actually matter.

Panes live in a GtkBox and are reordered in place, so rearranging never
unparents a terminal and running processes and scrollback survive the move.

Dragging a pane uses its header as the handle so it never competes with the
terminal's own mouse handling, and drops go through the same moveRelative
path as the Ctrl+Shift+arrow shortcuts.

Also fixes an ordering bug this surfaced: View.create used to add its first
pane immediately, firing the title callback into a Tab that had not been
initialized yet. The view is now created empty and the caller adds the pane
once it has finished wiring itself up.
This commit is contained in:
Greyson Parrelli
2026-08-11 10:59:21 -04:00
parent 74bc9e8fd3
commit 5b00c98e03
6 changed files with 768 additions and 63 deletions
+74 -27
View File
@@ -14,7 +14,8 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
const vt = @import("ghostty-vt");
const Terminal = @import("Terminal.zig");
const Pane = @import("Pane.zig");
const View = @import("View.zig");
const Window = @This();
@@ -42,10 +43,11 @@ updating: bool = false,
/// doesn't try to close a tab we're already destroying.
closing: bool = false,
/// A single tab: the terminal plus the sidebar row that selects it.
/// A single tab: a view of one or more terminals, plus the sidebar row that
/// selects it.
const Tab = struct {
window: *Window,
term: *Terminal,
view: *View,
row: *gtk.ListBoxRow,
label: *gtk.Label,
name: [16]u8,
@@ -161,7 +163,7 @@ pub fn present(self: *Window) void {
// 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.term.grabFocus();
if (self.activeTab()) |tab| tab.view.focus();
}
/// Open a new tab and switch to it.
@@ -169,19 +171,19 @@ pub fn newTab(self: *Window) !void {
const tab = try self.alloc.create(Tab);
errdefer self.alloc.destroy(tab);
const term = try Terminal.create(self.alloc, .{
.on_title = &onTabTitle,
.on_exit = &onTabExit,
const view = try View.create(self.alloc, .{
.on_empty = &onViewEmpty,
.on_title = &onViewTitle,
.ctx = tab,
});
errdefer term.destroy();
errdefer view.destroy();
const id = self.next_id;
self.next_id += 1;
tab.* = .{
.window = self,
.term = term,
.view = view,
.row = gtk.ListBoxRow.new(),
.label = gtk.Label.new("shell"),
.name = undefined,
@@ -211,9 +213,15 @@ pub fn newTab(self: *Window) !void {
tab.row.setChild(row_box.as(gtk.Widget));
self.list.append(tab.row.as(gtk.Widget));
_ = self.stack.addNamed(term.widget(), tab.pageName());
_ = 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);
}
@@ -224,7 +232,7 @@ fn select(self: *Window, tab: *Tab) void {
self.stack.setVisibleChildName(tab.pageName());
self.list.selectRow(tab.row);
tab.term.grabFocus();
tab.view.focus();
}
fn indexOf(self: *Window, tab: *Tab) ?usize {
@@ -237,11 +245,11 @@ fn closeTab(self: *Window, tab: *Tab) void {
if (self.closing) return;
const index = self.indexOf(tab) orelse return;
self.stack.remove(tab.term.widget());
self.stack.remove(tab.view.widget());
self.list.remove(tab.row.as(gtk.Widget));
_ = self.tabs.orderedRemove(index);
tab.term.destroy();
tab.view.destroy();
self.alloc.destroy(tab);
if (self.tabs.items.len == 0) {
@@ -279,21 +287,30 @@ fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv
}
}
fn onTabTitle(ctx: ?*anyopaque, title: []const u8) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
/// 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 from the terminal are
// arbitrary length, so clamp to something a sidebar row can show.
var buf: [128]u8 = undefined;
const n = @min(title.len, buf.len - 1);
@memcpy(buf[0..n], title[0..n]);
buf[n] = 0;
// 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]);
tab.label.setText(buf[0..n :0]);
tab.label.as(gtk.Widget).setTooltipText(buf[0..n :0]);
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 onTabExit(ctx: ?*anyopaque) void {
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);
}
@@ -306,7 +323,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
// 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.term.destroy();
tab.view.destroy();
self.alloc.destroy(tab);
}
self.tabs.deinit(self.alloc);
@@ -360,7 +377,19 @@ fn onShortcut(
return 1;
},
gdk.KEY_W, gdk.KEY_w => {
if (self.activeTab()) |tab| self.closeTab(tab);
// 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 => {
@@ -371,6 +400,23 @@ fn onShortcut(
}
}
// 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 zone: ?Pane.Zone = switch (keyval) {
gdk.KEY_Left => .left,
gdk.KEY_Right => .right,
gdk.KEY_Up => .top,
gdk.KEY_Down => .bottom,
else => null,
};
if (zone) |z| {
if (self.activeTab()) |tab| tab.view.moveFocused(z);
return 1;
}
}
// Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals.
if (ctrl and !shift) {
switch (keyval) {
@@ -434,7 +480,8 @@ fn onPasteReady(
defer glib.free(text);
const tab = self.activeTab() orelse return;
const session = tab.term.session;
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);