//! 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 Browser = @import("Browser.zig"); const Layouts = @import("Layouts.zig"); const OpenLayoutDialog = @import("OpenLayoutDialog.zig"); const Pane = @import("Pane.zig"); const SaveLayoutDialog = @import("SaveLayoutDialog.zig"); const Settings = @import("Settings.zig"); const SettingsDialog = @import("SettingsDialog.zig"); const TabSettingsDialog = @import("TabSettingsDialog.zig"); const Terminal = @import("Terminal.zig"); const View = @import("View.zig"); const appearance = @import("appearance.zig"); const emoji = @import("emoji.zig"); const key = @import("key.zig"); const shortcuts = @import("shortcuts.zig"); const Window = @This(); /// Total width of the sidebar column, margins and border included — GTK folds /// both into a widget's size request. Widened when the sidebar became an inset /// card: the margin, border and roomier row padding all come out of the label, /// and at the old 220 a tab name truncated a good deal earlier than it used to. const sidebar_width = 236; 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`. /// /// Kept in that order by a sort function rather than by moving rows around: /// `tabs` is the single source of truth for tab order, and re-sorting is the /// only way to reorder a `GtkListBox` without taking a row out of it, which /// would drop the selection and the focus along with it. list: *gtk.ListBox, tabs: std.ArrayListUnmanaged(*Tab) = .empty, /// State tracked for the duration of a row drag, null when none is in flight. drag: ?Drag = null, /// 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, /// Saved tab templates, read from the config file at startup. layouts: Layouts, /// The popover listing them. Rebuilt whenever the set changes, since its /// contents are one row per layout. layout_popover: *gtk.Popover, /// Per-row context for the popover's handlers, owned for as long as the rows /// they belong to are on screen. layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty, /// What a sidebar row is signalling. Defined with the dots themselves, since /// a row and a pane header show the same five states for the same reasons. const Attention = Pane.Attention; /// Where a tab came from, when it came from a saved layout. /// /// Kept so that "use these tabs at launch" in the settings page has something /// to write down. A live view can be captured as a *shape* — that is what "save /// tab as layout" does — but the shape is not what a startup entry wants; it /// wants the name of the layout and the values it was opened with, and those are /// only knowable at the moment of opening. So they are recorded then. /// /// Every string is owned by the window's allocator, since the dialog the values /// were typed into is long gone by the time anyone asks. const Source = struct { layout: []u8, values: []Settings.Value, }; /// State tracked while a sidebar row is being dragged to a new position. /// /// Shaped like the pane drag in `View`, and for the same reason: the reorder is /// applied as the pointer moves rather than on the drop, so the sidebar under /// the cursor is always the order you will get. That means a cancelled drag has /// something to undo, which is what `origin` is for. const Drag = struct { tab: *Tab, /// Where the tab sat in `tabs` when the drag began, so a cancelled drag can /// put it back. origin: usize, /// Set once a drop has been accepted; a drag that ends without this was /// cancelled, and the preview has to be undone. committed: bool = false, }; /// A single tab: a view of one or more panes, plus the sidebar row that /// selects it. const Tab = struct { window: *Window, view: *View, row: *gtk.ListBoxRow, label: *gtk.Label, /// Shows what the tab's focused pane is, so a web view is recognisable in /// the sidebar without reading the title. icon: *gtk.Image, /// An emoji the user picked, shown in the icon's place. Unlike `custom_name` /// this is not owned: the glyph points into `emoji.table`, which is static, /// so there is nothing here to copy and nothing to free. emoji: ?[:0]const u8 = null, /// The widget that draws that emoji, sharing the icon's slot in the row. emoji_label: *gtk.Label, /// Status dot, hidden unless the tab has something to report. dot: *gtk.Image, /// A name the user typed, which wins over whatever the panes report. /// Null means the label tracks the content, which is the default. custom_name: ?[]u8 = null, /// The layout this tab was opened from, if any. Null for a plain shell. source: ?Source = null, /// The row's right-click menu, parented to this tab's row. menu_popover: *gtk.Popover, /// Popover holding the rename entry, parented to this tab's row. rename_popover: *gtk.Popover, rename_entry: *gtk.Entry, /// Set when a pane in this tab finished since the last time you opened it. /// /// The row and the panes answer slightly different questions, which is why /// this exists alongside the panes' own latches. The row's question is /// "should I go there?", and opening the tab settles it whether or not you /// then deal with every pane inside. A pane's question is "have you dealt /// with me?", which only you can answer. finished_since_visit: bool = false, name: [16]u8, name_len: usize, fn pageName(self: *const Tab) [:0]const u8 { return self.name[0..self.name_len :0]; } /// What the row should be showing right now. /// /// Both halves have to hold. The flag alone would keep a row lit after you /// answered the last pane in it without leaving the tab; the panes alone /// would keep it lit after you had already come and looked. fn attention(self: *const Tab) Attention { const news = self.finished_since_visit and self.view.anyDoneUnanswered(); return .of(self.view.status(), news); } }; 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("Playpen"); window.as(gtk.Window).setDefaultSize(1100, 720); self.* = .{ .alloc = alloc, .window = window, .stack = gtk.Stack.new(), .list = gtk.ListBox.new(), .layouts = .init(alloc), .layout_popover = gtk.Popover.new(), }; self.layouts.load(); if (self.layouts.load_error) |message| std.log.warn("{s}", .{message}); window.as(gtk.Widget).addCssClass("playpen-window"); // ---- sidebar ------------------------------------------------------- const sidebar = gtk.Box.new(.vertical, 0); sidebar.as(gtk.Widget).addCssClass("playpen-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).addCssClass("playpen-header-button"); // A MenuButton centers its inner button in the header bar, but a plain // Button stretches to fill it; center this one too so the pair match. new_tab_button.as(gtk.Widget).setValign(.center); new_tab_button.as(gtk.Widget).setTooltipText("New tab (Ctrl+Shift+T)"); _ = gtk.Button.signals.clicked.connect( new_tab_button, *Window, &onNewTabClicked, self, .{}, ); // Packed at the start, with the window controls left alone at the end. In // a 220px header the two used to sit right up against the close button, // which both wasted the empty half of the bar and put "new tab" a few // pixels from "close window". header.packStart(new_tab_button.as(gtk.Widget)); // Layouts sit behind their own button rather than replacing the plain // new-tab one: opening an ordinary shell stays a single click. const layout_button = gtk.MenuButton.new(); layout_button.setIconName("view-grid-symbolic"); layout_button.as(gtk.Widget).addCssClass("playpen-header-button"); layout_button.as(gtk.Widget).setTooltipText("Open a saved layout"); layout_button.setPopover(self.layout_popover); self.layout_popover.as(gtk.Widget).addCssClass("playpen-layout-popover"); self.refreshLayoutMenu(); header.packStart(layout_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("playpen-list"); self.list.setSortFunc(&sortRows, self, null); _ = gtk.ListBox.signals.row_selected.connect( self.list, *Window, &onRowSelected, self, .{}, ); // One drop target for the whole list rather than one per row, so that the // gaps between rows and the empty space under the last one are part of it: // a drag to the bottom of the sidebar should land there, not be cancelled // for having missed every row by a few pixels. self.installRowDropTarget(); 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)); // Settings sit at the foot of the sidebar rather than in its header. The // header holds the two things you reach for constantly — a new tab and a // saved layout — and a preferences button is the opposite of that: opened // rarely, and never in a hurry. Below the tab list it stays out of the way // of both, and it is where every other sidebar puts it. const footer = gtk.Box.new(.horizontal, 0); footer.as(gtk.Widget).addCssClass("playpen-sidebar-footer"); const settings_button = gtk.Button.newFromIconName("emblem-system-symbolic"); settings_button.as(gtk.Widget).addCssClass("flat"); settings_button.as(gtk.Widget).addCssClass("playpen-settings-button"); settings_button.as(gtk.Widget).setTooltipText("Settings (Ctrl+,)"); _ = gtk.Button.signals.clicked.connect( settings_button, *Window, &onSettingsClicked, self, .{}, ); footer.append(settings_button.as(gtk.Widget)); sidebar.append(footer.as(gtk.Widget)); // ---- content ------------------------------------------------------- self.stack.as(gtk.Widget).setHexpand(1); self.stack.as(gtk.Widget).setVexpand(1); self.stack.as(gtk.Widget).addCssClass("playpen-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 keys = gtk.EventControllerKey.new(); keys.as(gtk.EventController).setPropagationPhase(.capture); _ = gtk.EventControllerKey.signals.key_pressed.connect( keys, *Window, &onShortcut, self, .{}, ); window.as(gtk.Widget).addController(keys.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, .{}, ); appearance.onChanged(&onAppearanceChanged, self); try self.openStartupTabs(); 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. /// Open a new tab holding a single terminal — the plain case, unchanged by /// layouts existing. pub fn newTab(self: *Window) !void { const tab = try self.newTabEmpty(); // Only once the tab is in `self.tabs` is it complete enough for the // view's callbacks to use, so this is the first safe moment to give the // view its first pane. try tab.view.addPane(.plain(.terminal)); self.refreshLabel(tab); self.select(tab); } /// The tab and its sidebar row, with no panes in the view yet. /// /// Split out from `newTab` because a layout fills the view itself, with a /// whole tree rather than one pane, and the tab has to exist first. fn newTabEmpty(self: *Window) !*Tab { 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, .on_status = &onViewStatus, .on_finished = &onViewFinished, .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"), .icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"), .emoji_label = gtk.Label.new(null), .dot = gtk.Image.newFromIconName(Pane.status_icon), .menu_popover = gtk.Popover.new(), .rename_popover = gtk.Popover.new(), .rename_entry = gtk.Entry.new(), .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("playpen-row"); // Both live in the row, and `refreshLabel` shows exactly one of them. The // emoji is given the icon's width so that a sidebar of mixed rows still // has its labels starting in one column. row_box.append(tab.icon.as(gtk.Widget)); tab.emoji_label.as(gtk.Widget).addCssClass("playpen-tab-emoji"); tab.emoji_label.as(gtk.Widget).setVisible(0); row_box.append(tab.emoji_label.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)); // After the label rather than before it, so the dots down the sidebar // line up in a column instead of being pushed around by title length. tab.dot.as(gtk.Widget).addCssClass("playpen-status-dot"); Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, .none); row_box.append(tab.dot.as(gtk.Widget)); const close = gtk.Button.newFromIconName("window-close-symbolic"); close.as(gtk.Widget).addCssClass("flat"); close.as(gtk.Widget).addCssClass("playpen-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.buildRowMenu(tab, row_box); self.buildRename(tab, row_box); installRowDragSource(tab, row_box); self.list.append(tab.row.as(gtk.Widget)); _ = self.stack.addNamed(view.widget(), tab.pageName()); try self.tabs.append(self.alloc, tab); return tab; } // ------------------------------------------------------------------------- // The row menu // // Right-clicking a row opens a menu rather than going straight to the rename // entry, which is where it used to land. Renaming was the only thing a row // could do, so it was reasonable for the gesture to *be* renaming; now that a // row also has settings behind it, a gesture that silently picks one of the two // would make the other one unreachable by the route people try first. // // It does not select the row it belongs to. Renaming a tab, or giving it an // emoji, is not a reason to go and look at it — often it is the opposite, since // the tab you are labelling is the one you are about to leave alone for a while. /// Attach the row's context menu and the right-click that opens it. fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void { _ = self; const box = gtk.Box.new(.vertical, 2); box.as(gtk.Widget).addCssClass("playpen-row-menu"); box.append(menuItem("Rename", &onMenuRename, tab)); box.append(menuItem("Settings…", &onMenuSettings, tab)); tab.menu_popover.setChild(box.as(gtk.Widget)); tab.menu_popover.setHasArrow(0); tab.menu_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget)); const secondary = gtk.GestureClick.new(); secondary.as(gtk.GestureSingle).setButton(3); _ = gtk.GestureClick.signals.pressed.connect( secondary, *Tab, &onRowSecondary, tab, .{}, ); anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController)); } /// One line of the row menu, styled like the layout menu's rows so the two /// popovers read as the same kind of thing. fn menuItem( text: [:0]const u8, handler: *const fn (*gtk.Button, *Tab) callconv(.c) void, tab: *Tab, ) *gtk.Widget { const button = gtk.Button.newWithLabel(text); button.as(gtk.Widget).addCssClass("flat"); button.setHasFrame(0); if (button.getChild()) |child| child.setHalign(.start); _ = gtk.Button.signals.clicked.connect(button, *Tab, handler, tab, .{}); return button.as(gtk.Widget); } /// Open the menu where the pointer is, rather than centred on the row: with one /// popover per row anchored to the whole row, a fixed position would put the /// menu somewhere you weren't pointing. fn onRowSecondary( _: *gtk.GestureClick, _: c_int, x: f64, y: f64, tab: *Tab, ) callconv(.c) void { const at: gdk.Rectangle = .{ .f_x = @intFromFloat(x), .f_y = @intFromFloat(y), .f_width = 1, .f_height = 1, }; tab.menu_popover.setPointingTo(&at); tab.menu_popover.popup(); } fn onMenuRename(_: *gtk.Button, tab: *Tab) callconv(.c) void { tab.menu_popover.popdown(); tab.window.beginRename(tab); } fn onMenuSettings(_: *gtk.Button, tab: *Tab) callconv(.c) void { tab.menu_popover.popdown(); tab.window.openTabSettings(tab); } // ------------------------------------------------------------------------- // Per-tab settings /// Open the settings for one tab. /// /// The tab is handed over as the dialog's opaque context and resolved again on /// the way back, so the dialog never holds a pointer into anything it owns. What /// it does hold is the tab itself, which is why `closeTab` closes it. fn openTabSettings(self: *Window, tab: *Tab) void { var buf: [128]u8 = undefined; TabSettingsDialog.present( self.alloc, self.window.as(gtk.Window), .{ .tab_name = self.tabName(tab, &buf), .emoji = tab.emoji, }, &onTabEmojiChanged, tab, ) catch |err| { std.log.err("failed to open tab settings: {s}", .{@errorName(err)}); }; } /// The picker chose a glyph, or cleared the choice. The glyph is static, so /// there is nothing to copy and nothing to release. fn onTabEmojiChanged(ctx: ?*anyopaque, glyph: ?[:0]const u8) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.emoji = glyph; tab.window.refreshLabel(tab); } // ------------------------------------------------------------------------- // Renaming // // A tab's label normally follows its panes, which is right up until you have // four of them all reporting some variation on the same thing. A typed name // pins the row to whatever you actually call that tab, and clearing it hands // the label back to the panes. // // The entry lives in a popover anchored to the row rather than in a dialog: // renaming a tab is a one-field edit, and a modal window for it would be a // heavier interruption than the thing being edited. /// Attach the rename popover and the gestures that open it. fn buildRename(self: *Window, tab: *Tab, anchor: *gtk.Box) void { _ = self; const box = gtk.Box.new(.vertical, 6); box.as(gtk.Widget).addCssClass("playpen-rename"); const hint = gtk.Label.new("Tab name — empty to follow the terminal"); hint.setXalign(0); hint.as(gtk.Widget).addCssClass("playpen-dialog-hint"); box.append(hint.as(gtk.Widget)); tab.rename_entry.as(gtk.Widget).setHexpand(1); _ = gtk.Entry.signals.activate.connect( tab.rename_entry, *Tab, &onRenameActivate, tab, .{}, ); box.append(tab.rename_entry.as(gtk.Widget)); tab.rename_popover.setChild(box.as(gtk.Widget)); tab.rename_popover.as(gtk.Widget).addCssClass("playpen-rename-popover"); tab.rename_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget)); // Double-click still goes straight here, without passing through the menu: // it matches how tab strips elsewhere behave, and it is the shortcut worth // keeping for the one thing you rename a tab far more often than you // configure it. const double = gtk.GestureClick.new(); double.as(gtk.GestureSingle).setButton(1); _ = gtk.GestureClick.signals.pressed.connect( double, *Tab, &onRowDoubleClick, tab, .{}, ); anchor.as(gtk.Widget).addController(double.as(gtk.EventController)); } /// Open the rename entry, prefilled with the name the tab is showing now so /// that editing it is a tweak rather than a retype. fn beginRename(self: *Window, tab: *Tab) void { var buf: [192]u8 = undefined; const current = self.tabName(tab, &buf); var z: [192:0]u8 = undefined; const n = @min(current.len, z.len - 1); @memcpy(z[0..n], current[0..n]); z[n] = 0; tab.rename_entry.as(gtk.Editable).setText(z[0..n :0]); tab.rename_entry.as(gtk.Editable).selectRegion(0, -1); tab.rename_popover.popup(); _ = tab.rename_entry.as(gtk.Widget).grabFocus(); } /// Commit whatever is in the entry. Empty clears the custom name, which is /// how you get back to the automatic label without a separate "reset" action. fn onRenameActivate(_: *gtk.Entry, tab: *Tab) callconv(.c) void { const self = tab.window; const typed = std.mem.span(tab.rename_entry.as(gtk.Editable).getText()); const trimmed = std.mem.trim(u8, typed, " \t"); if (tab.custom_name) |old| self.alloc.free(old); tab.custom_name = null; if (trimmed.len > 0) { tab.custom_name = self.alloc.dupe(u8, trimmed) catch |err| blk: { std.log.err("failed to rename tab: {s}", .{@errorName(err)}); break :blk null; }; } tab.rename_popover.popdown(); self.refreshLabel(tab); } fn onRowDoubleClick( _: *gtk.GestureClick, n_press: c_int, _: f64, _: f64, tab: *Tab, ) callconv(.c) void { if (n_press < 2) return; tab.window.beginRename(tab); } // ------------------------------------------------------------------------- // Layouts /// Per-row context for the layout menu. One is allocated per row and freed /// when the menu is rebuilt, so a row's handler always knows which layout it /// belongs to without indexing into a list that may have changed. const LayoutRow = struct { window: *Window, name: []const u8, }; /// Rebuild the popover: one row per saved layout, then the actions. fn refreshLayoutMenu(self: *Window) void { self.freeLayoutRows(); const box = gtk.Box.new(.vertical, 2); box.as(gtk.Widget).addCssClass("playpen-layout-menu"); if (self.layouts.items.items.len == 0) { const empty = gtk.Label.new(if (self.layouts.load_error != null) "Layouts file could not be read" else "No saved layouts yet"); empty.as(gtk.Widget).addCssClass("playpen-layout-empty"); box.append(empty.as(gtk.Widget)); } for (self.layouts.items.items) |layout| { const row = self.alloc.create(LayoutRow) catch continue; row.* = .{ .window = self, .name = layout.name }; self.layout_rows.append(self.alloc, row) catch { self.alloc.destroy(row); continue; }; const line = gtk.Box.new(.horizontal, 4); var label_buf: [128]u8 = undefined; const text = std.fmt.bufPrintZ(&label_buf, "{s}", .{layout.name}) catch continue; const open = gtk.Button.newWithLabel(text); open.as(gtk.Widget).addCssClass("flat"); open.as(gtk.Widget).setHexpand(1); open.setHasFrame(0); if (open.getChild()) |child| child.setHalign(.start); _ = gtk.Button.signals.clicked.connect(open, *LayoutRow, &onLayoutClicked, row, .{}); line.append(open.as(gtk.Widget)); const edit = gtk.Button.newFromIconName("document-edit-symbolic"); edit.as(gtk.Widget).addCssClass("flat"); edit.as(gtk.Widget).setTooltipText("Edit this layout"); _ = gtk.Button.signals.clicked.connect(edit, *LayoutRow, &onLayoutEdit, row, .{}); line.append(edit.as(gtk.Widget)); const delete = gtk.Button.newFromIconName("user-trash-symbolic"); delete.as(gtk.Widget).addCssClass("flat"); delete.as(gtk.Widget).setTooltipText("Delete this layout"); _ = gtk.Button.signals.clicked.connect(delete, *LayoutRow, &onLayoutDelete, row, .{}); line.append(delete.as(gtk.Widget)); box.append(line.as(gtk.Widget)); } box.append(gtk.Separator.new(.horizontal).as(gtk.Widget)); const save = gtk.Button.newWithLabel("Save tab as layout…"); save.as(gtk.Widget).addCssClass("flat"); save.setHasFrame(0); if (save.getChild()) |child| child.setHalign(.start); _ = gtk.Button.signals.clicked.connect(save, *Window, &onSaveLayoutClicked, self, .{}); box.append(save.as(gtk.Widget)); const reload = gtk.Button.newWithLabel("Reload from disk"); reload.as(gtk.Widget).addCssClass("flat"); reload.setHasFrame(0); if (reload.getChild()) |child| child.setHalign(.start); _ = gtk.Button.signals.clicked.connect(reload, *Window, &onReloadLayouts, self, .{}); box.append(reload.as(gtk.Widget)); self.layout_popover.setChild(box.as(gtk.Widget)); } fn freeLayoutRows(self: *Window) void { for (self.layout_rows.items) |row| self.alloc.destroy(row); self.layout_rows.clearRetainingCapacity(); } fn onLayoutClicked(_: *gtk.Button, row: *LayoutRow) callconv(.c) void { const self = row.window; self.layout_popover.popdown(); const layout = self.layouts.find(row.name) orelse return; // Nothing to ask for, so skip straight past the dialog. if (layout.parameters.len == 0) { openLayout(self, layout, &.{}); return; } OpenLayoutDialog.present( self.alloc, self.window.as(gtk.Window), layout, &onLayoutParameters, self, ) catch |err| { std.log.err("failed to open layout dialog: {s}", .{@errorName(err)}); }; } fn onLayoutParameters( ctx: ?*anyopaque, layout: *Layouts.Layout, bindings: []const Layouts.Binding, ) void { const self: *Window = @ptrCast(@alignCast(ctx.?)); openLayout(self, layout, bindings); } /// Open a layout in a new tab and go to it. fn openLayout(self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding) void { const tab = self.buildLayoutTab(layout, bindings) catch |err| { std.log.err("failed to open layout \"{s}\": {s}", .{ layout.name, @errorName(err) }); return; }; self.select(tab); } /// Build a tab holding `layout`, with `bindings` substituted into it. /// /// The tab is left unselected. Opening one from the menu goes to it; the startup /// list opens several and then goes to the first, so which one you land in is /// the caller's decision rather than a side effect of building. fn buildLayoutTab( self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding, ) !*Tab { const tab = try self.newTabEmpty(); tab.view.applyLayout(layout.root, bindings) catch |err| { // A half-built view has no panes to work in and no shell to close, so // drop the tab rather than leave an empty one behind. Discarded rather // than closed: closing the only tab takes the window with it, and at // startup this is reachable before there is another one. if (tab.view.panes.items.len == 0) { self.discardTab(tab); return err; } std.log.err("layout \"{s}\" only partly built: {s}", .{ layout.name, @errorName(err) }); }; self.recordSource(tab, layout.name, bindings); self.refreshLabel(tab); return tab; } /// Edit a saved layout in place: its name, parameters and per-pane scripts. /// /// The arrangement itself isn't editable here — to reshape one, open it, /// rearrange the tab, and save over it under the same name. fn onLayoutEdit(_: *gtk.Button, row: *LayoutRow) callconv(.c) void { const self = row.window; self.layout_popover.popdown(); const layout = self.layouts.find(row.name) orelse return; SaveLayoutDialog.present( self.alloc, self.window.as(gtk.Window), &self.layouts, layout.root, .{ .title = "Edit layout", .confirm = "Save", .name = layout.name, .parameters = layout.parameters, .original_name = layout.name, }, &onLayoutSaved, self, ) catch |err| { std.log.err("failed to open layout editor: {s}", .{@errorName(err)}); }; } fn onLayoutDelete(_: *gtk.Button, row: *LayoutRow) callconv(.c) void { const self = row.window; self.layouts.remove(row.name); self.layouts.save() catch { std.log.err("failed to write layouts file", .{}); }; // Rebuilding frees `row`, so nothing may touch it after this. self.refreshLayoutMenu(); } fn onSaveLayoutClicked(_: *gtk.Button, self: *Window) callconv(.c) void { self.layout_popover.popdown(); self.saveCurrentTabAsLayout(); } fn saveCurrentTabAsLayout(self: *Window) void { const tab = self.activeTab() orelse return; const root = tab.view.capture(self.layouts.builder()) catch |err| { std.log.err("failed to capture layout: {s}", .{@errorName(err)}); return; } orelse return; // The tab's own label is the obvious first guess at a name. var name_buf: [128]u8 = undefined; const suggested = tab.view.label(&name_buf); SaveLayoutDialog.present( self.alloc, self.window.as(gtk.Window), &self.layouts, root, .{ .title = "Save tab as layout", .confirm = "Save", .name = suggested, }, &onLayoutSaved, self, ) catch |err| { std.log.err("failed to open save dialog: {s}", .{@errorName(err)}); }; } fn onLayoutSaved(ctx: ?*anyopaque) void { const self: *Window = @ptrCast(@alignCast(ctx.?)); self.refreshLayoutMenu(); } fn onReloadLayouts(_: *gtk.Button, self: *Window) callconv(.c) void { self.layout_popover.popdown(); self.layouts.load(); if (self.layouts.load_error) |message| std.log.warn("{s}", .{message}); self.refreshLayoutMenu(); } // ------------------------------------------------------------------------- // Startup tabs // // The window opens itself out of the settings' startup list: one tab per entry, // each naming a saved layout and the values to fill its parameters in with. It // is deliberately a list of recipes rather than a snapshot of a previous // session — three layouts against three worktrees is a thing you can *write // down*, and a window's worth of live shells is not. Nothing here restores a // scrollback or a running command; it re-runs the arrangement, which is the part // that was tedious to set up by hand every morning. // // A list that opens nothing at all still has to leave a window you can type in, // so a missing layout costs its tab and an empty list falls back to the plain // single-shell window the app opened with before this existed. /// Open the tabs the settings ask for, or one plain shell when they ask for /// nothing. fn openStartupTabs(self: *Window) !void { for (Settings.get().startup) |entry| self.openStartupTab(entry); if (self.tabs.items.len == 0) { try self.newTab(); return; } // The first, not the last: a startup list reads top to bottom, and the tab // you want to be looking at is the one you put at the top of it. self.select(self.tabs.items[0]); } /// Open one entry. A failure is reported and skipped — the other tabs are still /// worth having, and a window that refused to open because the fourth of six /// layouts had been renamed would be a poor trade. fn openStartupTab(self: *Window, entry: Settings.StartupTab) void { const tab = self.buildStartupTab(entry) catch |err| { std.log.warn("could not open startup tab \"{s}\": {s}", .{ if (entry.layout.len > 0) entry.layout else "shell", @errorName(err), }); return; } orelse return; self.applyStartupChrome(tab, entry); self.refreshLabel(tab); } /// The tab for one entry, or null when it names a layout that no longer exists. fn buildStartupTab(self: *Window, entry: Settings.StartupTab) !?*Tab { // No layout named is the plain case, and worth supporting: a startup list // is often two configured tabs and one ordinary shell to work in. if (entry.layout.len == 0) { const tab = try self.newTabEmpty(); errdefer self.discardTab(tab); try tab.view.addPane(.plain(.terminal)); return tab; } const layout = self.layouts.find(entry.layout) orelse { // Renamed or deleted since the list was written. Worth saying out loud: // the alternative is a window that is quietly one tab short. std.log.warn("startup: no layout named \"{s}\"", .{entry.layout}); return null; }; const bindings = try self.startupBindings(layout, entry); defer self.alloc.free(bindings); return try self.buildLayoutTab(layout, bindings); } /// What to open a layout's parameters with: the values the entry names, then /// every declared parameter it doesn't name, at that parameter's own default. /// The entry's values come first, and `expand` takes the first match, so an /// entry always wins over a default. /// /// A value for something the layout doesn't declare is kept rather than dropped. /// A script may refer to `{{anything}}` whether or not the layout declared it, /// and an entry that fills one in is far more likely to know something the /// declaration list has fallen behind on than to be wrong. /// /// Every string here is borrowed — from the settings arena or the layouts arena, /// both of which outlive the tab — so only the slice itself is allocated. fn startupBindings( self: *Window, layout: *Layouts.Layout, entry: Settings.StartupTab, ) ![]Layouts.Binding { var out: std.ArrayListUnmanaged(Layouts.Binding) = .empty; errdefer out.deinit(self.alloc); try out.ensureTotalCapacity(self.alloc, entry.parameters.len + layout.parameters.len); for (entry.parameters) |v| { out.appendAssumeCapacity(.{ .name = v.name, .value = v.value }); } for (layout.parameters) |p| { if (namesValue(entry.parameters, p.name)) continue; out.appendAssumeCapacity(.{ .name = p.name, .value = p.default }); } return out.toOwnedSlice(self.alloc); } fn namesValue(values: []const Settings.Value, name: []const u8) bool { for (values) |v| { if (std.mem.eql(u8, v.name, name)) return true; } return false; } /// The name and emoji an entry pins on its tab, both behaving exactly as though /// they had been set by hand once it was open. fn applyStartupChrome(self: *Window, tab: *Tab, entry: Settings.StartupTab) void { if (entry.name.len > 0) { tab.custom_name = self.alloc.dupe(u8, entry.name) catch |err| blk: { std.log.warn("could not name startup tab: {s}", .{@errorName(err)}); break :blk null; }; } // Resolved against the emoji table rather than copied, because a row holds // a pointer into that table and nothing else. A glyph that isn't in it is // a hand-edited file naming something this build can't draw. if (entry.emoji.len > 0) { tab.emoji = emoji.lookup(entry.emoji); if (tab.emoji == null) { std.log.warn("startup: \"{s}\" is not an emoji this build knows", .{entry.emoji}); } } } /// Remember what a tab was opened with, so the settings page can write it down /// later. Best effort: failing to record it costs the tab its place in a /// captured list, which is not a reason to refuse to open it. fn recordSource( self: *Window, tab: *Tab, layout_name: []const u8, bindings: []const Layouts.Binding, ) void { self.freeSource(tab); tab.source = self.captureSource(layout_name, bindings) catch |err| { std.log.warn("could not record how a tab was opened: {s}", .{@errorName(err)}); return; }; } fn captureSource( self: *Window, layout_name: []const u8, bindings: []const Layouts.Binding, ) !Source { const layout = try self.alloc.dupe(u8, layout_name); errdefer self.alloc.free(layout); var values: std.ArrayListUnmanaged(Settings.Value) = .empty; errdefer { for (values.items) |v| { self.alloc.free(v.name); self.alloc.free(v.value); } values.deinit(self.alloc); } for (bindings) |b| { const name = try self.alloc.dupe(u8, b.name); errdefer self.alloc.free(name); const value = try self.alloc.dupe(u8, b.value); try values.append(self.alloc, .{ .name = name, .value = value }); } return .{ .layout = layout, .values = try values.toOwnedSlice(self.alloc) }; } fn freeSource(self: *Window, tab: *Tab) void { const source = tab.source orelse return; for (source.values) |v| { self.alloc.free(v.name); self.alloc.free(v.value); } self.alloc.free(source.values); self.alloc.free(source.layout); tab.source = null; } /// Write the open tabs into the settings as the startup list, in sidebar order. /// /// What each tab contributes is its recipe — the layout it was opened from and /// the values it was opened with — plus whatever name and emoji it is wearing. A /// tab opened as a plain shell contributes a plain shell. What is deliberately /// not captured is where the shells have wandered to since: that would be a /// snapshot with an expiry date, and the layout it came from is the thing the /// user actually maintains. fn captureStartupTabs(ctx: ?*anyopaque) void { const self: *Window = @ptrCast(@alignCast(ctx.?)); var entries: std.ArrayListUnmanaged(Settings.StartupTab) = .empty; defer entries.deinit(self.alloc); for (self.tabs.items) |tab| { entries.append(self.alloc, .{ .layout = if (tab.source) |s| s.layout else "", .name = tab.custom_name orelse "", .emoji = tab.emoji orelse "", .parameters = if (tab.source) |s| s.values else &.{}, }) catch |err| { std.log.err("could not capture the open tabs: {s}", .{@errorName(err)}); return; }; } const settings = Settings.get(); settings.setStartup(entries.items) catch |err| { std.log.err("could not capture the open tabs: {s}", .{@errorName(err)}); return; }; settings.save() catch { std.log.err("failed to save settings", .{}); }; } /// 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(); // Opening the tab is the acknowledgement the row was asking for, and the // pane you land in counts as answered along with it. Any other pane in a // split keeps its own dot until you go to it. tab.finished_since_visit = false; tab.view.answerFocused(); self.refreshStatus(tab); } fn indexOf(self: *Window, tab: *Tab) ?usize { for (self.tabs.items, 0..) |t, i| if (t == tab) return i; return null; } // ------------------------------------------------------------------------- // Reordering rows // // Dragging a row up or down the sidebar moves the tab, which is the order // everything else reads: `Ctrl+1`..`Ctrl+9`, next/previous tab, and the startup // list captured from "use current tabs" all go through `tabs`. So the drag has // only one thing to change, and the sidebar follows it. // // The payload is the same fixed string the pane drag uses, and both drop targets // accept plain strings, so a pane dragged over the sidebar reaches this code and // a row dragged over a pane reaches that one. Neither can do anything with the // other: each checks its *own* drag state first and refuses when there is none, // which is what keeps the two kinds of drag from crossing. const row_drag_payload = "playpen-tab"; /// Where a row sits in `tabs`, which is the order the sidebar is sorted by. /// /// A row GTK asks about before its tab has been recorded sorts last. That /// happens once per tab: a new row goes into the list — which sorts it — a /// moment before `tabs` learns about it, and last is where it belongs anyway. fn orderOf(self: *Window, row: *gtk.ListBoxRow) usize { for (self.tabs.items, 0..) |tab, i| if (tab.row == row) return i; return std.math.maxInt(usize); } fn sortRows(a: *gtk.ListBoxRow, b: *gtk.ListBoxRow, data: ?*anyopaque) callconv(.c) c_int { const self: *Window = @ptrCast(@alignCast(data.?)); const ia = self.orderOf(a); const ib = self.orderOf(b); if (ia < ib) return -1; if (ia > ib) return 1; return 0; } /// Put the tab at `from` at index `to`, sliding the tabs between them along, and /// re-sort the sidebar to match. fn moveTab(self: *Window, from: usize, to: usize) void { if (from == to) return; const tab = self.tabs.orderedRemove(from); // Capacity is guaranteed: the element being put back was just taken out of // this same list. self.tabs.insertAssumeCapacity(to, tab); self.list.invalidateSort(); } /// Which slot a drop at `y` — in list coordinates — would put the dragged tab /// in, as an index into `tabs` with that tab taken out. /// /// Counts the rows the pointer has passed the midpoint of, skipping the dragged /// row itself: its own position is what is being decided, and measuring against /// where it currently sits is what would make the order oscillate as the rows /// move out from under the cursor. Points above the first row and below the last /// fall out as the first and last slot without needing a case of their own. fn slotAt(self: *Window, dragged: *Tab, y: f64) usize { const list = self.list.as(gtk.Widget); var slot: usize = 0; for (self.tabs.items) |tab| { if (tab == dragged) continue; const row = tab.row.as(gtk.Widget); var rx: f64 = 0; var ry: f64 = 0; if (list.translateCoordinates(row, 0, y, &rx, &ry) == 0) continue; const height: f64 = @floatFromInt(row.getHeight()); if (ry < height / 2) break; slot += 1; } return slot; } fn installRowDragSource(tab: *Tab, anchor: *gtk.Box) void { const source = gtk.DragSource.new(); source.setActions(.{ .move = true }); _ = gtk.DragSource.signals.prepare.connect(source, *Tab, &onRowDragPrepare, tab, .{}); _ = gtk.DragSource.signals.drag_begin.connect(source, *Tab, &onRowDragBegin, tab, .{}); _ = gtk.DragSource.signals.drag_end.connect(source, *Tab, &onRowDragEnd, tab, .{}); // Left to itself GTK draws the payload as the drag icon, which would put the // literal string "playpen-tab" under the cursor. The row is what is being // carried, so the row is what should be drawn: a paintable of it follows the // pointer while the real one stays dimmed in place. const ghost = gtk.WidgetPaintable.new(anchor.as(gtk.Widget)); defer ghost.as(gobject.Object).unref(); source.setIcon(ghost.as(gdk.Paintable), 0, 0); // On the row's box rather than the row, so the close button keeps its own // presses: a controller on a child claims the gesture before this one sees // it. Clicking to select a tab still works either way — a drag source only // takes the sequence once the pointer has moved past the drag threshold. anchor.as(gtk.Widget).addController(source.as(gtk.EventController)); } fn installRowDropTarget(self: *Window) void { const target = gtk.DropTarget.new(gobject.ext.types.string, .{ .move = true }); _ = gtk.DropTarget.signals.motion.connect(target, *Window, &onRowDropMotion, self, .{}); _ = gtk.DropTarget.signals.drop.connect(target, *Window, &onRowDrop, self, .{}); self.list.as(gtk.Widget).addController(target.as(gtk.EventController)); } fn onRowDragPrepare( _: *gtk.DragSource, _: f64, _: f64, tab: *Tab, ) callconv(.c) ?*gdk.ContentProvider { const self = tab.window; // A lone tab has nothing to be reordered against, so there is no preview to // show and no drop that could change anything. Refusing the drag is better // than starting one that can only ever be cancelled. if (self.tabs.items.len < 2) return null; self.drag = .{ .tab = tab, .origin = self.indexOf(tab) orelse return null }; tab.row.as(gtk.Widget).addCssClass("dragging"); var value = gobject.ext.Value.newFrom(@as([*:0]const u8, row_drag_payload)); return gdk.ContentProvider.newForValue(&value); } /// Style the surface GTK carries the row in. /// /// It is styled from here rather than by its `dnd` node, which every drag icon /// in the app shares: a rule on that would restyle the pane drag's icon too, and /// a pane's drag icon has nothing to do with this one. fn onRowDragBegin(_: *gtk.DragSource, drag: *gdk.Drag, _: *Tab) callconv(.c) void { const icon = gtk.DragIcon.getForDrag(drag); icon.as(gtk.Widget).addCssClass("playpen-tab-drag"); } /// Move the dragged row to where a drop at this point would leave it. fn previewRowDrag(self: *Window, y: f64) void { const drag = self.drag orelse return; const from = self.indexOf(drag.tab) orelse return; self.moveTab(from, self.slotAt(drag.tab, y)); } fn onRowDropMotion(_: *gtk.DropTarget, _: f64, y: f64, self: *Window) callconv(.c) gdk.DragAction { // A pane being dragged over the sidebar, not a row: it carries the same kind // of payload, but there is no row drag for it to be part of. if (self.drag == null) return .{}; self.previewRowDrag(y); return .{ .move = true }; } fn onRowDrop( _: *gtk.DropTarget, _: *gobject.Value, _: f64, y: f64, self: *Window, ) callconv(.c) c_int { if (self.drag == null) return 0; // The preview has usually already applied this, but a drop without any // intervening motion still needs the move performed. self.previewRowDrag(y); self.drag.?.committed = true; return 1; } /// End of a drag. If no drop was accepted, put the row back where it started. fn onRowDragEnd(_: *gtk.DragSource, _: *gdk.Drag, _: c_int, tab: *Tab) callconv(.c) void { const self = tab.window; const drag = self.drag orelse return; self.drag = null; tab.row.as(gtk.Widget).removeCssClass("dragging"); if (drag.committed) return; // Only the dragged tab ever moved, so the rest of the list still has its // original order and putting this one back at its original index restores // the arrangement the drag started from. const now = self.indexOf(drag.tab) orelse return; self.moveTab(now, drag.origin); } /// Take a tab out of the window and free it, with no view about what should be /// selected next or whether anything is left. /// /// `closeTab` is the one to reach for. This is the half of it the startup path /// needs, where a tab that couldn't be built has to go away without taking the /// window down with it — which closing the only tab would do, before there is /// another one to fall back to. fn discardTab(self: *Window, tab: *Tab) void { const index = self.indexOf(tab) orelse return; // A tab closing mid-drag invalidates the recorded origin, since the indices // after this one all shift down — and if it is the dragged tab itself there // is nothing left to put anywhere. Either way the drag has to end without // trying to undo itself. if (self.drag) |drag| { if (drag.tab == tab) self.drag = null else self.drag.?.committed = true; } self.stack.remove(tab.view.widget()); // The dialog holds this tab as an opaque pointer, so it has to go first. TabSettingsDialog.closeFor(tab); // A popover attached with setParent is not an ordinary child, so it has // to be detached by hand; letting the row take it down warns instead. tab.menu_popover.as(gtk.Widget).unparent(); tab.rename_popover.as(gtk.Widget).unparent(); self.list.remove(tab.row.as(gtk.Widget)); _ = self.tabs.orderedRemove(index); tab.view.destroy(); self.releaseTab(tab); } /// Free a tab's own allocations. The view is not one of them — it is destroyed /// by whoever took the tab out of the window, which on the teardown path is not /// the same code. fn releaseTab(self: *Window, tab: *Tab) void { if (tab.custom_name) |name| self.alloc.free(name); self.freeSource(tab); self.alloc.destroy(tab); } /// 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.discardTab(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]); } // ------------------------------------------------------------------------- // Settings fn openSettings(self: *Window) void { SettingsDialog.present(self.alloc, self.window.as(gtk.Window), .{ .layouts = &self.layouts, .on_capture = &captureStartupTabs, .ctx = self, }) catch |err| { std.log.err("failed to open settings: {s}", .{@errorName(err)}); }; } fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void { self.openSettings(); } /// The colour scheme changed. Everything styled by CSS restyles itself; the /// terminal grids do not, because Cairo draws them from `theme.zig` and GTK /// has no idea that widget's contents depend on the palette at all. Without /// this, switching to light leaves every terminal a dark rectangle until /// something else happens to dirty it. /// /// Sessions already open are re-palletted rather than left on the one they /// started in: a shell you have had running all day is exactly the one you are /// looking at when you switch, and leaving it in the old scheme's colours /// would make the setting look like it only applies to new tabs. fn onAppearanceChanged(ctx: ?*anyopaque) void { const self: *Window = @ptrCast(@alignCast(ctx.?)); for (self.tabs.items) |tab| { for (tab.view.panes.items) |pane| { const terminal = pane.terminal() orelse continue; terminal.session.refreshPalette(); terminal.area.as(gtk.Widget).queueDraw(); } } } // ------------------------------------------------------------------------- // 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; } } } /// The text a tab's row should show: the name the user typed, or failing /// that whatever the panes are reporting. fn tabName(self: *Window, tab: *Tab, buf: []u8) []const u8 { _ = self; if (tab.custom_name) |name| { const n = @min(name.len, buf.len); @memcpy(buf[0..n], name[0..n]); return buf[0..n]; } return tab.view.label(buf); } /// Refresh a sidebar row from its view's current state. fn refreshLabel(self: *Window, tab: *Tab) void { // 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 = self.tabName(tab, 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]); // An emoji replaces the icon rather than joining it. The row has one slot // for "what is this tab", and filling it twice would spend twice the width // saying it once — width the label is short of already. if (tab.emoji) |glyph| { tab.emoji_label.setText(glyph); tab.emoji_label.as(gtk.Widget).setVisible(1); tab.icon.as(gtk.Widget).setVisible(0); } else { tab.emoji_label.as(gtk.Widget).setVisible(0); tab.icon.as(gtk.Widget).setVisible(1); tab.icon.setFromIconName(tab.view.iconName()); } self.refreshStatus(tab); } /// Refresh just the status dot. Split out from `refreshLabel` because a /// pane changing state doesn't change any of the text. fn refreshStatus(self: *Window, tab: *Tab) void { _ = self; Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, tab.attention()); } fn onViewTitle(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.window.refreshLabel(tab); } /// A pane in this tab changed state, or answered one it was carrying. fn onViewStatus(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.window.refreshStatus(tab); } /// A pane in this tab finished work. /// /// Recorded even when the tab is the one on screen: you may well have watched /// it stop and then gone somewhere else, and the next time you open this tab is /// when that stops being news. fn onViewFinished(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.finished_since_visit = true; tab.window.refreshStatus(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; // Before anything is freed: a scheme change arriving mid-teardown would // otherwise walk a tab list we are about to destroy. The sort function reads // that same list, and holds this window as its user data, so it goes now for // the same reason. appearance.clearOnChanged(); self.list.setSortFunc(null, null, null); // The settings page holds this window's layout store and a pointer back to // the window itself. It is modal, so you cannot close the window underneath // it by hand — but a shell exiting can take the last tab and so the window, // which makes this reachable. SettingsDialog.close(); // 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| { // As in closeTab: the tab settings dialog is not a child of this // window, so nothing else takes it down before the tab it points at. TabSettingsDialog.closeFor(tab); tab.view.destroy(); self.releaseTab(tab); } self.tabs.deinit(self.alloc); self.freeLayoutRows(); self.layout_rows.deinit(self.alloc); self.layouts.deinit(); 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; } /// The focused terminal of the visible tab, or null when a web pane has focus. fn focusedTerminal(self: *Window) ?*Terminal { const tab = self.activeTab() orelse return null; return tab.view.focusedTerminal(); } /// The focused web view of the visible tab, or null when a terminal has focus. fn focusedBrowser(self: *Window) ?*Browser { const tab = self.activeTab() orelse return null; return tab.view.focusedBrowser(); } /// Split the visible tab's focused pane, adding a pane of the given kind. fn addPane(self: *Window, kind: View.Kind) void { const tab = self.activeTab() orelse return; tab.view.addPane(.plain(kind)) catch |err| { std.log.err("failed to open {s} pane: {s}", .{ @tagName(kind), @errorName(err) }); }; } 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)); } /// Turn a key press into an action, and run it. /// /// This used to be a switch over keyvals, and is now a table lookup, because /// the chords are configurable — see `shortcuts.zig`. What is left here is the /// translation into a chord and the doing of each action; which chord means /// which action is no longer this file's business. /// /// The modifier match is exact, which the switch it replaced was not: it tested /// `ctrl and shift` and so also fired on Ctrl+Alt+Shift+T. Requiring the whole /// set to agree is what makes two chords over the same key — Alt+J and /// Alt+Shift+J — reliably different things. fn onShortcut( _: *gtk.EventControllerKey, keyval: c_uint, _: c_uint, state: gdk.ModifierType, self: *Window, ) callconv(.c) c_int { const mods: shortcuts.Mods = .{ .ctrl = state.control_mask, .alt = state.alt_mask, .shift = state.shift_mask, .super = state.super_mask, }; // Every shortcut needs one of these, so ordinary typing — which arrives // here first, on every key — is declined before anything is looked up. if (!mods.claiming()) return 0; // Shift turns the letter keys into their capitals, and a chord is written // as the key you press rather than the character it produces. const key_val = key.keyFromKeyval(gdk.keyvalToLower(keyval)) orelse return 0; const action = shortcuts.actionFor( .{ .mods = mods, .key = key_val }, &Settings.get().keys, ) orelse return 0; return if (self.perform(action)) 1 else 0; } /// Run one action. Returns whether the key was used, which is not the same as /// whether anything happened: an action that has nothing to act on here — copy /// with no selection, find outside a web pane — declines the key so that /// whatever is focused gets it instead, while one that simply had nowhere to go /// still swallows it rather than sending a stray control code to a shell. fn perform(self: *Window, action: shortcuts.Action) bool { switch (action) { .new_tab => { self.newTab() catch |err| { std.log.err("failed to open tab: {s}", .{@errorName(err)}); }; }, // Closes the focused pane. The view raises on_empty when its last pane // goes, which is what closes the tab. .close_pane => if (self.activeTab()) |tab| { if (tab.view.focusedPane()) |pane| tab.view.closePane(pane); }, .new_terminal => self.addPane(.terminal), .new_web => self.addPane(.web), .rename_tab => if (self.activeTab()) |tab| self.beginRename(tab), .toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(), .open_settings => self.openSettings(), // Only a terminal needs us to encode a paste for it. A web pane has its // own clipboard handling, so the key is left alone rather than // swallowed here. .paste => { const terminal = self.focusedTerminal() orelse return false; terminal.pasteFrom(.standard); }, // With nothing selected the key is declined rather than swallowed, so a // web pane's own copy still works and a terminal still receives it. .copy => { const terminal = self.focusedTerminal() orelse return false; return terminal.copySelection(.standard); }, // Find-in-page, on the chord every browser uses. In a terminal Ctrl+F // is an ordinary control character that the program running there is // waiting for, so this only claims the key over a web pane. .find => { const browser = self.focusedBrowser() orelse return false; browser.openFind(); }, .prev_tab => self.cycle(-1), .next_tab => self.cycle(1), // Moving focus between panes. A view edge with nothing beyond it stops // the move, but still takes the key: the chord was bound for navigating, // and sending it on to the shell at the edge of a split would be a // control code nobody asked for. .focus_pane_left => _ = self.focusNeighbor(.left), .focus_pane_right => _ = self.focusNeighbor(.right), .focus_pane_up => _ = self.focusNeighbor(.top), .focus_pane_down => _ = self.focusNeighbor(.bottom), // Moving the pane itself, which is the keyboard route to the same // rearranging that dragging a pane's header does. .move_pane_left => self.movePane(.left), .move_pane_right => self.movePane(.right), .move_pane_up => self.movePane(.top), .move_pane_down => self.movePane(.bottom), .select_tab_1 => self.selectIndex(0), .select_tab_2 => self.selectIndex(1), .select_tab_3 => self.selectIndex(2), .select_tab_4 => self.selectIndex(3), .select_tab_5 => self.selectIndex(4), .select_tab_6 => self.selectIndex(5), .select_tab_7 => self.selectIndex(6), .select_tab_8 => self.selectIndex(7), .select_last_tab => self.selectIndex(self.tabs.items.len -| 1), } return true; } fn focusNeighbor(self: *Window, side: View.Side) bool { const tab = self.activeTab() orelse return false; return tab.view.focusNeighbor(side); } fn movePane(self: *Window, side: View.Side) void { const tab = self.activeTab() orelse return; tab.view.moveFocused(side); }