Split tree layouts, resizable dividers, and live drag preview

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.
This commit is contained in:
Greyson Parrelli
2026-08-11 11:25:44 -04:00
parent 5b00c98e03
commit 918ccec6e1
6 changed files with 656 additions and 212 deletions
+32 -18
View File
@@ -78,7 +78,8 @@ just the VT core.
``` ```
main.zig AdwApplication, CSS loading main.zig AdwApplication, CSS loading
Window.zig sidebar + GtkStack of views, tab management, shortcuts Window.zig sidebar + GtkStack of views, tab management, shortcuts
View.zig one tab's content: an ordered list of panes + their layout View.zig one tab's content: its panes, their layout, and drag handling
Layout.zig the split tree: nodes, rearranging, GtkPaned materialization
Pane.zig a terminal plus its header, drag source, and drop target Pane.zig a terminal plus its header, drag source, and drop target
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
Session.zig libghostty-vt Terminal + parser, fed by the PTY Session.zig libghostty-vt Terminal + parser, fed by the PTY
@@ -87,19 +88,32 @@ key.zig GDK keyval -> libghostty-vt key mapping
theme.zig colors libghostty-vt has no opinion about theme.zig colors libghostty-vt has no opinion about
``` ```
A tab is a **view**, and a view holds one or more terminal **panes**. New panes A tab is a **view**, and a view holds one or more terminal **panes** arranged
open side by side; moving a pane to a top or bottom edge restacks the view, and in a **binary split tree**: every interior node is a split with an orientation,
moving it to a side edge puts it back in a row. every leaf is a pane. That is what allows the layouts a single shared
orientation can't express — three panes in a row, drag the rightmost to the
bottom, and you get two on top with one spanning the full width beneath them.
**Layout is a flat list, not a split tree.** A view has one orientation shared Each split node owns a `GtkPaned`, so dividers are draggable and every split
by all its panes, so it can express "three side by side" or "three stacked" but remembers its position as a **ratio** rather than a pixel count. The ratio is
not "two beside a stack of three". A tree would handle the general case and is the source of truth: it is re-applied whenever the available space changes, so
where this goes if mixed layouts turn out to matter; it just brings a lot of proportions survive window resizes, and only user drags update it.
structure that a shell-next-to-an-agent view doesn't need yet.
Panes are held in a `GtkBox`, which can reorder its children in place. Nothing Where a drop lands depends on how close it is to the view's own border. Near an
is ever unparented, so rearranging a view never disturbs the running edge of the window the pane is placed against the whole layout and spans it;
terminals — their scrollback and processes carry straight through the move. anywhere else it splits just the pane under the pointer. That single rule gives
both "put this across the bottom" and "split this one in half".
Panes and split nodes each hold a strong reference to their own widget, so
detaching and reattaching during a rearrangement never finalizes anything.
Running terminals keep their scrollback and processes straight through a move.
**Drags rearrange live.** Each time the drop target changes, the move is
applied for real, so the layout under the cursor is always the layout you will
get — there is no separate drop indicator because the view itself is the
preview. Cancelling a drag puts the pane back: only the dragged pane ever
moves, so the rest of the tree is unchanged and re-inserting it beside its
original sibling restores the original shape.
Two design choices worth calling out: Two design choices worth calling out:
@@ -127,8 +141,9 @@ in principle, but a terminal grid is small.
- Window title (OSC 0/2) becomes the pane header and tab label; the tab shows - Window title (OSC 0/2) becomes the pane header and tab label; the tab shows
its pane count once a view holds more than one its pane count once a view holds more than one
- Tabs: create, close, switch; closing the last one closes the window - Tabs: create, close, switch; closing the last one closes the window
- Multiple terminals per tab, rearranged by keyboard or by dragging a pane's - Multiple terminals per tab in an arbitrary split tree, rearranged by keyboard
header; closing the last pane in a view closes its tab or by dragging a pane's header, with draggable dividers between them;
closing the last pane in a view closes its tab
### Shortcuts ### Shortcuts
@@ -157,10 +172,9 @@ This is a proof of concept, and the following are deliberately absent:
- **Ligatures and complex shaping.** Each run is drawn independently at a - **Ligatures and complex shaping.** Each run is drawn independently at a
fixed grid offset, so text that needs shaping across cell boundaries won't fixed grid offset, so text that needs shaping across cell boundaries won't
look right. look right.
- **Resizable splits.** Panes in a view divide the space evenly; there are no - **Moving panes between tabs.** Panes can only be rearranged within their
draggable dividers. Moving to `GtkPaned` would add them. own view.
- **Mixed layouts and moving panes between tabs.** See the flat-list note - **Saved layouts.** A view's arrangement lives only as long as the tab.
above; panes can only be rearranged within their own view.
- **Kitty graphics, hyperlinks, tab reordering, config file.** - **Kitty graphics, hyperlinks, tab reordering, config file.**
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than - **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
`ghostty`, since we don't install a terminfo entry. `ghostty`, since we don't install a terminfo entry.
+296
View File
@@ -0,0 +1,296 @@
//! The split tree behind a view.
//!
//! A view's panes form a binary tree: every interior node is a split with an
//! orientation and two children, and every leaf is a pane. That is what lets
//! a view hold layouts a flat list can't express — three panes in a row where
//! one is then dragged to the bottom becomes a vertical split whose top half
//! is the remaining row, so the moved pane spans the full width beneath them.
//!
//! Each split node owns a `GtkPaned`, which gives draggable dividers for
//! free. Split nodes hold a strong reference to their paned and panes hold one
//! to their own widget, so detaching during a rearrangement never destroys
//! anything — widgets are only finalized when their node is.
const std = @import("std");
const gobject = @import("gobject");
const gtk = @import("gtk");
const Pane = @import("Pane.zig");
const Layout = @This();
/// Which side of a target a pane is placed on. The side also determines the
/// orientation of the split created to hold them.
pub const Side = enum {
left,
right,
top,
bottom,
pub fn orientation(self: Side) gtk.Orientation {
return switch (self) {
.left, .right => .horizontal,
.top, .bottom => .vertical,
};
}
/// True if the placed pane comes first in the new split.
pub fn isBefore(self: Side) bool {
return self == .left or self == .top;
}
};
pub const Node = struct {
parent: ?*Node = null,
kind: union(enum) {
leaf: *Pane,
split: Split,
},
pub fn widget(self: *Node) *gtk.Widget {
return switch (self.kind) {
.leaf => |pane| pane.widget(),
.split => |s| s.paned.as(gtk.Widget),
};
}
};
pub const Split = struct {
paned: *gtk.Paned,
a: *Node,
b: *Node,
/// Divider position as a fraction of the available space. Kept as a ratio
/// rather than pixels so it survives window resizes and rebuilds.
ratio: f64 = 0.5,
/// Set while we are pushing the ratio into the paned, so the resulting
/// position change isn't mistaken for the user dragging the divider.
applying: bool = false,
};
alloc: std.mem.Allocator,
root: ?*Node = null,
pub fn deinit(self: *Layout) void {
if (self.root) |root| self.destroyTree(root);
self.root = null;
}
/// Free a subtree's nodes. Panes are owned by the view, not by the tree, so
/// leaves are dropped without touching their pane.
fn destroyTree(self: *Layout, node: *Node) void {
switch (node.kind) {
.leaf => {},
.split => |s| {
self.destroyTree(s.a);
self.destroyTree(s.b);
s.paned.setStartChild(null);
s.paned.setEndChild(null);
s.paned.as(gobject.Object).unref();
},
}
self.alloc.destroy(node);
}
pub fn newLeaf(self: *Layout, pane: *Pane) !*Node {
const node = try self.alloc.create(Node);
node.* = .{ .kind = .{ .leaf = pane } };
return node;
}
/// Find the leaf holding `pane`.
pub fn find(self: *Layout, pane: *Pane) ?*Node {
return findIn(self.root orelse return null, pane);
}
fn findIn(node: *Node, pane: *Pane) ?*Node {
return switch (node.kind) {
.leaf => |p| if (p == pane) node else null,
.split => |s| findIn(s.a, pane) orelse findIn(s.b, pane),
};
}
/// Place `node` beside `target`, on the given side, by replacing `target`
/// with a new split holding both.
pub fn insert(self: *Layout, node: *Node, target: *Node, side: Side, ratio: f64) !void {
// Captured before the new split claims target as a child.
const grandparent = target.parent;
const first = if (side.isBefore()) node else target;
const second = if (side.isBefore()) target else node;
const split = try self.makeSplit(side.orientation(), first, second, ratio);
split.parent = grandparent;
if (grandparent) |g| {
const s = &g.kind.split;
if (s.a == target) s.a = split else s.b = split;
} else {
self.root = split;
}
}
/// Detach `node` from the tree. Its parent split collapses, so the sibling
/// takes the parent's place and the layout never keeps a split with one child.
pub fn remove(self: *Layout, node: *Node) void {
const parent = node.parent orelse {
// Removing the only pane empties the tree.
self.root = null;
return;
};
const s = &parent.kind.split;
const sibling = if (s.a == node) s.b else s.a;
sibling.parent = parent.parent;
if (parent.parent) |g| {
const gs = &g.kind.split;
if (gs.a == parent) gs.a = sibling else gs.b = sibling;
} else {
self.root = sibling;
}
node.parent = null;
s.paned.setStartChild(null);
s.paned.setEndChild(null);
s.paned.as(gobject.Object).unref();
self.alloc.destroy(parent);
}
fn makeSplit(
self: *Layout,
orientation: gtk.Orientation,
a: *Node,
b: *Node,
ratio: f64,
) !*Node {
const node = try self.alloc.create(Node);
errdefer self.alloc.destroy(node);
const paned = gtk.Paned.new(orientation);
// Take ownership of the floating reference so the paned survives being
// unparented while the tree is reassembled.
_ = paned.as(gobject.Object).refSink();
// Both sides share newly available space, and neither may be squeezed to
// nothing, so a terminal can't be collapsed out of existence by a resize.
paned.setResizeStartChild(1);
paned.setResizeEndChild(1);
paned.setShrinkStartChild(0);
paned.setShrinkEndChild(0);
paned.setWideHandle(1);
node.* = .{ .kind = .{ .split = .{
.paned = paned,
.a = a,
.b = b,
.ratio = ratio,
} } };
a.parent = node;
b.parent = node;
_ = gobject.Object.signals.notify.connect(
paned,
*Node,
&onMaxPosition,
node,
.{ .detail = "max-position" },
);
_ = gobject.Object.signals.notify.connect(
paned,
*Node,
&onPosition,
node,
.{ .detail = "position" },
);
return node;
}
/// The space available to the paned changed, so re-derive the divider
/// position from the ratio. Doing this on every change (rather than only the
/// first) is what keeps a split's proportions across window resizes.
fn onMaxPosition(paned: *gtk.Paned, _: *gobject.ParamSpec, node: *Node) callconv(.c) void {
const s = &node.kind.split;
const max = maxPosition(paned);
if (max <= 0) return;
s.applying = true;
defer s.applying = false;
paned.setPosition(@intFromFloat(@round(s.ratio * @as(f64, @floatFromInt(max)))));
}
/// Remember where the user dragged the divider to, as a fraction. The ratio
/// is the source of truth, so only user-driven changes update it.
fn onPosition(paned: *gtk.Paned, _: *gobject.ParamSpec, node: *Node) callconv(.c) void {
const s = &node.kind.split;
if (s.applying) return;
const max = maxPosition(paned);
if (max <= 0) return;
s.ratio = @as(f64, @floatFromInt(paned.getPosition())) / @as(f64, @floatFromInt(max));
}
fn maxPosition(paned: *gtk.Paned) c_int {
var value = gobject.ext.Value.new(c_int);
defer value.unset();
paned.as(gobject.Object).getProperty("max-position", &value);
return gobject.ext.Value.get(&value, c_int);
}
/// Rebuild the widget hierarchy from the tree and mount it in `box`.
///
/// Everything is detached first so that reattaching never hits a widget that
/// still has a parent. This is cheap: a view holds a handful of panes, and
/// the terminals themselves are untouched by the reparenting.
pub fn materialize(self: *Layout, box: *gtk.Box) void {
if (box.as(gtk.Widget).getFirstChild()) |child| box.remove(child);
const root = self.root orelse return;
detach(root);
assemble(root);
box.append(root.widget());
}
fn detach(node: *Node) void {
switch (node.kind) {
.leaf => {},
.split => |s| {
detach(s.a);
detach(s.b);
s.paned.setStartChild(null);
s.paned.setEndChild(null);
},
}
}
fn assemble(node: *Node) void {
switch (node.kind) {
.leaf => {},
.split => |s| {
assemble(s.a);
assemble(s.b);
s.paned.setStartChild(s.a.widget());
s.paned.setEndChild(s.b.widget());
},
}
}
/// The side `node` sits on within its parent split, and the parent's ratio.
/// Used to put a pane back where it started when a drag is cancelled.
pub fn positionOf(node: *Node) ?struct { sibling: *Node, side: Side, ratio: f64 } {
const parent = node.parent orelse return null;
const s = parent.kind.split;
const first = s.a == node;
const vertical = s.paned.as(gtk.Orientable).getOrientation() == .vertical;
return .{
.sibling = if (first) s.b else s.a,
.side = if (vertical)
(if (first) .top else .bottom)
else
(if (first) .left else .right),
.ratio = s.ratio,
};
}
+67 -68
View File
@@ -13,43 +13,21 @@ const gdk = @import("gdk");
const gobject = @import("gobject"); const gobject = @import("gobject");
const gtk = @import("gtk"); const gtk = @import("gtk");
const Layout = @import("Layout.zig");
const Terminal = @import("Terminal.zig"); const Terminal = @import("Terminal.zig");
const View = @import("View.zig"); const View = @import("View.zig");
const Pane = @This(); const Pane = @This();
/// Where a dragged pane would land relative to the pane under the pointer. const Side = Layout.Side;
/// The edge implies the resulting orientation: dropping on a side arranges
/// panes left-to-right, dropping on top or bottom stacks them.
pub const Zone = enum {
left,
right,
top,
bottom,
fn cssClass(self: Zone) [:0]const u8 { const all_sides = [_]Side{ .left, .right, .top, .bottom };
return switch (self) {
.left => "drop-left",
.right => "drop-right",
.top => "drop-top",
.bottom => "drop-bottom",
};
}
pub fn orientation(self: Zone) gtk.Orientation { /// How close to the view's outer border a drop must be, in pixels, to place
return switch (self) { /// the pane against the whole layout instead of against the pane under the
.left, .right => .horizontal, /// pointer. This is what lets a pane dropped along the bottom span the full
.top, .bottom => .vertical, /// width beneath everything else, rather than only splitting one pane.
}; const span_margin: f64 = 48;
}
/// True if the dragged pane belongs before the pane it was dropped on.
pub fn isBefore(self: Zone) bool {
return self == .left or self == .top;
}
};
const all_zones = [_]Zone{ .left, .right, .top, .bottom };
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
view: *View, view: *View,
@@ -62,9 +40,6 @@ box: *gtk.Box,
header: *gtk.Box, header: *gtk.Box,
label: *gtk.Label, label: *gtk.Label,
/// The zone currently highlighted by a hovering drag, if any.
hint: ?Zone = null,
/// Latest terminal title, kept NUL-terminated for GTK. /// Latest terminal title, kept NUL-terminated for GTK.
title: [128:0]u8 = @splat(0), title: [128:0]u8 = @splat(0),
@@ -90,6 +65,12 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
}); });
errdefer self.term.destroy(); errdefer self.term.destroy();
// Own a strong reference to our own widget. Rearranging the view detaches
// panes from their parent before reattaching them elsewhere, and without a
// reference of our own that detach would drop the last one and finalize
// the widget out from under us.
_ = self.box.as(gobject.Object).refSink();
const box_widget = self.box.as(gtk.Widget); const box_widget = self.box.as(gtk.Widget);
box_widget.addCssClass("vtabs-pane"); box_widget.addCssClass("vtabs-pane");
box_widget.setHexpand(1); box_widget.setHexpand(1);
@@ -110,6 +91,8 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
pub fn destroy(self: *Pane) void { pub fn destroy(self: *Pane) void {
self.term.destroy(); self.term.destroy();
// Releases the reference taken in `create`, finalizing the widget tree.
self.box.as(gobject.Object).unref();
self.alloc.destroy(self); self.alloc.destroy(self);
} }
@@ -188,16 +171,14 @@ fn onDragPrepare(
self: *Pane, self: *Pane,
) callconv(.c) ?*gdk.ContentProvider { ) callconv(.c) ?*gdk.ContentProvider {
// A single-pane view has nothing to rearrange. // A single-pane view has nothing to rearrange.
if (self.view.panes.items.len < 2) return null; if (!self.view.beginDrag(self)) return null;
self.view.dragging = self;
var value = gobject.ext.Value.newFrom(@as([*:0]const u8, drag_payload)); var value = gobject.ext.Value.newFrom(@as([*:0]const u8, drag_payload));
return gdk.ContentProvider.newForValue(&value); return gdk.ContentProvider.newForValue(&value);
} }
fn onDragEnd(_: *gtk.DragSource, _: *gdk.Drag, _: c_int, self: *Pane) callconv(.c) void { fn onDragEnd(_: *gtk.DragSource, _: *gdk.Drag, _: c_int, self: *Pane) callconv(.c) void {
self.view.dragging = null; self.view.endDrag();
self.view.clearHints();
} }
fn installDropTarget(self: *Pane) void { fn installDropTarget(self: *Pane) void {
@@ -208,16 +189,21 @@ fn installDropTarget(self: *Pane) void {
self.widget().addController(target.as(gtk.EventController)); self.widget().addController(target.as(gtk.EventController));
} }
/// Pick the edge the pointer is nearest to. Nearest-edge beats fixed /// Work out where a drop at this point would put the dragged pane.
/// hit-zones here: every point in the pane maps to some edge, so there is no ///
/// dead center where a drop would do nothing. /// The side is whichever edge of this pane the pointer is nearest, so every
fn zoneAt(self: *Pane, x: f64, y: f64) Zone { /// point maps to some edge and there is no dead centre. If that point is also
/// close to the matching border of the view itself, the drop is promoted to
/// span the whole layout instead of splitting just this pane — dropping along
/// the bottom of the window gives a pane stretching across the bottom, while
/// dropping higher up still splits only the pane under the pointer.
fn targetAt(self: *Pane, x: f64, y: f64) View.Target {
const w: f64 = @floatFromInt(self.widget().getWidth()); const w: f64 = @floatFromInt(self.widget().getWidth());
const h: f64 = @floatFromInt(self.widget().getHeight()); const h: f64 = @floatFromInt(self.widget().getHeight());
if (w <= 0 or h <= 0) return .right; if (w <= 0 or h <= 0) return .{ .pane = self, .side = .right };
// Distances are normalized so a wide, short pane doesn't bias every drop // Normalized so a wide, short pane doesn't bias every drop toward its
// toward its long edges. // long edges.
const distances = [_]f64{ const distances = [_]f64{
x / w, // left x / w, // left
1.0 - x / w, // right 1.0 - x / w, // right
@@ -229,23 +215,45 @@ fn zoneAt(self: *Pane, x: f64, y: f64) Zone {
for (distances, 0..) |d, i| { for (distances, 0..) |d, i| {
if (d < distances[best]) best = i; if (d < distances[best]) best = i;
} }
return all_zones[best]; const side = all_sides[best];
return .{
.pane = if (self.atViewEdge(x, y, side)) null else self,
.side = side,
};
}
/// True if the point, expressed in view coordinates, is within the span
/// margin of the view's border on the given side.
fn atViewEdge(self: *Pane, x: f64, y: f64, side: Side) bool {
const view = self.view.widget();
var vx: f64 = 0;
var vy: f64 = 0;
if (self.widget().translateCoordinates(view, x, y, &vx, &vy) == 0) return false;
const vw: f64 = @floatFromInt(view.getWidth());
const vh: f64 = @floatFromInt(view.getHeight());
return switch (side) {
.left => vx <= span_margin,
.right => vx >= vw - span_margin,
.top => vy <= span_margin,
.bottom => vy >= vh - span_margin,
};
} }
fn onDropMotion(_: *gtk.DropTarget, x: f64, y: f64, self: *Pane) callconv(.c) gdk.DragAction { fn onDropMotion(_: *gtk.DropTarget, x: f64, y: f64, self: *Pane) callconv(.c) gdk.DragAction {
const moving = self.view.dragging orelse return .{}; const drag = self.view.drag orelse return .{};
if (moving == self) { if (drag.pane == self) return .{ .move = true };
self.showHint(null);
return .{};
}
self.showHint(self.zoneAt(x, y)); // Rearrange as the pointer moves rather than waiting for the drop, so the
// layout under the cursor is always the layout you would get.
self.view.previewDrag(self.targetAt(x, y));
return .{ .move = true }; return .{ .move = true };
} }
fn onDropLeave(_: *gtk.DropTarget, self: *Pane) callconv(.c) void { fn onDropLeave(_: *gtk.DropTarget, _: *Pane) callconv(.c) void {}
self.showHint(null);
}
fn onDrop( fn onDrop(
_: *gtk.DropTarget, _: *gtk.DropTarget,
@@ -254,24 +262,15 @@ fn onDrop(
y: f64, y: f64,
self: *Pane, self: *Pane,
) callconv(.c) c_int { ) callconv(.c) c_int {
const moving = self.view.dragging orelse return 0; const drag = self.view.drag orelse return 0;
self.view.clearHints();
if (moving == self) return 0;
self.view.moveRelative(moving, self, self.zoneAt(x, y)); // The preview has usually already applied this, but a drop without any
// intervening motion still needs the move performed.
if (drag.pane != self) self.view.previewDrag(self.targetAt(x, y));
self.view.commitDrag();
return 1; return 1;
} }
/// Show (or clear) the edge highlight that previews where a drop will land.
pub fn showHint(self: *Pane, zone: ?Zone) void {
if (self.hint) |old| {
if (zone != null and old == zone.?) return;
self.widget().removeCssClass(old.cssClass());
}
self.hint = zone;
if (zone) |z| self.widget().addCssClass(z.cssClass());
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Callbacks // Callbacks
+234 -114
View File
@@ -1,49 +1,75 @@
//! A view: the content of one tab, holding one or more terminal panes. //! A view: the content of one tab, holding one or more terminal panes
//! arranged in a split tree.
//! //!
//! Layout is deliberately flat. Panes are an ordered list sharing a single //! Dragging a pane rearranges the view live rather than on release. Each time
//! orientation for the whole view, rather than a recursive split tree. New //! the drop target changes, the move is applied for real, so what you see
//! panes are added side by side; dropping a pane on another pane's top or //! during the drag is the layout you will get. If the drag is cancelled the
//! bottom edge restacks the view, and dropping on a side edge puts it back //! pane goes back where it came from, which is possible because only the
//! into a row. //! dragged pane ever moves: the rest of the tree is unchanged by a move, so
//! //! re-inserting it next to its original sibling restores the original shape.
//! 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 std = @import("std");
const gtk = @import("gtk"); const gtk = @import("gtk");
const Layout = @import("Layout.zig");
const Pane = @import("Pane.zig"); const Pane = @import("Pane.zig");
const Terminal = @import("Terminal.zig");
const View = @This(); const View = @This();
pub const Side = Layout.Side;
/// Where a dragged pane would land.
pub const Target = struct {
/// The pane being dropped on, or null to place against the view's own
/// edge so the pane spans the full width or height of the layout.
pane: ?*Pane,
side: Side,
pub fn eql(a: Target, b: Target) bool {
return a.pane == b.pane and a.side == b.side;
}
};
/// State tracked for the duration of a pane drag.
const Drag = struct {
pane: *Pane,
/// Where the pane sat when the drag began, so a cancelled drag can put
/// it back.
origin_sibling: *Layout.Node,
origin_side: Side,
origin_ratio: f64,
/// The last target previewed, so we only rearrange when it changes.
previewed: ?Target = null,
/// Set once a drop has been accepted; a drag that ends without this was
/// cancelled and must be undone.
committed: bool = false,
};
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
/// The container holding the panes. Its orientation is the view's layout: /// Mount point for the tree's root widget.
/// `.horizontal` lays panes out left to right, `.vertical` stacks them.
box: *gtk.Box, box: *gtk.Box,
layout: Layout,
/// Every pane in the view, in creation order. The tree owns the arrangement;
/// this is just a flat set for iteration.
panes: std.ArrayListUnmanaged(*Pane) = .empty, 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, focused: ?*Pane = null,
/// The pane currently being dragged, if any. Drags never leave the process, drag: ?Drag = null,
/// 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 /// 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. /// try to remove it from a list we're already draining.
closing: bool = false, closing: bool = false,
/// Raised when the last pane goes away and the tab should close with it.
on_empty: *const fn (ctx: ?*anyopaque) void, on_empty: *const fn (ctx: ?*anyopaque) void,
/// Raised when the text a tab should display has changed.
on_title: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null, ctx: ?*anyopaque = null,
pub const Callbacks = struct { pub const Callbacks = struct {
@@ -58,9 +84,8 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
self.* = .{ self.* = .{
.alloc = alloc, .alloc = alloc,
// Side by side is the default: a shell next to an agent is the case .box = gtk.Box.new(.horizontal, 0),
// this exists for, and side-by-side keeps both prompts visible. .layout = .{ .alloc = alloc },
.box = gtk.Box.new(.horizontal, 6),
.on_empty = cbs.on_empty, .on_empty = cbs.on_empty,
.on_title = cbs.on_title, .on_title = cbs.on_title,
.ctx = cbs.ctx, .ctx = cbs.ctx,
@@ -78,6 +103,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
pub fn destroy(self: *View) void { pub fn destroy(self: *View) void {
self.closing = true; self.closing = true;
self.layout.deinit();
for (self.panes.items) |pane| pane.destroy(); for (self.panes.items) |pane| pane.destroy();
self.panes.deinit(self.alloc); self.panes.deinit(self.alloc);
self.alloc.destroy(self); self.alloc.destroy(self);
@@ -88,15 +114,11 @@ pub fn widget(self: *View) *gtk.Widget {
} }
/// Text for the tab label: the focused terminal's title, prefixed with the /// 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 /// pane count once there is more than one.
/// the view at a glance.
pub fn label(self: *View, buf: []u8) []const u8 { pub fn label(self: *View, buf: []u8) []const u8 {
const pane = self.focused orelse (if (self.panes.items.len > 0) const pane = self.focusedPane() orelse return "";
self.panes.items[0]
else
return "");
const title = pane.titleSlice(); const title = pane.titleSlice();
if (self.panes.items.len < 2) { if (self.panes.items.len < 2) {
const n = @min(title.len, buf.len); const n = @min(title.len, buf.len);
@memcpy(buf[0..n], title[0..n]); @memcpy(buf[0..n], title[0..n]);
@@ -106,47 +128,38 @@ pub fn label(self: *View, buf: []u8) []const u8 {
return std.fmt.bufPrint(buf, "{d} · {s}", .{ self.panes.items.len, title }) catch title; 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 focusedPane(self: *View) ?*Pane {
pub fn focusedTerminal(self: *View) ?*@import("Terminal.zig") { return self.focused orelse (if (self.panes.items.len > 0) self.panes.items[0] else null);
const pane = self.focused orelse (if (self.panes.items.len > 0) }
self.panes.items[0]
else pub fn focusedTerminal(self: *View) ?*Terminal {
return null); const pane = self.focusedPane() orelse return null;
return pane.term; 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 { pub fn focus(self: *View) void {
const pane = self.focused orelse (if (self.panes.items.len > 0) if (self.focusedPane()) |pane| pane.grabFocus();
self.panes.items[0]
else
return);
pane.grabFocus();
} }
/// Add a terminal, placed just after the focused pane so a split appears /// Add a terminal, splitting the focused pane so the new one appears beside
/// next to the terminal you were working in rather than at the far end. /// the terminal you were working in.
pub fn addPane(self: *View) !void { pub fn addPane(self: *View) !void {
const pane = try Pane.create(self.alloc, self); const pane = try Pane.create(self.alloc, self);
errdefer pane.destroy(); errdefer pane.destroy();
const index = if (self.focused) |current| const node = try self.layout.newLeaf(pane);
(self.indexOf(current) orelse self.panes.items.len -| 1) + 1 errdefer self.alloc.destroy(node);
else
self.panes.items.len;
try self.panes.insert(self.alloc, index, pane); if (self.focusedPane()) |current| {
errdefer _ = self.panes.orderedRemove(index); const target = self.layout.find(current) orelse return error.PaneNotInLayout;
try self.layout.insert(node, target, .right, 0.5);
} else {
self.layout.root = node;
}
try self.panes.append(self.alloc, pane);
self.layout.materialize(self.box);
self.box.append(pane.widget());
self.syncOrder();
self.setFocused(pane); self.setFocused(pane);
pane.grabFocus(); pane.grabFocus();
self.on_title(self.ctx); self.on_title(self.ctx);
@@ -154,22 +167,40 @@ pub fn addPane(self: *View) !void {
pub fn closePane(self: *View, pane: *Pane) void { pub fn closePane(self: *View, pane: *Pane) void {
if (self.closing) return; if (self.closing) return;
const index = self.indexOf(pane) orelse return; const index = self.indexOf(pane) orelse return;
if (self.dragging == pane) self.dragging = null; // A close during a drag invalidates the recorded origin: the node the
// dragged pane would be restored next to may be the one going away. Treat
// the drag as committed so it ends without trying to undo itself.
if (self.drag) |d| {
if (d.pane == pane) {
self.drag = null;
} else {
self.drag.?.committed = true;
}
}
if (self.layout.find(pane)) |node| {
self.layout.remove(node);
self.alloc.destroy(node);
}
self.box.remove(pane.widget());
_ = self.panes.orderedRemove(index); _ = self.panes.orderedRemove(index);
pane.destroy();
if (self.focused == pane) self.focused = null; if (self.focused == pane) self.focused = null;
// Detach the pane's widget before destroying it so materialize doesn't
// walk into a half-freed subtree.
if (pane.widget().getParent() != null) pane.widget().unparent();
pane.destroy();
if (self.panes.items.len == 0) { if (self.panes.items.len == 0) {
self.on_empty(self.ctx); self.on_empty(self.ctx);
return; return;
} }
// Focus whatever took its place, else the new last pane. self.layout.materialize(self.box);
const next = @min(index, self.panes.items.len - 1); const next = @min(index, self.panes.items.len - 1);
self.setFocused(self.panes.items[next]); self.setFocused(self.panes.items[next]);
self.panes.items[next].grabFocus(); self.panes.items[next].grabFocus();
@@ -185,76 +216,165 @@ pub fn setFocused(self: *View, pane: *Pane) void {
} }
pub fn paneTitleChanged(self: *View, pane: *Pane) void { 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); 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. // -------------------------------------------------------------------------
/// // Rearranging
/// 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()) /// Apply a move. A target pane of null places the moving pane against the
(if (index > 0) self.panes.items[index - 1] else null) /// view's own edge, which is what produces a pane spanning the full width or
/// height across everything else.
pub fn moveTo(self: *View, moving: *Pane, target: Target, ratio: f64) void {
if (target.pane == moving) return;
if (self.panes.items.len < 2) return;
const node = self.layout.find(moving) orelse return;
// Removing first keeps the tree free of the moving pane while the
// destination is resolved, which matters when the destination is the
// root: the root changes as the pane's old parent collapses.
self.layout.remove(node);
const anchor: *Layout.Node = if (target.pane) |p|
(self.layout.find(p) orelse {
self.reattachAtRoot(node);
return;
})
else else
(if (index + 1 < self.panes.items.len) self.panes.items[index + 1] else null); (self.layout.root orelse {
self.layout.root = node;
node.parent = null;
self.layout.materialize(self.box);
return;
});
if (neighbor) |target| { self.layout.insert(node, anchor, target.side, ratio) catch {
self.moveRelative(pane, target, zone); self.reattachAtRoot(node);
return;
};
self.layout.materialize(self.box);
}
/// Last-resort reattachment so a pane can never be orphaned by a failed move.
fn reattachAtRoot(self: *View, node: *Layout.Node) void {
if (self.layout.root) |root| {
self.layout.insert(node, root, .right, 0.5) catch {
// Nothing left to try, and leaving the node detached would lose a
// running terminal, so make it the whole layout.
self.layout.root = node;
node.parent = null;
};
} else { } else {
// Already at that end: there is nothing to swap with, but the self.layout.root = node;
// requested direction still describes how the view should be laid out. node.parent = null;
self.setOrientation(zone.orientation());
} }
self.layout.materialize(self.box);
} }
/// Move `moving` next to `target`, on the side named by `zone`. The zone also /// Move the focused pane one step in a direction: the keyboard equivalent of
/// decides the view's orientation, which is what makes dragging a pane to the /// dragging it onto the neighbouring pane.
/// bottom edge restack the whole view. pub fn moveFocused(self: *View, side: Side) void {
pub fn moveRelative(self: *View, moving: *Pane, target: *Pane, zone: Pane.Zone) void { const pane = self.focusedPane() orelse return;
if (moving == target) return; if (self.panes.items.len < 2) return;
const from = self.indexOf(moving) orelse return;
_ = self.panes.orderedRemove(from); const target = self.neighbor(pane, side) orelse {
// Nothing that way, so place it against the view edge instead and let
// Recompute after the removal: taking `moving` out may have shifted it. // it span the layout on that side.
const target_index = self.indexOf(target) orelse { self.moveTo(pane, .{ .pane = null, .side = side }, 0.5);
// Should not happen, but rather than lose the pane, put it back.
self.panes.insert(self.alloc, from, moving) catch {};
return; return;
}; };
self.moveTo(pane, .{ .pane = target, .side = side }, 0.5);
}
const to = if (zone.isBefore()) target_index else target_index + 1; /// The pane visually adjacent to `pane` on the given side, found by probing
self.panes.insert(self.alloc, to, moving) catch { /// just past the pane's edge and asking which pane covers that point.
self.panes.insert(self.alloc, from, moving) catch {}; fn neighbor(self: *View, pane: *Pane, side: Side) ?*Pane {
return; const w = pane.widget();
const width: f64 = @floatFromInt(w.getWidth());
const height: f64 = @floatFromInt(w.getHeight());
if (width <= 0 or height <= 0) return null;
const probe_gap: f64 = 12;
const local: [2]f64 = switch (side) {
.left => .{ -probe_gap, height / 2 },
.right => .{ width + probe_gap, height / 2 },
.top => .{ width / 2, -probe_gap },
.bottom => .{ width / 2, height + probe_gap },
}; };
self.setOrientation(zone.orientation()); var vx: f64 = 0;
self.syncOrder(); var vy: f64 = 0;
if (w.translateCoordinates(self.widget(), local[0], local[1], &vx, &vy) == 0) return null;
for (self.panes.items) |candidate| {
if (candidate == pane) continue;
var cx: f64 = 0;
var cy: f64 = 0;
if (self.widget().translateCoordinates(candidate.widget(), vx, vy, &cx, &cy) == 0) continue;
const cw: f64 = @floatFromInt(candidate.widget().getWidth());
const ch: f64 = @floatFromInt(candidate.widget().getHeight());
if (cx >= 0 and cy >= 0 and cx < cw and cy < ch) return candidate;
}
return null;
} }
fn setOrientation(self: *View, orientation: gtk.Orientation) void { // -------------------------------------------------------------------------
self.box.as(gtk.Orientable).setOrientation(orientation); // Drag lifecycle
/// Called when a pane's header starts a drag.
pub fn beginDrag(self: *View, pane: *Pane) bool {
if (self.panes.items.len < 2) return false;
const node = self.layout.find(pane) orelse return false;
const position = Layout.positionOf(node) orelse return false;
self.drag = .{
.pane = pane,
.origin_sibling = position.sibling,
.origin_side = position.side,
.origin_ratio = position.ratio,
};
pane.widget().addCssClass("dragging");
return true;
} }
/// Reorder the box's children to match `panes`. GtkBox can reorder in place, /// Preview a target by performing the move for real, but only when the target
/// so panes never get unparented and their terminals keep running untouched. /// has actually changed since the last preview.
fn syncOrder(self: *View) void { pub fn previewDrag(self: *View, target: Target) void {
var previous: ?*gtk.Widget = null; if (self.drag == null) return;
for (self.panes.items) |pane| { if (target.pane == self.drag.?.pane) return;
self.box.reorderChildAfter(pane.widget(), previous); if (self.drag.?.previewed) |previous| {
previous = pane.widget(); if (previous.eql(target)) return;
}
} }
pub fn clearHints(self: *View) void { self.drag.?.previewed = target;
for (self.panes.items) |pane| pane.showHint(null); self.moveTo(self.drag.?.pane, target, 0.5);
}
pub fn commitDrag(self: *View) void {
if (self.drag != null) self.drag.?.committed = true;
}
/// End of a drag. If no drop was accepted, undo whatever the preview did.
pub fn endDrag(self: *View) void {
const drag = self.drag orelse return;
self.drag = null;
drag.pane.widget().removeCssClass("dragging");
if (drag.committed or drag.previewed == null) return;
// Only the dragged pane ever moved, so the rest of the tree still has its
// original shape; putting the pane back beside its original sibling
// restores the arrangement the drag started from.
const node = self.layout.find(drag.pane) orelse return;
self.layout.remove(node);
self.layout.insert(node, drag.origin_sibling, drag.origin_side, drag.origin_ratio) catch {
self.reattachAtRoot(node);
return;
};
self.layout.materialize(self.box);
} }
fn indexOf(self: *View, pane: *Pane) ?usize { fn indexOf(self: *View, pane: *Pane) ?usize {
+3 -4
View File
@@ -14,7 +14,6 @@ const gobject = @import("gobject");
const gtk = @import("gtk"); const gtk = @import("gtk");
const vt = @import("ghostty-vt"); const vt = @import("ghostty-vt");
const Pane = @import("Pane.zig");
const View = @import("View.zig"); const View = @import("View.zig");
const Window = @This(); const Window = @This();
@@ -404,15 +403,15 @@ fn onShortcut(
// is the keyboard route to the same rearranging that dragging a pane's // is the keyboard route to the same rearranging that dragging a pane's
// header does. // header does.
if (ctrl and shift) { if (ctrl and shift) {
const zone: ?Pane.Zone = switch (keyval) { const side: ?View.Side = switch (keyval) {
gdk.KEY_Left => .left, gdk.KEY_Left => .left,
gdk.KEY_Right => .right, gdk.KEY_Right => .right,
gdk.KEY_Up => .top, gdk.KEY_Up => .top,
gdk.KEY_Down => .bottom, gdk.KEY_Down => .bottom,
else => null, else => null,
}; };
if (zone) |z| { if (side) |s| {
if (self.activeTab()) |tab| tab.view.moveFocused(z); if (self.activeTab()) |tab| tab.view.moveFocused(s);
return 1; return 1;
} }
} }
+22 -6
View File
@@ -68,6 +68,11 @@
padding: 6px 6px 6px 0; padding: 6px 6px 6px 0;
} }
/* Paned itself draws nothing; the panes inside it carry the styling. */
.vtabs-view paned {
background: none;
}
/* Each terminal sits in its own rounded frame so multiple panes in a view /* Each terminal sits in its own rounded frame so multiple panes in a view
read as distinct surfaces. */ read as distinct surfaces. */
.vtabs-pane { .vtabs-pane {
@@ -115,9 +120,20 @@
opacity: 1; opacity: 1;
} }
/* Drop indicator: an inset bar on the edge the pane would land against, so /* The pane being dragged. There is no separate drop indicator: the layout
the preview also tells you which way the view is about to be arranged. */ rearranges live during the drag, so the view itself is the preview. */
.vtabs-pane.drop-left { box-shadow: inset 4px 0 0 0 #b29df5; } .vtabs-pane.dragging {
.vtabs-pane.drop-right { box-shadow: inset -4px 0 0 0 #b29df5; } opacity: 0.65;
.vtabs-pane.drop-top { box-shadow: inset 0 4px 0 0 #b29df5; } border-color: #b29df5;
.vtabs-pane.drop-bottom { box-shadow: inset 0 -4px 0 0 #b29df5; } }
/* Divider between panes. Wide enough to grab without hunting for it. */
.vtabs-view paned > separator {
background-color: #0f0d14;
min-width: 6px;
min-height: 6px;
}
.vtabs-view paned > separator:hover {
background-color: #4a3f6b;
}