diff --git a/README.md b/README.md index 52b74b2..fa87d24 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/Layout.zig b/src/Layout.zig new file mode 100644 index 0000000..b79bcdf --- /dev/null +++ b/src/Layout.zig @@ -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, + }; +} diff --git a/src/Pane.zig b/src/Pane.zig index 3130a8d..695ff59 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -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 diff --git a/src/View.zig b/src/View.zig index 01d0eb5..a96c13b 100644 --- a/src/View.zig +++ b/src/View.zig @@ -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 { diff --git a/src/Window.zig b/src/Window.zig index d5d1c1f..950e301 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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; } } diff --git a/src/style.css b/src/style.css index 022fcbf..adba517 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +}