//! A view: the content of one tab, holding one or more panes — terminals, web //! views, or a mix — arranged in a split tree. //! //! 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 Layouts = @import("Layouts.zig"); const Pane = @import("Pane.zig"); const Terminal = @import("Terminal.zig"); const View = @This(); pub const Side = Layout.Side; pub const Kind = Pane.Kind; pub const Status = Pane.Status; /// 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, /// 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, focused: ?*Pane = null, /// The pane temporarily filling the view, if any. /// /// This is a display state and nothing more — the split tree is untouched /// while it is set, so leaving zoom restores the arrangement exactly rather /// than reconstructing it. zoomed: ?*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, on_empty: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque) void, on_status: *const fn (ctx: ?*anyopaque) void, /// A pane in here just finished work. Separate from `on_status` because the /// tab needs the *edge*, not the state: "something finished since you were /// last here" cannot be recovered by looking at the panes afterwards, since /// one that finished before your last visit looks identical to one that /// finished after it. on_finished: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque = null, pub const Callbacks = struct { on_empty: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque) void, on_status: *const fn (ctx: ?*anyopaque) void, on_finished: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque, }; pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View { const self = try alloc.create(View); errdefer alloc.destroy(self); self.* = .{ .alloc = alloc, .box = gtk.Box.new(.horizontal, 0), .layout = .{ .alloc = alloc }, .on_empty = cbs.on_empty, .on_title = cbs.on_title, .on_status = cbs.on_status, .on_finished = cbs.on_finished, .ctx = cbs.ctx, }; self.box.as(gtk.Widget).addCssClass("playpen-view"); self.box.as(gtk.Widget).setHexpand(1); self.box.as(gtk.Widget).setVexpand(1); // Deliberately created empty. Adding a pane fires on_title, and the // caller's context object is not usable until it has finished wiring // itself to this view. return self; } 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); } pub fn widget(self: *View) *gtk.Widget { return self.box.as(gtk.Widget); } // ------------------------------------------------------------------------- // Zoom // // One pane temporarily filling the tab, for when a split is too cramped to // work in. Nothing closes and nothing moves: the tree is left exactly as it // was and only the rendering changes, so leaving zoom restores the previous // arrangement rather than rebuilding an approximation of it. /// Put the view on screen. Every structural change goes through here rather /// than calling the layout directly, so zoom is honoured from one place /// instead of being re-checked at each call site. fn render(self: *View) void { if (self.zoomed) |pane| { self.layout.materializeZoom(self.box, pane); } else { self.layout.materialize(self.box); } } /// Toggle `pane` filling the view. /// /// A single-pane view has nothing to hide, so zooming it would be an /// invisible state change that leaves the button looking wrong; refuse /// instead. pub fn toggleZoom(self: *View, pane: *Pane) void { if (self.zoomed == pane) { self.zoomed = null; } else { if (self.panes.items.len < 2) return; self.zoomed = pane; } self.render(); self.refreshZoomChrome(); // Re-rendering reparents the pane, which drops keyboard focus on the way // through, so it has to be taken again afterwards. pane.grabFocus(); self.on_title(self.ctx); } pub fn toggleZoomFocused(self: *View) void { if (self.focusedPane()) |pane| self.toggleZoom(pane); } /// Leave zoom, if we are in it. Used by everything that changes the shape of /// the view: the point of those actions is the arrangement, and staying zoomed /// would hide the very thing the user just asked for. fn clearZoom(self: *View) void { if (self.zoomed == null) return; self.zoomed = null; self.refreshZoomChrome(); } /// Point every pane's zoom button at the current state: which pane (if any) is /// zoomed, and whether zooming means anything in a view this size. fn refreshZoomChrome(self: *View) void { const available = self.panes.items.len > 1; for (self.panes.items) |pane| { pane.setZoomAvailable(available); pane.setZoomed(self.zoomed == pane); } } /// Text for the tab label: the focused pane's title, prefixed with the pane /// count once there is more than one. pub fn label(self: *View, buf: []u8) []const u8 { 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]); return buf[0..n]; } return std.fmt.bufPrint(buf, "{d} · {s}", .{ self.panes.items.len, title }) catch title; } pub fn focusedPane(self: *View) ?*Pane { return self.focused orelse (if (self.panes.items.len > 0) self.panes.items[0] else null); } /// The focused pane's terminal, or null when the focused pane holds a web /// view instead. pub fn focusedTerminal(self: *View) ?*Terminal { const pane = self.focusedPane() orelse return null; return pane.terminal(); } /// Icon for the tab row: whatever the focused pane is showing. pub fn iconName(self: *View) [:0]const u8 { const pane = self.focusedPane() orelse return Kind.terminal.iconName(); return pane.kind.iconName(); } pub fn focus(self: *View) void { if (self.focusedPane()) |pane| pane.grabFocus(); } /// Add a pane, splitting the focused one so the new pane appears beside /// whatever you were working in. pub fn addPane(self: *View, spec: Pane.Spec) !void { const pane = try Pane.create(self.alloc, self, spec); errdefer pane.destroy(); const node = try self.layout.newLeaf(pane); errdefer self.alloc.destroy(node); 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); // Splitting while zoomed would put the new pane somewhere you cannot see, // so opening one leaves zoom. self.zoomed = null; self.render(); self.refreshZoomChrome(); self.setFocused(pane); pane.grabFocus(); self.on_title(self.ctx); } pub fn closePane(self: *View, pane: *Pane) void { if (self.closing) return; const index = self.indexOf(pane) orelse return; // 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.panes.orderedRemove(index); if (self.focused == pane) self.focused = null; // The zoomed pane going away takes the zoom with it. A different pane // closing — a background shell exiting, say — leaves it standing, since // what you are looking at is still there and still what you asked for. if (self.zoomed == pane) self.zoomed = 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; } self.render(); self.refreshZoomChrome(); const next = @min(index, self.panes.items.len - 1); self.setFocused(self.panes.items[next]); self.panes.items[next].grabFocus(); self.on_title(self.ctx); // Closing a busy pane changes what the tab as a whole is reporting, so // the row has to be recomputed even though no pane changed its own state. self.on_status(self.ctx); } pub fn setFocused(self: *View, pane: *Pane) void { if (self.focused == pane) return; if (self.focused) |old| old.setActive(false); self.focused = pane; pane.setActive(true); self.on_title(self.ctx); // Deliberately moving into a pane answers it, the same as typing would. // The identity check above is what keeps this honest: work finishing in // the pane you are already sitting in leaves the focus unchanged, so it // stays flagged — which is the case the whole indicator exists for. pane.markAnswered(); self.on_status(self.ctx); } pub fn paneFinished(self: *View, pane: *Pane) void { _ = pane; if (self.closing) return; self.on_finished(self.ctx); } pub fn paneTitleChanged(self: *View, pane: *Pane) void { if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx); } pub fn paneStatusChanged(self: *View, pane: *Pane) void { _ = pane; if (self.closing) return; self.on_status(self.ctx); } /// The view's status: the most urgent thing any of its panes is reporting. /// /// A tab is one row however many panes it holds, so the row has to say /// something about all of them at once. Urgency wins rather than, say, the /// focused pane's status, because the whole point of the indicator is to be /// read from another tab — a pane blocked on a permission prompt matters /// even if it isn't the one you left focused. pub fn status(self: *View) Status { var worst: Status = .idle; for (self.panes.items) |pane| { worst = switch (pane.status) { // Nothing outranks a pane waiting on the user, so this can // return the moment one is found. .needs_input => return .needs_input, .failed => .failed, .busy => if (worst == .failed) worst else .busy, .idle => worst, }; } return worst; } /// Whether any pane here has finished work that hasn't been answered. /// /// Any one pane is enough, and deliberately so: the view's own status can't /// answer this, because a tab holding three agents never goes idle as a whole /// until the last of them stops, and "one of them is ready for you" is worth /// hearing before then. pub fn anyDoneUnanswered(self: *View) bool { for (self.panes.items) |pane| if (pane.done_unanswered) return true; return false; } /// Answer the pane the user has landed in, without touching its siblings. /// /// Called when a tab is opened: the pane you arrive in is the one you are /// looking at, while the others in a split are still holding news you have not /// gone to yet, and their dots are the only thing that says which. pub fn answerFocused(self: *View) void { if (self.focusedPane()) |pane| pane.markAnswered(); } // ------------------------------------------------------------------------- // Layouts /// Fill an empty view from a saved layout, substituting `bindings` into every /// directory, script and URL as the panes are created. /// /// Only valid on a view that has no panes yet — a layout describes a whole /// tab, not an addition to one. pub fn applyLayout( self: *View, spec: *const Layouts.Node, bindings: []const Layouts.Binding, ) !void { std.debug.assert(self.panes.items.len == 0); const root = try self.buildNode(spec, bindings); self.layout.root = root; root.parent = null; self.render(); // The first pane in tree order is the top-left one, which is where you // would start reading the tab and so where focus belongs. if (self.panes.items.len > 0) { self.setFocused(self.panes.items[0]); self.panes.items[0].grabFocus(); } // Panes are built one at a time and each starts assuming it is alone, so // a multi-pane layout has to be told once it is fully assembled. self.refreshZoomChrome(); self.on_title(self.ctx); } /// Build one subtree. Panes are appended to `self.panes` as they are created, /// so a failure part-way leaves them owned by the view and torn down with it /// rather than leaked. fn buildNode( self: *View, spec: *const Layouts.Node, bindings: []const Layouts.Binding, ) !*Layout.Node { switch (spec.*) { .pane => |p| { const pane_spec = try self.paneSpec(p, bindings); defer freePaneSpec(self.alloc, pane_spec); const pane = try Pane.create(self.alloc, self, pane_spec); errdefer pane.destroy(); const node = try self.layout.newLeaf(pane); errdefer self.alloc.destroy(node); try self.panes.append(self.alloc, pane); return node; }, .split => |s| { const first = try self.buildNode(s.first, bindings); const second = try self.buildNode(s.second, bindings); return self.layout.newSplit( switch (s.orientation) { .horizontal => .horizontal, .vertical => .vertical, }, first, second, s.ratio, ); }, } } /// Turn a layout's pane description into the spec `Pane.create` wants, with /// parameters substituted. The strings are owned by the caller. fn paneSpec( self: *View, p: Layouts.Pane, bindings: []const Layouts.Binding, ) !Pane.Spec { return switch (p.kind) { .terminal => blk: { const cwd = try Layouts.expandPath(self.alloc, p.cwd, bindings); errdefer self.alloc.free(cwd); const command = try Layouts.expand(self.alloc, p.command, bindings); break :blk .{ .terminal = .{ .cwd = cwd, .command = command } }; }, .web => .{ .web = .{ .url = try Layouts.expand(self.alloc, p.url, bindings), } }, }; } fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void { switch (spec) { .terminal => |o| { alloc.free(o.cwd); alloc.free(o.command); }, .web => |o| alloc.free(o.url), } } /// Capture this view's arrangement as a layout tree, for "save tab as layout". /// /// The shape, split orientations and divider ratios come across exactly as /// they are on screen. What each pane should *run* can't be known from a live /// pane, so terminals come back with their current directory and an empty /// command for the user to fill in; web panes bring their current URL. pub fn capture(self: *View, builder: Layouts.Builder) !?*Layouts.Node { const root = self.layout.root orelse return null; return try self.captureNode(root, builder); } fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layouts.Node { return switch (node.kind) { .leaf => |pane| switch (pane.content) { .terminal => |t| blk: { var buf: [std.fs.max_path_bytes]u8 = undefined; break :blk try builder.pane(.{ .kind = .terminal, .cwd = t.session.pty.cwd(&buf) orelse "", }); }, .web => |b| try builder.pane(.{ .kind = .web, .url = b.currentUrl(), }), }, .split => |s| try builder.split( switch (s.paned.as(gtk.Orientable).getOrientation()) { .vertical => .vertical, else => .horizontal, }, s.ratio, try self.captureNode(s.a, builder), try self.captureNode(s.b, builder), ), }; } // ------------------------------------------------------------------------- // Rearranging /// 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 (self.layout.root orelse { self.layout.root = node; node.parent = null; self.render(); return; }); self.layout.insert(node, anchor, target.side, ratio) catch { self.reattachAtRoot(node); return; }; self.render(); } /// 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 { self.layout.root = node; node.parent = null; } self.render(); } /// 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; // Rearranging is about where panes sit relative to each other, which is // exactly what zoom is hiding. Leave it so the move can be seen — and so // `neighbor` below has laid-out panes to probe for in the first place. self.clearZoom(); self.render(); 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); } /// 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 }, }; var vx: f64 = 0; var vy: f64 = 0; if (w.translateCoordinates(self.widget(), local[0], local[1], &vx, &vy) == 0) return null; for (self.panes.items) |candidate| { if (candidate == pane) continue; var cx: f64 = 0; var cy: f64 = 0; if (self.widget().translateCoordinates(candidate.widget(), vx, vy, &cx, &cy) == 0) continue; const cw: f64 = @floatFromInt(candidate.widget().getWidth()); const ch: f64 = @floatFromInt(candidate.widget().getHeight()); if (cx >= 0 and cy >= 0 and cx < cw and cy < ch) return candidate; } return null; } // ------------------------------------------------------------------------- // 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; // Only the zoomed pane is on screen, so there is nothing to drop against // and no preview to show. Refusing the drag is better than starting one // that can only ever be cancelled. if (self.zoomed != null) 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.render(); } fn indexOf(self: *View, pane: *Pane) ?usize { for (self.panes.items, 0..) |p, i| if (p == pane) return i; return null; }