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
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
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
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
```
A tab is a **view**, and a view holds one or more terminal **panes**. New panes
open side by side; moving a pane to a top or bottom edge restacks the view, and
moving it to a side edge puts it back in a row.
A tab is a **view**, and a view holds one or more terminal **panes** arranged
in a **binary split tree**: every interior node is a split with an orientation,
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
by all its panes, so it can express "three side by side" or "three stacked" but
not "two beside a stack of three". A tree would handle the general case and is
where this goes if mixed layouts turn out to matter; it just brings a lot of
structure that a shell-next-to-an-agent view doesn't need yet.
Each split node owns a `GtkPaned`, so dividers are draggable and every split
remembers its position as a **ratio** rather than a pixel count. The ratio is
the source of truth: it is re-applied whenever the available space changes, so
proportions survive window resizes, and only user drags update it.
Panes are held in a `GtkBox`, which can reorder its children in place. Nothing
is ever unparented, so rearranging a view never disturbs the running
terminals — their scrollback and processes carry straight through the move.
Where a drop lands depends on how close it is to the view's own border. Near an
edge of the window the pane is placed against the whole layout and spans it;
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:
@@ -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
its pane count once a view holds more than one
- Tabs: create, close, switch; closing the last one closes the window
- Multiple terminals per tab, rearranged by keyboard or by dragging a pane's
header; closing the last pane in a view closes its tab
- Multiple terminals per tab in an arbitrary split tree, rearranged by keyboard
or by dragging a pane's header, with draggable dividers between them;
closing the last pane in a view closes its tab
### 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
fixed grid offset, so text that needs shaping across cell boundaries won't
look right.
- **Resizable splits.** Panes in a view divide the space evenly; there are no
draggable dividers. Moving to `GtkPaned` would add them.
- **Mixed layouts and moving panes between tabs.** See the flat-list note
above; panes can only be rearranged within their own view.
- **Moving panes between tabs.** Panes can only be rearranged within their
own view.
- **Saved layouts.** A view's arrangement lives only as long as the tab.
- **Kitty graphics, hyperlinks, tab reordering, config file.**
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
`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 gtk = @import("gtk");
const Layout = @import("Layout.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
const Pane = @This();
/// Where a dragged pane would land relative to the pane under the pointer.
/// 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,
const Side = Layout.Side;
fn cssClass(self: Zone) [:0]const u8 {
return switch (self) {
.left => "drop-left",
.right => "drop-right",
.top => "drop-top",
.bottom => "drop-bottom",
};
}
const all_sides = [_]Side{ .left, .right, .top, .bottom };
pub fn orientation(self: Zone) gtk.Orientation {
return switch (self) {
.left, .right => .horizontal,
.top, .bottom => .vertical,
};
}
/// 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 };
/// How close to the view's outer border a drop must be, in pixels, to place
/// the pane against the whole layout instead of against the pane under the
/// pointer. This is what lets a pane dropped along the bottom span the full
/// width beneath everything else, rather than only splitting one pane.
const span_margin: f64 = 48;
alloc: std.mem.Allocator,
view: *View,
@@ -62,9 +40,6 @@ box: *gtk.Box,
header: *gtk.Box,
label: *gtk.Label,
/// The zone currently highlighted by a hovering drag, if any.
hint: ?Zone = null,
/// Latest terminal title, kept NUL-terminated for GTK.
title: [128:0]u8 = @splat(0),
@@ -90,6 +65,12 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
});
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);
box_widget.addCssClass("vtabs-pane");
box_widget.setHexpand(1);
@@ -110,6 +91,8 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
pub fn destroy(self: *Pane) void {
self.term.destroy();
// Releases the reference taken in `create`, finalizing the widget tree.
self.box.as(gobject.Object).unref();
self.alloc.destroy(self);
}
@@ -188,16 +171,14 @@ fn onDragPrepare(
self: *Pane,
) callconv(.c) ?*gdk.ContentProvider {
// 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));
return gdk.ContentProvider.newForValue(&value);
}
fn onDragEnd(_: *gtk.DragSource, _: *gdk.Drag, _: c_int, self: *Pane) callconv(.c) void {
self.view.dragging = null;
self.view.clearHints();
self.view.endDrag();
}
fn installDropTarget(self: *Pane) void {
@@ -208,16 +189,21 @@ fn installDropTarget(self: *Pane) void {
self.widget().addController(target.as(gtk.EventController));
}
/// Pick the edge the pointer is nearest to. Nearest-edge beats fixed
/// hit-zones here: every point in the pane maps to some edge, so there is no
/// dead center where a drop would do nothing.
fn zoneAt(self: *Pane, x: f64, y: f64) Zone {
/// Work out where a drop at this point would put the dragged pane.
///
/// The side is whichever edge of this pane the pointer is nearest, so every
/// 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 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
// toward its long edges.
// Normalized so a wide, short pane doesn't bias every drop toward its
// long edges.
const distances = [_]f64{
x / w, // left
1.0 - x / w, // right
@@ -229,23 +215,45 @@ fn zoneAt(self: *Pane, x: f64, y: f64) Zone {
for (distances, 0..) |d, 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 {
const moving = self.view.dragging orelse return .{};
if (moving == self) {
self.showHint(null);
return .{};
}
const drag = self.view.drag orelse return .{};
if (drag.pane == self) return .{ .move = true };
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 };
}
fn onDropLeave(_: *gtk.DropTarget, self: *Pane) callconv(.c) void {
self.showHint(null);
}
fn onDropLeave(_: *gtk.DropTarget, _: *Pane) callconv(.c) void {}
fn onDrop(
_: *gtk.DropTarget,
@@ -254,24 +262,15 @@ fn onDrop(
y: f64,
self: *Pane,
) callconv(.c) c_int {
const moving = self.view.dragging orelse return 0;
self.view.clearHints();
if (moving == self) return 0;
const drag = self.view.drag orelse 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;
}
/// 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
+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 {
+3 -4
View File
@@ -14,7 +14,6 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
const vt = @import("ghostty-vt");
const Pane = @import("Pane.zig");
const View = @import("View.zig");
const Window = @This();
@@ -404,15 +403,15 @@ fn onShortcut(
// 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) {
const side: ?View.Side = 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);
if (side) |s| {
if (self.activeTab()) |tab| tab.view.moveFocused(s);
return 1;
}
}
+22 -6
View File
@@ -68,6 +68,11 @@
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
read as distinct surfaces. */
.vtabs-pane {
@@ -115,9 +120,20 @@
opacity: 1;
}
/* Drop indicator: an inset bar on the edge the pane would land against, so
the preview also tells you which way the view is about to be arranged. */
.vtabs-pane.drop-left { box-shadow: inset 4px 0 0 0 #b29df5; }
.vtabs-pane.drop-right { box-shadow: inset -4px 0 0 0 #b29df5; }
.vtabs-pane.drop-top { box-shadow: inset 0 4px 0 0 #b29df5; }
.vtabs-pane.drop-bottom { box-shadow: inset 0 -4px 0 0 #b29df5; }
/* The pane being dragged. There is no separate drop indicator: the layout
rearranges live during the drag, so the view itself is the preview. */
.vtabs-pane.dragging {
opacity: 0.65;
border-color: #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;
}