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
+28 -29
View File
@@ -9,6 +9,7 @@
const std = @import("std");
const cairo = @import("cairo");
const gdk = @import("gdk");
const gobject = @import("gobject");
const gtk = @import("gtk");
const pango = @import("pango");
const pangocairo = @import("pangocairo");
@@ -29,9 +30,6 @@ const font_spec = "monospace 11";
/// Padding between the grid and the widget edge, in pixels.
const pad: f64 = 8;
/// Corner rounding of the terminal pane.
const corner_radius: f64 = 10;
alloc: std.mem.Allocator,
session: *Session,
@@ -49,6 +47,10 @@ run_buf: std.ArrayListUnmanaged(u8) = .empty,
/// Called when the session's title changes, so the owner can retitle the tab.
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *const fn (ctx: ?*anyopaque) void,
/// Called when this terminal takes keyboard focus.
on_focus: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub fn create(
@@ -56,6 +58,7 @@ pub fn create(
cbs: struct {
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *const fn (ctx: ?*anyopaque) void,
on_focus: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
},
) !*Terminal {
@@ -72,6 +75,7 @@ pub fn create(
.font = font,
.on_title = cbs.on_title,
.on_exit = cbs.on_exit,
.on_focus = cbs.on_focus,
.ctx = cbs.ctx,
};
@@ -129,6 +133,16 @@ pub fn create(
);
w.addController(click.as(gtk.EventController));
// Watching the property rather than just the click gesture means focus
// taken programmatically or by keyboard navigation is reported too.
_ = gobject.Object.signals.notify.connect(
area,
*Terminal,
&onNotifyHasFocus,
self,
.{ .detail = "has-focus" },
);
return self;
}
@@ -175,6 +189,14 @@ fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void {
self.on_title(self.ctx, title);
}
fn onNotifyHasFocus(
_: *gtk.DrawingArea,
_: *gobject.ParamSpec,
self: *Terminal,
) callconv(.c) void {
if (self.widget().hasFocus() != 0) self.on_focus(self.ctx);
}
fn onSessionExit(ctx: ?*anyopaque) void {
const self: *Terminal = @ptrCast(@alignCast(ctx.?));
self.on_exit(self.ctx);
@@ -313,27 +335,17 @@ fn drawFunc(
};
}
fn render(self: *Terminal, cr: *cairo.Context, width: c_int, height: c_int) !void {
fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void {
const term = &self.session.term;
const screen = term.screens.active;
// Background. Clipped to a rounded rectangle so the terminal reads as an
// inset pane next to the sidebar, the way Zen insets web content.
// Background. The pane frame around us clips to its own rounded corners,
// so this just fills.
const default_bg: theme.Rgb = if (term.colors.background.get()) |c|
.from(c)
else
theme.bg;
{
roundedRect(
cr,
0,
0,
@floatFromInt(width),
@floatFromInt(height),
corner_radius,
);
cr.clip();
const r, const g, const b = default_bg.cairoRgb();
cr.setSourceRgb(r, g, b);
cr.paint();
@@ -547,19 +559,6 @@ fn drawCursor(
}
}
/// Trace a rounded rectangle as the current path.
fn roundedRect(cr: *cairo.Context, x: f64, y: f64, w: f64, h: f64, r: f64) void {
const radius = @min(r, @min(w, h) / 2);
const pi = std.math.pi;
cr.newSubPath();
cr.arc(x + w - radius, y + radius, radius, -pi / 2.0, 0);
cr.arc(x + w - radius, y + h - radius, radius, 0, pi / 2.0);
cr.arc(x + radius, y + h - radius, radius, pi / 2.0, pi);
cr.arc(x + radius, y + radius, radius, pi, 3.0 * pi / 2.0);
cr.closePath();
}
/// Resolve a cell's style into concrete colors and attributes.
fn appearance(
pin: vt.Pin,