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:
+74
-27
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user