From 5b00c98e032ac983e0642b4195a2ce336d41dc4e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 11 Aug 2026 10:59:21 -0400 Subject: [PATCH] Multiple terminals per tab, rearrangeable by drag or keyboard A tab is now a view holding an ordered list of panes rather than a single terminal. New panes open side by side; moving one against a top or bottom edge restacks the view, and against a side edge returns it to a row. Layout is a flat list with one orientation per view rather than a split tree. That covers a shell beside an agent without the structure a tree needs, and it can be replaced once mixed layouts actually matter. Panes live in a GtkBox and are reordered in place, so rearranging never unparents a terminal and running processes and scrollback survive the move. Dragging a pane uses its header as the handle so it never competes with the terminal's own mouse handling, and drops go through the same moveRelative path as the Ctrl+Shift+arrow shortcuts. Also fixes an ordering bug this surfaced: View.create used to add its first pane immediately, firing the title callback into a Tab that had not been initialized yet. The view is now created empty and the caller adds the pane once it has finished wiring itself up. --- README.md | 41 ++++++- src/Pane.zig | 310 +++++++++++++++++++++++++++++++++++++++++++++++ src/Terminal.zig | 57 +++++---- src/View.zig | 263 ++++++++++++++++++++++++++++++++++++++++ src/Window.zig | 101 ++++++++++----- src/style.css | 59 ++++++++- 6 files changed, 768 insertions(+), 63 deletions(-) create mode 100644 src/Pane.zig create mode 100644 src/View.zig diff --git a/README.md b/README.md index 00001db..52b74b2 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,9 @@ just the VT core. ``` main.zig AdwApplication, CSS loading -Window.zig sidebar + GtkStack of terminals, tab management, shortcuts +Window.zig sidebar + GtkStack of views, tab management, shortcuts +View.zig one tab's content: an ordered list of panes + their layout +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 Pty.zig openpt/fork/exec, controlling terminal setup @@ -85,6 +87,20 @@ 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. + +**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. + +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. + Two design choices worth calling out: **No IO thread.** The PTY is read on the GLib main loop through a unix fd @@ -108,20 +124,29 @@ in principle, but a terminal grid is small. - Block / bar / underline / hollow cursor styles - Scrollback via mouse wheel; typing snaps back to the prompt - Resize reflows the grid and notifies the child -- Window title (OSC 0/2) becomes the tab label -- Tabs: create, close, switch; closing the last one closes the window; - a child exiting closes its own tab +- 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 ### Shortcuts | | | |---|---| | `Ctrl+Shift+T` | new tab | -| `Ctrl+Shift+W` | close tab | +| `Ctrl+Shift+E` | new terminal in the current tab | +| `Ctrl+Shift+W` | close the focused terminal (closes the tab with its last one) | +| `Ctrl+Shift+←/→/↑/↓` | move the focused terminal within its view | | `Ctrl+Shift+V` | paste (bracketed-paste aware, refuses unsafe pastes) | | `Ctrl+PageUp/PageDown` | previous / next tab | | `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last | +Dragging a pane by its header does the same thing as the arrow shortcuts: the +edge you drop against decides both the order and the view's orientation. The +header is the drag handle rather than the whole pane so that dragging never +competes with the terminal's own mouse handling. + ## Not implemented This is a proof of concept, and the following are deliberately absent: @@ -132,7 +157,11 @@ 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. -- **Kitty graphics, hyperlinks, tab reordering, split panes, config file.** +- **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. +- **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/Pane.zig b/src/Pane.zig new file mode 100644 index 0000000..3130a8d --- /dev/null +++ b/src/Pane.zig @@ -0,0 +1,310 @@ +//! One terminal pane inside a view. +//! +//! A pane is a terminal plus the chrome needed to tell panes apart and +//! rearrange them: a header strip showing the terminal's title, which doubles +//! as the drag handle, and a drop target covering the whole pane. +//! +//! The header exists mainly so dragging a pane never competes with the +//! terminal's own mouse handling. Grabbing anywhere in the terminal body +//! would collide with text selection as soon as that lands. + +const std = @import("std"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +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, + + fn cssClass(self: Zone) [:0]const u8 { + return switch (self) { + .left => "drop-left", + .right => "drop-right", + .top => "drop-top", + .bottom => "drop-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 }; + +alloc: std.mem.Allocator, +view: *View, +term: *Terminal, + +/// Vertical box: header strip on top, terminal filling the rest. +box: *gtk.Box, + +/// The header doubles as the drag handle, so it's kept for the drag source. +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), + +pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane { + const self = try alloc.create(Pane); + errdefer alloc.destroy(self); + + self.* = .{ + .alloc = alloc, + .view = view, + .term = undefined, + .box = gtk.Box.new(.vertical, 0), + .header = gtk.Box.new(.horizontal, 4), + .label = gtk.Label.new("shell"), + }; + setTitle(self, "shell"); + + self.term = try .create(alloc, .{ + .on_title = &onTermTitle, + .on_exit = &onTermExit, + .on_focus = &onTermFocus, + .ctx = self, + }); + errdefer self.term.destroy(); + + const box_widget = self.box.as(gtk.Widget); + box_widget.addCssClass("vtabs-pane"); + box_widget.setHexpand(1); + box_widget.setVexpand(1); + // Clip the terminal to the pane's rounded corners. The terminal paints a + // plain rectangle and has no idea it's inside a rounded frame. + box_widget.setOverflow(.hidden); + + self.buildHeader(); + self.box.append(self.header.as(gtk.Widget)); + self.box.append(self.term.widget()); + + self.installDragSource(); + self.installDropTarget(); + + return self; +} + +pub fn destroy(self: *Pane) void { + self.term.destroy(); + self.alloc.destroy(self); +} + +pub fn widget(self: *Pane) *gtk.Widget { + return self.box.as(gtk.Widget); +} + +pub fn grabFocus(self: *Pane) void { + self.term.grabFocus(); +} + +pub fn titleSlice(self: *const Pane) [:0]const u8 { + return std.mem.sliceTo(&self.title, 0); +} + +/// Mark this pane as the focused one in its view. +pub fn setActive(self: *Pane, active: bool) void { + if (active) { + self.widget().addCssClass("active"); + } else { + self.widget().removeCssClass("active"); + } +} + +fn buildHeader(self: *Pane) void { + const header = self.header; + header.as(gtk.Widget).addCssClass("vtabs-pane-header"); + // The whole strip is the drag handle, so advertise that with the cursor. + header.as(gtk.Widget).setCursorFromName("grab"); + + self.label.setXalign(0); + self.label.setEllipsize(.end); + self.label.as(gtk.Widget).setHexpand(1); + self.label.as(gtk.Widget).addCssClass("vtabs-pane-title"); + header.append(self.label.as(gtk.Widget)); + + const split = gtk.Button.newFromIconName("list-add-symbolic"); + split.as(gtk.Widget).addCssClass("flat"); + split.as(gtk.Widget).addCssClass("vtabs-pane-button"); + split.as(gtk.Widget).setTooltipText("New terminal in this view (Ctrl+Shift+E)"); + _ = gtk.Button.signals.clicked.connect(split, *Pane, &onSplitClicked, self, .{}); + header.append(split.as(gtk.Widget)); + + const close = gtk.Button.newFromIconName("window-close-symbolic"); + close.as(gtk.Widget).addCssClass("flat"); + close.as(gtk.Widget).addCssClass("vtabs-pane-button"); + close.as(gtk.Widget).setTooltipText("Close terminal (Ctrl+Shift+W)"); + _ = gtk.Button.signals.clicked.connect(close, *Pane, &onCloseClicked, self, .{}); + header.append(close.as(gtk.Widget)); +} + +// ------------------------------------------------------------------------- +// Drag and drop +// +// The payload is a fixed string purely to satisfy the GTK content API. The +// pane being dragged is recorded on the view instead: drags never leave this +// process, and passing a raw pointer through a GValue buys nothing. + +const drag_payload = "vtabs-pane"; + +fn installDragSource(self: *Pane) void { + const source = gtk.DragSource.new(); + source.setActions(.{ .move = true }); + _ = gtk.DragSource.signals.prepare.connect(source, *Pane, &onDragPrepare, self, .{}); + _ = gtk.DragSource.signals.drag_end.connect(source, *Pane, &onDragEnd, self, .{}); + + // Attached to the header only, so drags inside the terminal body stay + // available for the terminal itself. + self.header.as(gtk.Widget).addController(source.as(gtk.EventController)); +} + +fn onDragPrepare( + _: *gtk.DragSource, + _: f64, + _: f64, + self: *Pane, +) callconv(.c) ?*gdk.ContentProvider { + // A single-pane view has nothing to rearrange. + if (self.view.panes.items.len < 2) 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(); +} + +fn installDropTarget(self: *Pane) void { + const target = gtk.DropTarget.new(gobject.ext.types.string, .{ .move = true }); + _ = gtk.DropTarget.signals.motion.connect(target, *Pane, &onDropMotion, self, .{}); + _ = gtk.DropTarget.signals.leave.connect(target, *Pane, &onDropLeave, self, .{}); + _ = gtk.DropTarget.signals.drop.connect(target, *Pane, &onDrop, self, .{}); + 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 { + const w: f64 = @floatFromInt(self.widget().getWidth()); + const h: f64 = @floatFromInt(self.widget().getHeight()); + if (w <= 0 or h <= 0) return .right; + + // Distances are 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 + y / h, // top + 1.0 - y / h, // bottom + }; + + var best: usize = 0; + for (distances, 0..) |d, i| { + if (d < distances[best]) best = i; + } + return all_zones[best]; +} + +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 .{}; + } + + self.showHint(self.zoneAt(x, y)); + return .{ .move = true }; +} + +fn onDropLeave(_: *gtk.DropTarget, self: *Pane) callconv(.c) void { + self.showHint(null); +} + +fn onDrop( + _: *gtk.DropTarget, + _: *gobject.Value, + x: f64, + y: f64, + self: *Pane, +) callconv(.c) c_int { + const moving = self.view.dragging orelse return 0; + self.view.clearHints(); + if (moving == self) return 0; + + self.view.moveRelative(moving, self, self.zoneAt(x, y)); + 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 + +fn setTitle(self: *Pane, text: []const u8) void { + const n = @min(text.len, self.title.len - 1); + @memcpy(self.title[0..n], text[0..n]); + @memset(self.title[n..], 0); + self.label.setText(self.titleSlice()); + self.label.as(gtk.Widget).setTooltipText(self.titleSlice()); +} + +fn onTermTitle(ctx: ?*anyopaque, title: []const u8) void { + const self: *Pane = @ptrCast(@alignCast(ctx.?)); + self.setTitle(title); + self.view.paneTitleChanged(self); +} + +fn onTermExit(ctx: ?*anyopaque) void { + const self: *Pane = @ptrCast(@alignCast(ctx.?)); + self.view.closePane(self); +} + +fn onTermFocus(ctx: ?*anyopaque) void { + const self: *Pane = @ptrCast(@alignCast(ctx.?)); + self.view.setFocused(self); +} + +fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void { + self.view.addPane() catch |err| { + std.log.err("failed to open terminal: {s}", .{@errorName(err)}); + }; +} + +fn onCloseClicked(_: *gtk.Button, self: *Pane) callconv(.c) void { + self.view.closePane(self); +} diff --git a/src/Terminal.zig b/src/Terminal.zig index 5109f95..6b7fdf8 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -9,6 +9,7 @@ const std = @import("std"); const cairo = @import("cairo"); const gdk = @import("gdk"); +const gobject = @import("gobject"); const gtk = @import("gtk"); const pango = @import("pango"); const pangocairo = @import("pangocairo"); @@ -29,9 +30,6 @@ const font_spec = "monospace 11"; /// Padding between the grid and the widget edge, in pixels. const pad: f64 = 8; -/// Corner rounding of the terminal pane. -const corner_radius: f64 = 10; - alloc: std.mem.Allocator, session: *Session, @@ -49,6 +47,10 @@ run_buf: std.ArrayListUnmanaged(u8) = .empty, /// Called when the session's title changes, so the owner can retitle the tab. on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, + +/// Called when this terminal takes keyboard focus. +on_focus: *const fn (ctx: ?*anyopaque) void, + ctx: ?*anyopaque = null, pub fn create( @@ -56,6 +58,7 @@ pub fn create( cbs: struct { on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, + on_focus: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque, }, ) !*Terminal { @@ -72,6 +75,7 @@ pub fn create( .font = font, .on_title = cbs.on_title, .on_exit = cbs.on_exit, + .on_focus = cbs.on_focus, .ctx = cbs.ctx, }; @@ -129,6 +133,16 @@ pub fn create( ); w.addController(click.as(gtk.EventController)); + // Watching the property rather than just the click gesture means focus + // taken programmatically or by keyboard navigation is reported too. + _ = gobject.Object.signals.notify.connect( + area, + *Terminal, + &onNotifyHasFocus, + self, + .{ .detail = "has-focus" }, + ); + return self; } @@ -175,6 +189,14 @@ fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void { self.on_title(self.ctx, title); } +fn onNotifyHasFocus( + _: *gtk.DrawingArea, + _: *gobject.ParamSpec, + self: *Terminal, +) callconv(.c) void { + if (self.widget().hasFocus() != 0) self.on_focus(self.ctx); +} + fn onSessionExit(ctx: ?*anyopaque) void { const self: *Terminal = @ptrCast(@alignCast(ctx.?)); self.on_exit(self.ctx); @@ -313,27 +335,17 @@ fn drawFunc( }; } -fn render(self: *Terminal, cr: *cairo.Context, width: c_int, height: c_int) !void { +fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { const term = &self.session.term; const screen = term.screens.active; - // Background. Clipped to a rounded rectangle so the terminal reads as an - // inset pane next to the sidebar, the way Zen insets web content. + // Background. The pane frame around us clips to its own rounded corners, + // so this just fills. const default_bg: theme.Rgb = if (term.colors.background.get()) |c| .from(c) else theme.bg; { - roundedRect( - cr, - 0, - 0, - @floatFromInt(width), - @floatFromInt(height), - corner_radius, - ); - cr.clip(); - const r, const g, const b = default_bg.cairoRgb(); cr.setSourceRgb(r, g, b); cr.paint(); @@ -547,19 +559,6 @@ fn drawCursor( } } -/// Trace a rounded rectangle as the current path. -fn roundedRect(cr: *cairo.Context, x: f64, y: f64, w: f64, h: f64, r: f64) void { - const radius = @min(r, @min(w, h) / 2); - const pi = std.math.pi; - - cr.newSubPath(); - cr.arc(x + w - radius, y + radius, radius, -pi / 2.0, 0); - cr.arc(x + w - radius, y + h - radius, radius, 0, pi / 2.0); - cr.arc(x + radius, y + h - radius, radius, pi / 2.0, pi); - cr.arc(x + radius, y + radius, radius, pi, 3.0 * pi / 2.0); - cr.closePath(); -} - /// Resolve a cell's style into concrete colors and attributes. fn appearance( pin: vt.Pin, diff --git a/src/View.zig b/src/View.zig new file mode 100644 index 0000000..01d0eb5 --- /dev/null +++ b/src/View.zig @@ -0,0 +1,263 @@ +//! A view: the content of one tab, holding one or more terminal panes. +//! +//! 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". + +const std = @import("std"); +const gtk = @import("gtk"); + +const Pane = @import("Pane.zig"); + +const View = @This(); + +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. +box: *gtk.Box, + +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, + +/// 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 { + on_empty: *const fn (ctx: ?*anyopaque) void, + on_title: *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, + // 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), + .on_empty = cbs.on_empty, + .on_title = cbs.on_title, + .ctx = cbs.ctx, + }; + + self.box.as(gtk.Widget).addCssClass("vtabs-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; + 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); +} + +/// 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. +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 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; +} + +/// 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); + 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(); +} + +/// 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. +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; + + try self.panes.insert(self.alloc, index, pane); + errdefer _ = self.panes.orderedRemove(index); + + self.box.append(pane.widget()); + self.syncOrder(); + 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; + + if (self.dragging == pane) self.dragging = null; + + self.box.remove(pane.widget()); + _ = self.panes.orderedRemove(index); + pane.destroy(); + + if (self.focused == pane) self.focused = null; + + if (self.panes.items.len == 0) { + self.on_empty(self.ctx); + return; + } + + // Focus whatever took its place, else the new last pane. + 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); +} + +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); +} + +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; + + const neighbor: ?*Pane = if (zone.isBefore()) + (if (index > 0) self.panes.items[index - 1] else null) + else + (if (index + 1 < self.panes.items.len) self.panes.items[index + 1] else null); + + if (neighbor) |target| { + self.moveRelative(pane, target, zone); + } 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()); + } +} + +/// 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; + + _ = 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 {}; + return; + }; + + 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; + }; + + self.setOrientation(zone.orientation()); + self.syncOrder(); +} + +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(); + } +} + +pub fn clearHints(self: *View) void { + for (self.panes.items) |pane| pane.showHint(null); +} + +fn indexOf(self: *View, pane: *Pane) ?usize { + for (self.panes.items, 0..) |p, i| if (p == pane) return i; + return null; +} diff --git a/src/Window.zig b/src/Window.zig index feea6ae..d5d1c1f 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -14,7 +14,8 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const vt = @import("ghostty-vt"); -const Terminal = @import("Terminal.zig"); +const Pane = @import("Pane.zig"); +const View = @import("View.zig"); const Window = @This(); @@ -42,10 +43,11 @@ updating: bool = false, /// doesn't try to close a tab we're already destroying. closing: bool = false, -/// A single tab: the terminal plus the sidebar row that selects it. +/// A single tab: a view of one or more terminals, plus the sidebar row that +/// selects it. const Tab = struct { window: *Window, - term: *Terminal, + view: *View, row: *gtk.ListBoxRow, label: *gtk.Label, name: [16]u8, @@ -161,7 +163,7 @@ pub fn present(self: *Window) void { // grabFocus during construction silently does nothing because the // widget is not yet realized, which would send the first keystroke to // the sidebar instead of the terminal. - if (self.activeTab()) |tab| tab.term.grabFocus(); + if (self.activeTab()) |tab| tab.view.focus(); } /// Open a new tab and switch to it. @@ -169,19 +171,19 @@ pub fn newTab(self: *Window) !void { const tab = try self.alloc.create(Tab); errdefer self.alloc.destroy(tab); - const term = try Terminal.create(self.alloc, .{ - .on_title = &onTabTitle, - .on_exit = &onTabExit, + const view = try View.create(self.alloc, .{ + .on_empty = &onViewEmpty, + .on_title = &onViewTitle, .ctx = tab, }); - errdefer term.destroy(); + errdefer view.destroy(); const id = self.next_id; self.next_id += 1; tab.* = .{ .window = self, - .term = term, + .view = view, .row = gtk.ListBoxRow.new(), .label = gtk.Label.new("shell"), .name = undefined, @@ -211,9 +213,15 @@ pub fn newTab(self: *Window) !void { tab.row.setChild(row_box.as(gtk.Widget)); self.list.append(tab.row.as(gtk.Widget)); - _ = self.stack.addNamed(term.widget(), tab.pageName()); + _ = self.stack.addNamed(view.widget(), tab.pageName()); try self.tabs.append(self.alloc, tab); + + // Only now is `tab` complete enough for the view's callbacks to use, so + // this is the first safe moment to give the view its terminal. + try view.addPane(); + + self.refreshLabel(tab); self.select(tab); } @@ -224,7 +232,7 @@ fn select(self: *Window, tab: *Tab) void { self.stack.setVisibleChildName(tab.pageName()); self.list.selectRow(tab.row); - tab.term.grabFocus(); + tab.view.focus(); } fn indexOf(self: *Window, tab: *Tab) ?usize { @@ -237,11 +245,11 @@ fn closeTab(self: *Window, tab: *Tab) void { if (self.closing) return; const index = self.indexOf(tab) orelse return; - self.stack.remove(tab.term.widget()); + self.stack.remove(tab.view.widget()); self.list.remove(tab.row.as(gtk.Widget)); _ = self.tabs.orderedRemove(index); - tab.term.destroy(); + tab.view.destroy(); self.alloc.destroy(tab); if (self.tabs.items.len == 0) { @@ -279,21 +287,30 @@ fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv } } -fn onTabTitle(ctx: ?*anyopaque, title: []const u8) void { - const tab: *Tab = @ptrCast(@alignCast(ctx.?)); +/// Refresh a sidebar row from its view's current state. +fn refreshLabel(self: *Window, tab: *Tab) void { + _ = self; - // GTK needs a NUL-terminated string, and titles from the terminal are - // arbitrary length, so clamp to something a sidebar row can show. - var buf: [128]u8 = undefined; - const n = @min(title.len, buf.len - 1); - @memcpy(buf[0..n], title[0..n]); - buf[n] = 0; + // GTK needs a NUL-terminated string, and titles come from the terminal so + // they can be any length; clamp to what a sidebar row can show. + var scratch: [192]u8 = undefined; + const text = tab.view.label(scratch[0 .. scratch.len - 1]); - tab.label.setText(buf[0..n :0]); - tab.label.as(gtk.Widget).setTooltipText(buf[0..n :0]); + var buf: [192]u8 = undefined; + @memcpy(buf[0..text.len], text); + buf[text.len] = 0; + + tab.label.setText(buf[0..text.len :0]); + tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]); } -fn onTabExit(ctx: ?*anyopaque) void { +fn onViewTitle(ctx: ?*anyopaque) void { + const tab: *Tab = @ptrCast(@alignCast(ctx.?)); + tab.window.refreshLabel(tab); +} + +/// The view lost its last pane, so the tab goes with it. +fn onViewEmpty(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.window.closeTab(tab); } @@ -306,7 +323,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { // Each terminal owns a session, which owns a PTY and its child process. // Dropping them here reaps the children rather than orphaning them. for (self.tabs.items) |tab| { - tab.term.destroy(); + tab.view.destroy(); self.alloc.destroy(tab); } self.tabs.deinit(self.alloc); @@ -360,7 +377,19 @@ fn onShortcut( return 1; }, gdk.KEY_W, gdk.KEY_w => { - if (self.activeTab()) |tab| self.closeTab(tab); + // Closes the focused terminal. The view raises on_empty when + // its last pane goes, which is what closes the tab. + if (self.activeTab()) |tab| { + if (tab.view.focusedPane()) |pane| tab.view.closePane(pane); + } + return 1; + }, + gdk.KEY_E, gdk.KEY_e => { + if (self.activeTab()) |tab| { + tab.view.addPane() catch |err| { + std.log.err("failed to open terminal: {s}", .{@errorName(err)}); + }; + } return 1; }, gdk.KEY_V, gdk.KEY_v => { @@ -371,6 +400,23 @@ fn onShortcut( } } + // Ctrl+Shift+arrows rearrange the focused terminal within its view. This + // 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) { + 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); + return 1; + } + } + // Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals. if (ctrl and !shift) { switch (keyval) { @@ -434,7 +480,8 @@ fn onPasteReady( defer glib.free(text); const tab = self.activeTab() orelse return; - const session = tab.term.session; + const terminal = tab.view.focusedTerminal() orelse return; + const session = terminal.session; // Coerce to a plain slice: encodePaste dispatches on the exact type. const span: []const u8 = std.mem.span(text); diff --git a/src/style.css b/src/style.css index 4f29cb6..022fcbf 100644 --- a/src/style.css +++ b/src/style.css @@ -60,7 +60,64 @@ } .vtabs-content { + background-color: #0f0d14; +} + +/* A view is the container for one tab's terminals. */ +.vtabs-view { + padding: 6px 6px 6px 0; +} + +/* Each terminal sits in its own rounded frame so multiple panes in a view + read as distinct surfaces. */ +.vtabs-pane { background-color: #16141c; border-radius: 10px; - margin: 6px 6px 6px 0; + border: 1px solid #2a2536; } + +.vtabs-pane.active { + border-color: #5b4d80; +} + +.vtabs-pane-header { + padding: 2px 4px 2px 10px; + background-color: #1c1926; + border-bottom: 1px solid #262133; +} + +.vtabs-pane.active .vtabs-pane-header { + background-color: #241f33; +} + +.vtabs-pane-title { + font-size: 0.82em; + color: #8f87a3; +} + +.vtabs-pane.active .vtabs-pane-title { + color: #ded7ef; +} + +.vtabs-pane-button { + min-width: 22px; + min-height: 22px; + padding: 0; + opacity: 0; +} + +.vtabs-pane:hover .vtabs-pane-button, +.vtabs-pane.active .vtabs-pane-button { + opacity: 0.55; +} + +.vtabs-pane-button:hover { + 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; }