//! The application window: a vertical tab strip down the left side and the //! active terminal filling the rest. //! //! The layout follows Zen Browser's vertical tabs — a persistent sidebar //! column holding the window controls, a "New Tab" affordance, and one row //! per tab, with the content pane inset to its right. const std = @import("std"); const adw = @import("adw"); const gdk = @import("gdk"); const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); const vt = @import("ghostty-vt"); const View = @import("View.zig"); const Window = @This(); const sidebar_width = 220; alloc: std.mem.Allocator, window: *adw.ApplicationWindow, /// Holds one page per tab; the visible page is the active terminal. stack: *gtk.Stack, /// One row per tab, in the same order as `tabs`. list: *gtk.ListBox, tabs: std.ArrayListUnmanaged(*Tab) = .empty, /// Monotonic counter so every tab gets a distinct GtkStack page name. next_id: u32 = 0, /// Set while we're programmatically changing the selection, so that the /// resulting `row-selected` signal doesn't recurse. updating: bool = false, /// Set once teardown has begun, so that a session exiting mid-teardown /// doesn't try to close a tab we're already destroying. closing: bool = false, /// A single tab: a view of one or more terminals, plus the sidebar row that /// selects it. const Tab = struct { window: *Window, view: *View, row: *gtk.ListBoxRow, label: *gtk.Label, name: [16]u8, name_len: usize, fn pageName(self: *const Tab) [:0]const u8 { return self.name[0..self.name_len :0]; } }; pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { const self = try alloc.create(Window); errdefer alloc.destroy(self); const window = adw.ApplicationWindow.new(app.as(gtk.Application)); window.as(gtk.Window).setTitle("vtabs"); window.as(gtk.Window).setDefaultSize(1100, 720); self.* = .{ .alloc = alloc, .window = window, .stack = gtk.Stack.new(), .list = gtk.ListBox.new(), }; window.as(gtk.Widget).addCssClass("vtabs-window"); // ---- sidebar ------------------------------------------------------- const sidebar = gtk.Box.new(.vertical, 0); sidebar.as(gtk.Widget).addCssClass("vtabs-sidebar"); sidebar.as(gtk.Widget).setSizeRequest(sidebar_width, -1); // The header bar lives inside the sidebar rather than spanning the // window, which is what gives the Zen-style look. It also carries the // window controls, which we still need since GTK draws its own // decorations on Wayland. const header = adw.HeaderBar.new(); header.setShowTitle(0); header.as(gtk.Widget).addCssClass("flat"); const new_tab_button = gtk.Button.newFromIconName("tab-new-symbolic"); new_tab_button.as(gtk.Widget).setTooltipText("New tab (Ctrl+Shift+T)"); _ = gtk.Button.signals.clicked.connect( new_tab_button, *Window, &onNewTabClicked, self, .{}, ); header.packEnd(new_tab_button.as(gtk.Widget)); sidebar.append(header.as(gtk.Widget)); self.list.setSelectionMode(.single); self.list.as(gtk.Widget).addCssClass("navigation-sidebar"); self.list.as(gtk.Widget).addCssClass("vtabs-list"); _ = gtk.ListBox.signals.row_selected.connect( self.list, *Window, &onRowSelected, self, .{}, ); const scroller = gtk.ScrolledWindow.new(); scroller.setPolicy(.never, .automatic); scroller.as(gtk.Widget).setVexpand(1); scroller.setChild(self.list.as(gtk.Widget)); sidebar.append(scroller.as(gtk.Widget)); // ---- content ------------------------------------------------------- self.stack.as(gtk.Widget).setHexpand(1); self.stack.as(gtk.Widget).setVexpand(1); self.stack.as(gtk.Widget).addCssClass("vtabs-content"); const content = gtk.Box.new(.horizontal, 0); content.append(sidebar.as(gtk.Widget)); content.append(self.stack.as(gtk.Widget)); window.setContent(content.as(gtk.Widget)); // Window-level shortcuts run in the capture phase so they are handled // before the focused terminal turns the key into a VT sequence. const shortcuts = gtk.EventControllerKey.new(); shortcuts.as(gtk.EventController).setPropagationPhase(.capture); _ = gtk.EventControllerKey.signals.key_pressed.connect( shortcuts, *Window, &onShortcut, self, .{}, ); window.as(gtk.Widget).addController(shortcuts.as(gtk.EventController)); // Free our own state once GTK is done with the window. Doing this on // `destroy` rather than `close-request` means no further events can // arrive for widgets whose user data we're about to free. _ = gtk.Widget.signals.destroy.connect( window, *Window, &onDestroy, self, .{}, ); try self.newTab(); return self; } pub fn present(self: *Window) void { self.window.as(gtk.Window).present(); // Focus has to be grabbed after the window is presented. Calling // 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.view.focus(); } /// Open a new tab and switch to it. pub fn newTab(self: *Window) !void { const tab = try self.alloc.create(Tab); errdefer self.alloc.destroy(tab); const view = try View.create(self.alloc, .{ .on_empty = &onViewEmpty, .on_title = &onViewTitle, .ctx = tab, }); errdefer view.destroy(); const id = self.next_id; self.next_id += 1; tab.* = .{ .window = self, .view = view, .row = gtk.ListBoxRow.new(), .label = gtk.Label.new("shell"), .name = undefined, .name_len = 0, }; const printed = std.fmt.bufPrintZ(&tab.name, "tab{d}", .{id}) catch unreachable; tab.name_len = printed.len; // ---- sidebar row --------------------------------------------------- const row_box = gtk.Box.new(.horizontal, 6); row_box.as(gtk.Widget).addCssClass("vtabs-row"); const icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"); row_box.append(icon.as(gtk.Widget)); tab.label.setXalign(0); tab.label.setEllipsize(.end); tab.label.as(gtk.Widget).setHexpand(1); row_box.append(tab.label.as(gtk.Widget)); const close = gtk.Button.newFromIconName("window-close-symbolic"); close.as(gtk.Widget).addCssClass("flat"); close.as(gtk.Widget).addCssClass("vtabs-close"); _ = gtk.Button.signals.clicked.connect(close, *Tab, &onCloseClicked, tab, .{}); row_box.append(close.as(gtk.Widget)); tab.row.setChild(row_box.as(gtk.Widget)); self.list.append(tab.row.as(gtk.Widget)); _ = 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); } /// Make `tab` the visible one. fn select(self: *Window, tab: *Tab) void { self.updating = true; defer self.updating = false; self.stack.setVisibleChildName(tab.pageName()); self.list.selectRow(tab.row); tab.view.focus(); } fn indexOf(self: *Window, tab: *Tab) ?usize { for (self.tabs.items, 0..) |t, i| if (t == tab) return i; return null; } /// Close a tab, and the window along with it if it was the last one. fn closeTab(self: *Window, tab: *Tab) void { if (self.closing) return; const index = self.indexOf(tab) orelse return; self.stack.remove(tab.view.widget()); self.list.remove(tab.row.as(gtk.Widget)); _ = self.tabs.orderedRemove(index); tab.view.destroy(); self.alloc.destroy(tab); if (self.tabs.items.len == 0) { // Teardown of our own state happens in onDestroy. self.window.as(gtk.Window).close(); return; } // Prefer the tab that took the closed one's place, else the new last. const next = @min(index, self.tabs.items.len - 1); self.select(self.tabs.items[next]); } // ------------------------------------------------------------------------- // Signal handlers fn onNewTabClicked(_: *gtk.Button, self: *Window) callconv(.c) void { self.newTab() catch |err| { std.log.err("failed to open tab: {s}", .{@errorName(err)}); }; } fn onCloseClicked(_: *gtk.Button, tab: *Tab) callconv(.c) void { tab.window.closeTab(tab); } fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv(.c) void { if (self.updating) return; const selected = row orelse return; for (self.tabs.items) |tab| { if (tab.row == selected) { self.select(tab); return; } } } /// 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 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]); 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 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); } /// GTK has finished with the window: release everything we allocated. fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { if (self.closing) return; self.closing = true; // 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.view.destroy(); self.alloc.destroy(tab); } self.tabs.deinit(self.alloc); self.alloc.destroy(self); } // ------------------------------------------------------------------------- // Shortcuts /// The tab whose terminal is currently visible. fn activeTab(self: *Window) ?*Tab { const name = self.stack.getVisibleChildName() orelse return null; const span = std.mem.span(name); for (self.tabs.items) |tab| { if (std.mem.eql(u8, tab.pageName(), span)) return tab; } return null; } fn selectIndex(self: *Window, index: usize) void { if (index >= self.tabs.items.len) return; self.select(self.tabs.items[index]); } /// Move the selection by `delta`, wrapping around the ends. fn cycle(self: *Window, delta: isize) void { if (self.tabs.items.len == 0) return; const current = self.indexOf(self.activeTab() orelse return) orelse return; const len: isize = @intCast(self.tabs.items.len); const next = @mod(@as(isize, @intCast(current)) + delta + len, len); self.selectIndex(@intCast(next)); } fn onShortcut( _: *gtk.EventControllerKey, keyval: c_uint, _: c_uint, state: gdk.ModifierType, self: *Window, ) callconv(.c) c_int { const ctrl = state.control_mask; const shift = state.shift_mask; const alt = state.alt_mask; if (ctrl and shift) { switch (keyval) { gdk.KEY_T, gdk.KEY_t => { self.newTab() catch |err| { std.log.err("failed to open tab: {s}", .{@errorName(err)}); }; return 1; }, gdk.KEY_W, gdk.KEY_w => { // 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 => { self.paste(); return 1; }, else => {}, } } // 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 side: ?View.Side = switch (keyval) { gdk.KEY_Left => .left, gdk.KEY_Right => .right, gdk.KEY_Up => .top, gdk.KEY_Down => .bottom, else => null, }; if (side) |s| { if (self.activeTab()) |tab| tab.view.moveFocused(s); return 1; } } // Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals. if (ctrl and !shift) { switch (keyval) { gdk.KEY_Page_Up => { self.cycle(-1); return 1; }, gdk.KEY_Page_Down => { self.cycle(1); return 1; }, else => {}, } } // Alt+1..9 jumps straight to a tab; Alt+9 is "last tab" by convention. if (alt and !ctrl) { if (keyval >= gdk.KEY_1 and keyval <= gdk.KEY_9) { const n = keyval - gdk.KEY_1; if (n == 8) { self.selectIndex(self.tabs.items.len -| 1); } else { self.selectIndex(@intCast(n)); } return 1; } } return 0; } // ------------------------------------------------------------------------- // Paste // // GTK4's clipboard API is asynchronous, so the read completes on a later // main loop turn. We resolve the destination tab at completion time rather // than capturing it, so closing a tab mid-paste can't leave a dangling // pointer. fn paste(self: *Window) void { const clipboard = self.window.as(gtk.Widget).getClipboard(); clipboard.readTextAsync(null, &onPasteReady, self); } fn onPasteReady( source: ?*gobject.Object, result: *gio.AsyncResult, data: ?*anyopaque, ) callconv(.c) void { const self: *Window = @ptrCast(@alignCast(data.?)); const clipboard: *gdk.Clipboard = @ptrCast(@alignCast(source.?)); var err: ?*glib.Error = null; const text = clipboard.readTextFinish(result, &err) orelse { if (err) |e| { std.log.warn("paste failed: {s}", .{e.f_message orelse "unknown"}); e.free(); } return; }; defer glib.free(text); const tab = self.activeTab() orelse return; 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); // Refuse pastes containing control characters that would execute on // arrival (a newline in unbracketed mode runs the command immediately). const opts: vt.input.PasteOptions = .fromTerminal(&session.term); if (!vt.input.isSafePaste(span)) { std.log.warn("refusing unsafe paste", .{}); return; } const parts = vt.input.encodePaste(span, opts) catch |e| { std.log.warn("paste encode failed: {s}", .{@errorName(e)}); return; }; for (parts) |part| session.write(part); }