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
+236 -116
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
//! 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".
//! Dragging a pane rearranges the view live rather than on release. Each time
//! the drop target changes, the move is applied for real, so what you see
//! during the drag is the layout you will get. If the drag is cancelled the
//! pane goes back where it came from, which is possible because only the
//! 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.
const std = @import("std");
const gtk = @import("gtk");
const Layout = @import("Layout.zig");
const Pane = @import("Pane.zig");
const Terminal = @import("Terminal.zig");
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,
/// The container holding the panes. Its orientation is the view's layout:
/// `.horizontal` lays panes out left to right, `.vertical` stacks them.
/// Mount point for the tree's root widget.
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,
/// 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,
drag: ?Drag = 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 {
@@ -58,9 +84,8 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
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),
.box = gtk.Box.new(.horizontal, 0),
.layout = .{ .alloc = alloc },
.on_empty = cbs.on_empty,
.on_title = cbs.on_title,
.ctx = cbs.ctx,
@@ -78,6 +103,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
pub fn destroy(self: *View) void {
self.closing = true;
self.layout.deinit();
for (self.panes.items) |pane| pane.destroy();
self.panes.deinit(self.alloc);
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
/// pane count once there is more than one so the sidebar shows the shape of
/// the view at a glance.
/// pane count once there is more than one.
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 pane = self.focusedPane() orelse 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]);
@@ -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;
}
/// 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);
pub fn focusedPane(self: *View) ?*Pane {
return self.focused orelse (if (self.panes.items.len > 0) self.panes.items[0] else null);
}
pub fn focusedTerminal(self: *View) ?*Terminal {
const pane = self.focusedPane() orelse 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();
if (self.focusedPane()) |pane| 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.
/// Add a terminal, splitting the focused pane so the new one appears beside
/// the terminal you were working in.
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;
const node = try self.layout.newLeaf(pane);
errdefer self.alloc.destroy(node);
try self.panes.insert(self.alloc, index, pane);
errdefer _ = self.panes.orderedRemove(index);
if (self.focusedPane()) |current| {
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);
pane.grabFocus();
self.on_title(self.ctx);
@@ -154,22 +167,40 @@ pub fn addPane(self: *View) !void {
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;
// 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);
pane.destroy();
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) {
self.on_empty(self.ctx);
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);
self.setFocused(self.panes.items[next]);
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 {
// 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;
// -------------------------------------------------------------------------
// Rearranging
const neighbor: ?*Pane = if (zone.isBefore())
(if (index > 0) self.panes.items[index - 1] else null)
/// Apply a move. A target pane of null places the moving pane against the
/// 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
(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.moveRelative(pane, target, zone);
self.layout.insert(node, anchor, target.side, ratio) catch {
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 {
// 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());
self.layout.root = node;
node.parent = null;
}
self.layout.materialize(self.box);
}
/// 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;
/// Move the focused pane one step in a direction: the keyboard equivalent of
/// dragging it onto the neighbouring pane.
pub fn moveFocused(self: *View, side: Side) void {
const pane = self.focusedPane() orelse return;
if (self.panes.items.len < 2) 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 {};
const target = self.neighbor(pane, side) orelse {
// Nothing that way, so place it against the view edge instead and let
// it span the layout on that side.
self.moveTo(pane, .{ .pane = null, .side = side }, 0.5);
return;
};
self.moveTo(pane, .{ .pane = target, .side = side }, 0.5);
}
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;
/// The pane visually adjacent to `pane` on the given side, found by probing
/// just past the pane's edge and asking which pane covers that point.
fn neighbor(self: *View, pane: *Pane, side: Side) ?*Pane {
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());
self.syncOrder();
}
var vx: f64 = 0;
var vy: f64 = 0;
if (w.translateCoordinates(self.widget(), local[0], local[1], &vx, &vy) == 0) return null;
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();
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;
}
pub fn clearHints(self: *View) void {
for (self.panes.items) |pane| pane.showHint(null);
// -------------------------------------------------------------------------
// 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;
}
/// Preview a target by performing the move for real, but only when the target
/// has actually changed since the last preview.
pub fn previewDrag(self: *View, target: Target) void {
if (self.drag == null) return;
if (target.pane == self.drag.?.pane) return;
if (self.drag.?.previewed) |previous| {
if (previous.eql(target)) return;
}
self.drag.?.previewed = target;
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 {