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
+263
View File
@@ -0,0 +1,263 @@
//! A view: the content of one tab, holding one or more terminal panes.
//!
//! Layout is deliberately flat. Panes are an ordered list sharing a single
//! orientation for the whole view, rather than a recursive split tree. New
//! panes are added side by side; dropping a pane on another pane's top or
//! bottom edge restacks the view, and dropping on a side edge puts it back
//! into a row.
//!
//! A flat list can't express mixed layouts (two panes beside a stack of
//! three). A split tree can, and is where this goes if that turns out to
//! matter, but the tree brings a lot of structure that isn't earning its keep
//! for "a shell and an agent in the same worktree".
const std = @import("std");
const gtk = @import("gtk");
const Pane = @import("Pane.zig");
const View = @This();
alloc: std.mem.Allocator,
/// The container holding the panes. Its orientation is the view's layout:
/// `.horizontal` lays panes out left to right, `.vertical` stacks them.
box: *gtk.Box,
panes: std.ArrayListUnmanaged(*Pane) = .empty,
/// The pane that last had keyboard focus, used for "act on the current
/// terminal" operations and for the tab's label.
focused: ?*Pane = null,
/// The pane currently being dragged, if any. Drags never leave the process,
/// so the pointer lives here instead of being marshalled through GTK.
dragging: ?*Pane = null,
/// Set while the view is being torn down, so a pane's child exiting doesn't
/// try to remove it from a list we're already draining.
closing: bool = false,
/// Raised when the last pane goes away and the tab should close with it.
on_empty: *const fn (ctx: ?*anyopaque) void,
/// Raised when the text a tab should display has changed.
on_title: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub const Callbacks = struct {
on_empty: *const fn (ctx: ?*anyopaque) void,
on_title: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
const self = try alloc.create(View);
errdefer alloc.destroy(self);
self.* = .{
.alloc = alloc,
// Side by side is the default: a shell next to an agent is the case
// this exists for, and side-by-side keeps both prompts visible.
.box = gtk.Box.new(.horizontal, 6),
.on_empty = cbs.on_empty,
.on_title = cbs.on_title,
.ctx = cbs.ctx,
};
self.box.as(gtk.Widget).addCssClass("vtabs-view");
self.box.as(gtk.Widget).setHexpand(1);
self.box.as(gtk.Widget).setVexpand(1);
// Deliberately created empty. Adding a pane fires on_title, and the
// caller's context object is not usable until it has finished wiring
// itself to this view.
return self;
}
pub fn destroy(self: *View) void {
self.closing = true;
for (self.panes.items) |pane| pane.destroy();
self.panes.deinit(self.alloc);
self.alloc.destroy(self);
}
pub fn widget(self: *View) *gtk.Widget {
return self.box.as(gtk.Widget);
}
/// Text for the tab label: the focused terminal's title, prefixed with the
/// pane count once there is more than one so the sidebar shows the shape of
/// the view at a glance.
pub fn label(self: *View, buf: []u8) []const u8 {
const pane = self.focused orelse (if (self.panes.items.len > 0)
self.panes.items[0]
else
return "");
const title = pane.titleSlice();
if (self.panes.items.len < 2) {
const n = @min(title.len, buf.len);
@memcpy(buf[0..n], title[0..n]);
return buf[0..n];
}
return std.fmt.bufPrint(buf, "{d} · {s}", .{ self.panes.items.len, title }) catch title;
}
/// The terminal that input-level actions (paste, close) should act on.
pub fn focusedTerminal(self: *View) ?*@import("Terminal.zig") {
const pane = self.focused orelse (if (self.panes.items.len > 0)
self.panes.items[0]
else
return null);
return pane.term;
}
/// The pane that pane-level actions should act on.
pub fn focusedPane(self: *View) ?*Pane {
return self.focused orelse (if (self.panes.items.len > 0)
self.panes.items[0]
else
null);
}
pub fn focus(self: *View) void {
const pane = self.focused orelse (if (self.panes.items.len > 0)
self.panes.items[0]
else
return);
pane.grabFocus();
}
/// Add a terminal, placed just after the focused pane so a split appears
/// next to the terminal you were working in rather than at the far end.
pub fn addPane(self: *View) !void {
const pane = try Pane.create(self.alloc, self);
errdefer pane.destroy();
const index = if (self.focused) |current|
(self.indexOf(current) orelse self.panes.items.len -| 1) + 1
else
self.panes.items.len;
try self.panes.insert(self.alloc, index, pane);
errdefer _ = self.panes.orderedRemove(index);
self.box.append(pane.widget());
self.syncOrder();
self.setFocused(pane);
pane.grabFocus();
self.on_title(self.ctx);
}
pub fn closePane(self: *View, pane: *Pane) void {
if (self.closing) return;
const index = self.indexOf(pane) orelse return;
if (self.dragging == pane) self.dragging = null;
self.box.remove(pane.widget());
_ = self.panes.orderedRemove(index);
pane.destroy();
if (self.focused == pane) self.focused = null;
if (self.panes.items.len == 0) {
self.on_empty(self.ctx);
return;
}
// Focus whatever took its place, else the new last pane.
const next = @min(index, self.panes.items.len - 1);
self.setFocused(self.panes.items[next]);
self.panes.items[next].grabFocus();
self.on_title(self.ctx);
}
pub fn setFocused(self: *View, pane: *Pane) void {
if (self.focused == pane) return;
if (self.focused) |old| old.setActive(false);
self.focused = pane;
pane.setActive(true);
self.on_title(self.ctx);
}
pub fn paneTitleChanged(self: *View, pane: *Pane) void {
// Only the pane shown in the tab label matters to the sidebar.
if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx);
}
/// Move the focused pane one place in the given direction.
///
/// This is the keyboard equivalent of dragging a pane, and deliberately goes
/// through the same `moveRelative` path so both routes rearrange the view
/// identically. As with a drop, the direction also sets the orientation, so
/// pushing a pane down restacks the view even when it is already last.
pub fn moveFocused(self: *View, zone: Pane.Zone) void {
const pane = self.focusedPane() orelse return;
const index = self.indexOf(pane) orelse return;
const neighbor: ?*Pane = if (zone.isBefore())
(if (index > 0) self.panes.items[index - 1] else null)
else
(if (index + 1 < self.panes.items.len) self.panes.items[index + 1] else null);
if (neighbor) |target| {
self.moveRelative(pane, target, zone);
} else {
// Already at that end: there is nothing to swap with, but the
// requested direction still describes how the view should be laid out.
self.setOrientation(zone.orientation());
}
}
/// Move `moving` next to `target`, on the side named by `zone`. The zone also
/// decides the view's orientation, which is what makes dragging a pane to the
/// bottom edge restack the whole view.
pub fn moveRelative(self: *View, moving: *Pane, target: *Pane, zone: Pane.Zone) void {
if (moving == target) return;
const from = self.indexOf(moving) orelse return;
_ = self.panes.orderedRemove(from);
// Recompute after the removal: taking `moving` out may have shifted it.
const target_index = self.indexOf(target) orelse {
// Should not happen, but rather than lose the pane, put it back.
self.panes.insert(self.alloc, from, moving) catch {};
return;
};
const to = if (zone.isBefore()) target_index else target_index + 1;
self.panes.insert(self.alloc, to, moving) catch {
self.panes.insert(self.alloc, from, moving) catch {};
return;
};
self.setOrientation(zone.orientation());
self.syncOrder();
}
fn setOrientation(self: *View, orientation: gtk.Orientation) void {
self.box.as(gtk.Orientable).setOrientation(orientation);
}
/// Reorder the box's children to match `panes`. GtkBox can reorder in place,
/// so panes never get unparented and their terminals keep running untouched.
fn syncOrder(self: *View) void {
var previous: ?*gtk.Widget = null;
for (self.panes.items) |pane| {
self.box.reorderChildAfter(pane.widget(), previous);
previous = pane.widget();
}
}
pub fn clearHints(self: *View) void {
for (self.panes.items) |pane| pane.showHint(null);
}
fn indexOf(self: *View, pane: *Pane) ?usize {
for (self.panes.items, 0..) |p, i| if (p == pane) return i;
return null;
}