//! 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 Review = @import("Review.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 review = @import("review.zig"); const shortcuts = @import("shortcuts.zig"); const Window = @This(); /// Default 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. /// /// A default rather than a fixture now that the divider beside the sidebar is /// draggable: it is where the column starts, and where the collapse toggle /// returns to when nothing has been dragged. const sidebar_width = 236; /// The sidebar folded down to its leading column: the margin, border, list and /// row padding, and the 16px slot the emoji (or kind icon) draws in. Nothing /// else fits at this width, which is the point — see `applySidebarCollapsed`. const sidebar_collapsed_width = 56; alloc: std.mem.Allocator, window: *adw.ApplicationWindow, /// Holds one page per tab; the visible page is the active terminal. stack: *gtk.Stack, /// The paned holding the sidebar and the content, whose divider position *is* /// the sidebar's width. A paned rather than a box so the divider is draggable. split: *gtk.Paned, /// Sidebar chrome that collapsing has to reach: the header (whose window /// controls go), the layouts button beside the new-tab one, the footer (which /// stacks its buttons when the column is narrow) and the toggle itself. header: *adw.HeaderBar, layout_button: *gtk.MenuButton, footer: *gtk.Box, collapse_button: *gtk.Button, /// Whether the sidebar is folded down to its emoji column. sidebar_collapsed: bool = false, /// The width to put back when the sidebar expands: wherever the divider last /// sat while the chrome was showing, so a collapse costs no arrangement. sidebar_expanded_width: c_int = sidebar_width, /// The position a drag has to reach before a collapsed sidebar unfolds. Set to /// the expanded minimum measured at the moment of collapse, so folding and /// unfolding by drag happen at the same width. sidebar_expand_at: c_int = sidebar_width, /// Set while we're moving the divider ourselves, so the position handler /// doesn't mistake it for a drag. applying_position: bool = false, /// 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, /// Set once quitting has been settled, so the `close-request` that follows goes /// straight through instead of asking the same question twice. See `quit`. quit_confirmed: bool = false, /// Set while the confirmation is on screen. /// /// Belt and braces. libadwaita gets to a close-request before this does while /// one of its dialogs is open, and answers it by closing the dialog — so a /// second press of the window-manager binding cancels the question rather than /// reaching here at all. This is what catches it if that ever stops being true, /// because the alternative is a second dialog stacked on the first. It stays in /// step either way: every route out of the dialog emits `response`, including /// the one libadwaita takes. confirming_quit: 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, /// The row's close button, hidden along with the label while the sidebar /// is collapsed. close: *gtk.Button, /// 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, /// This tab's review endpoint — `http://127.0.0.1:/t/` — owned /// by the window's allocator, or empty when the review server never started. /// /// Every tab has one from the moment it exists, whether or not it has a /// review pane, because it is what its terminals are handed as /// `PLAYPEN_REVIEW_URL`. An agent started in a tab should not have to be /// restarted because a review pane opened after it did. review_url: []u8 = &.{}, /// 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(), .split = gtk.Paned.new(.horizontal), .list = gtk.ListBox.new(), .layouts = .init(alloc), .layout_popover = gtk.Popover.new(), .header = adw.HeaderBar.new(), .layout_button = gtk.MenuButton.new(), .footer = gtk.Box.new(.horizontal, 0), .collapse_button = gtk.Button.newFromIconName("go-previous-symbolic"), }; 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"); // The request is the *floor*, not the width: the paned's divider decides // how wide the column actually is, and this is as far in as it may be // pushed once the chrome is out of the way. sidebar.as(gtk.Widget).setSizeRequest(sidebar_collapsed_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 = self.header; 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 = self.layout_button; 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 = self.footer; 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)); // An empty stretch between the two buttons, so the collapse toggle sits at // the far end. It has no height of its own, so when the collapsed footer // turns vertical it simply does nothing rather than pushing the buttons // apart. const footer_spacer = gtk.Box.new(.horizontal, 0); footer_spacer.as(gtk.Widget).setHexpand(1); footer.append(footer_spacer.as(gtk.Widget)); self.collapse_button.as(gtk.Widget).addCssClass("flat"); self.collapse_button.as(gtk.Widget).addCssClass("playpen-settings-button"); self.collapse_button.as(gtk.Widget).setTooltipText("Collapse the sidebar (Ctrl+Shift+S)"); _ = gtk.Button.signals.clicked.connect( self.collapse_button, *Window, &onCollapseClicked, self, .{}, ); footer.append(self.collapse_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"); // The divider between the sidebar and the content is the 6px gutter that // was already there, now draggable. Only the content flexes with the // window — the sidebar keeps whatever width it was given — and neither // side may be squeezed below its minimum, which for the sidebar is the // emoji-only column. self.split.as(gtk.Widget).addCssClass("playpen-root-split"); self.split.setStartChild(sidebar.as(gtk.Widget)); self.split.setEndChild(self.stack.as(gtk.Widget)); self.split.setResizeStartChild(0); self.split.setResizeEndChild(1); self.split.setShrinkStartChild(0); self.split.setShrinkEndChild(0); self.split.setWideHandle(1); self.setSidebarPosition(sidebar_width); _ = gobject.Object.signals.notify.connect( self.split, *Window, &onSidebarPosition, self, .{ .detail = "position" }, ); window.setContent(self.split.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)); // Asking before the window goes away. This has to be `close-request` // rather than `destroy`: it is the one signal that can still say no, and // by the time `destroy` arrives the decision has been made. _ = gtk.Window.signals.close_request.connect( window, *Window, &onCloseRequest, self, .{}, ); // 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, .on_review = &onViewReview, .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), .close = gtk.Button.newFromIconName("window-close-symbolic"), .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)); tab.close.as(gtk.Widget).addCssClass("flat"); tab.close.as(gtk.Widget).addCssClass("playpen-close"); _ = gtk.Button.signals.clicked.connect(tab.close, *Tab, &onCloseClicked, tab, .{}); row_box.append(tab.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)); // A tab opened while the sidebar is collapsed starts collapsed too. self.applyRowCollapsed(tab); _ = self.stack.addNamed(view.widget(), tab.pageName()); try self.tabs.append(self.alloc, tab); // The tab is announced to the review server as soon as it exists, so its // endpoint is real before the first shell in it starts. Which repository the // endpoint reviews is decided later: from the directory the tab is working // in when a review pane is opened by hand (`openReview`), or from the // directory a layout named (`bindLayoutReview`). self.bindReview(tab); return tab; } /// Give a tab its review endpoint and tell the server the tab exists. /// /// Best-effort throughout: a tab with no endpoint is a tab whose terminals get /// no `PLAYPEN_REVIEW_URL` and whose review pane explains itself, which is a /// smaller problem than refusing to open the tab. fn bindReview(self: *Window, tab: *Tab) void { const server = review.get() orelse return; server.registerTab(tab.pageName()) catch |err| { std.log.warn("review: could not register {s}: {s}", .{ tab.pageName(), @errorName(err), }); return; }; var buf: [256]u8 = undefined; const url = server.tabUrl(&buf, tab.pageName()) catch return; tab.review_url = self.alloc.dupe(u8, url) catch return; tab.view.review_spec = .{ .url = tab.review_url }; } // ------------------------------------------------------------------------- // 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(); // Before the panes, not after: a review pane starts loading its page as it // is built, so the repository has to be attached first. if (layoutReviewDir(layout.root)) |dir| self.bindLayoutReview(tab, dir, bindings); 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 { // Before the URL is freed: the server is holding this tab's id, and its // store, until told the tab has gone. The comments themselves are on disk // and stay there, so a tab reopened on the same repository picks the review // back up where it left off. if (review.get()) |server| server.unregisterTab(tab.pageName()); if (tab.review_url.len > 0) self.alloc.free(tab.review_url); 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. Without asking: the // last tab closing *is* the answer to the question, and there is // nothing left for a confirmation to offer to keep. self.quit(); 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]); } // ------------------------------------------------------------------------- // Sidebar width // // The divider beside the sidebar is a real paned handle, so the column is // resizable by drag. Below a certain width the sidebar stops being a list of // names and becomes a list of emoji, and that switch is a *mode*, not a // squeeze: the labels, dots, close buttons, layouts button and window controls // all leave, because at emoji width every one of them would be clipped rather // than small. The mode is entered three ways — the footer toggle, the // `toggle_sidebar` shortcut, and dragging the divider all the way in — and all // three meet in `applySidebarCollapsed`. fn onCollapseClicked(_: *gtk.Button, self: *Window) callconv(.c) void { self.toggleSidebar(); } /// Toggle between the emoji-only column and the last expanded width — which is /// the default width until a drag has said otherwise. fn toggleSidebar(self: *Window) void { if (self.sidebar_collapsed) { self.applySidebarCollapsed(false); self.setSidebarPosition(self.sidebar_expanded_width); } else { self.sidebar_expanded_width = self.split.getPosition(); self.applySidebarCollapsed(true); self.setSidebarPosition(sidebar_collapsed_width); } } /// Show or hide everything that doesn't fit an emoji-wide column. The width /// itself is the caller's business: the toggle sets it, and a drag is already /// setting it. fn applySidebarCollapsed(self: *Window, collapsed: bool) void { if (self.sidebar_collapsed == collapsed) return; // Measured before anything hides: this is the narrowest the expanded // sidebar can be, so it is where a drag has to reach before the chrome is // given back — the same width folding happened at, which is what keeps a // slow drag from flickering between the two modes. if (collapsed) { self.sidebar_expand_at = @max( minPosition(self.split), sidebar_collapsed_width + 24, ); } self.sidebar_collapsed = collapsed; const shown: c_int = @intFromBool(!collapsed); // The window controls belong to the desktop, but at 56px they would be // clipped mid-button, which is worse than absent. Closing the window is // still one expand away, or a keyboard shortcut that never left. self.header.setShowStartTitleButtons(shown); self.header.setShowEndTitleButtons(shown); self.layout_button.as(gtk.Widget).setVisible(shown); // Two buttons side by side don't fit the collapsed column, so the footer // stacks them instead of losing one. self.footer.as(gtk.Orientable).setOrientation(if (collapsed) .vertical else .horizontal); self.collapse_button.setIconName(if (collapsed) "go-next-symbolic" else "go-previous-symbolic"); self.collapse_button.as(gtk.Widget).setTooltipText(if (collapsed) "Expand the sidebar (Ctrl+Shift+S)" else "Collapse the sidebar (Ctrl+Shift+S)"); for (self.tabs.items) |tab| self.applyRowCollapsed(tab); } /// One row's share of the collapse: everything but the emoji (or kind icon) /// goes. The status dot goes too — the bar down the row's leading edge and the /// wash behind it carry the state at this width, and they were designed to be /// the glanceable half of the pair anyway. fn applyRowCollapsed(self: *Window, tab: *Tab) void { const shown: c_int = @intFromBool(!self.sidebar_collapsed); tab.label.as(gtk.Widget).setVisible(shown); tab.dot.as(gtk.Widget).setVisible(shown); tab.close.as(gtk.Widget).setVisible(shown); } /// Move the divider ourselves, without the position handler reading it as a /// drag. fn setSidebarPosition(self: *Window, position: c_int) void { self.applying_position = true; defer self.applying_position = false; self.split.setPosition(position); } /// The divider moved by hand. Expanded, the paned won't let a drag go below /// the chrome's own minimum — so a position *at* that minimum means the drag is /// pinned against it and wants less, and the answer is to fold. Hiding the /// chrome lowers the paned's minimum, which is what lets the same drag carry on /// down to the emoji column. Dragging back out past where the fold happened /// unfolds again. fn onSidebarPosition(paned: *gtk.Paned, _: *gobject.ParamSpec, self: *Window) callconv(.c) void { if (self.applying_position) return; const position = paned.getPosition(); if (self.sidebar_collapsed) { if (position >= self.sidebar_expand_at) self.applySidebarCollapsed(false); return; } if (position <= minPosition(paned)) { self.applySidebarCollapsed(true); return; } self.sidebar_expanded_width = position; } fn minPosition(paned: *gtk.Paned) c_int { var value = gobject.ext.Value.new(c_int); defer value.unset(); paned.as(gobject.Object).getProperty("min-position", &value); return gobject.ext.Value.get(&value, c_int); } // ------------------------------------------------------------------------- // 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]); // On the row rather than the label, so a collapsed sidebar — where the // label is hidden and the emoji is all a row shows — still says which tab // an emoji is when you hover it. tab.row.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. /// A pane in this tab asked for the tab's review. fn onViewReview(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.window.openReview(tab); } fn onViewEmpty(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.window.closeTab(tab); } // ------------------------------------------------------------------------- // Quitting // // A window here is not a shell but an arrangement of them — tabs, splits, and // whatever each one is in the middle of — and closing it exits every one of // those at once. That is a lot to hang off a single keystroke, which under a // tiling compositor is exactly where it hangs: niri's close binding is a // modifier away from the ones that move focus between windows, and it does not // ask. So we do. /// The window has been asked to close. Returning non-zero keeps it. /// /// Three ways through here. Confirmation switched off, or a decision already /// taken, and the close goes ahead. Otherwise the question goes up and the /// window stays until it is answered. /// /// Note that a close arriving *while* the question is up does not reach this at /// all — see `confirming_quit`. It dismisses the dialog, which is a cancel, so /// leaning on the binding never costs the window. fn onCloseRequest(_: *adw.ApplicationWindow, self: *Window) callconv(.c) c_int { if (self.quit_confirmed) return 0; if (!Settings.get().confirm_quit) return 0; // Already asked, and still waiting for the answer. Unreachable in practice; // see the field. if (self.confirming_quit) return 1; self.confirming_quit = true; self.askBeforeQuitting(); return 1; } /// Close the window without asking. /// /// For the paths where the question has already been answered — the /// confirmation being accepted, and the last tab closing, which is a decision /// to close this window made one tab at a time. fn quit(self: *Window) void { self.quit_confirmed = true; self.window.as(gtk.Window).close(); } /// Put the question up: an Adwaita alert dialog over the window it is about. fn askBeforeQuitting(self: *Window) void { const dialog = adw.AlertDialog.new("Quit Playpen?", null); // The count is the reason for asking at all. "Quit?" over one shell is a // shrug; over nine tabs of work it is the whole point of the dialog, and // it is also the quickest way to notice you are about to close the wrong // window. The buffer is sized so the format cannot fail, but the fallback // says the same thing without the number rather than nothing at all. var buf: [160]u8 = undefined; const body: [:0]const u8 = std.fmt.bufPrintZ( &buf, "{d} tab{s} will close, and every shell in {s} will exit.", .{ self.tabs.items.len, if (self.tabs.items.len == 1) "" else "s", if (self.tabs.items.len == 1) "it" else "them", }, ) catch "Every tab will close, and every shell in them will exit."; dialog.setBody(body.ptr); dialog.addResponse("cancel", "Keep Working"); dialog.addResponse("quit", "Quit"); // Destructive, and *not* the default: Escape and Enter both have to land on // keeping the window, or the dialog is one more keystroke to fumble rather // than a guard against fumbling one. dialog.setResponseAppearance("quit", .destructive); dialog.setDefaultResponse("cancel"); dialog.setCloseResponse("cancel"); _ = adw.AlertDialog.signals.response.connect( dialog, *Window, &onQuitResponse, self, .{}, ); dialog.as(adw.Dialog).present(self.window.as(gtk.Widget)); } /// The question was answered — by a button, by Escape, or by the dialog being /// dismissed, which `close_response` has already turned into "cancel". fn onQuitResponse(_: *adw.AlertDialog, response: [*:0]u8, self: *Window) callconv(.c) void { self.confirming_quit = false; if (!std.mem.eql(u8, std.mem.span(response), "quit")) return; // Not from here: this runs while the dialog is closing, and destroying the // window it is parented to out from under it is how that turns into a // crash. One trip back through the main loop and the dialog is gone. _ = glib.idleAddOnce(&onQuitIdle, self); } fn onQuitIdle(data: ?*anyopaque) callconv(.c) void { const self: *Window = @ptrCast(@alignCast(data.?)); self.quit(); } /// 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(); } fn focusedReview(self: *Window) ?*Review { const tab = self.activeTab() orelse return null; return tab.view.focusedReview(); } /// 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) }); }; } // ------------------------------------------------------------------------- // The review pane // // A tab has at most one, and it is bound to a repository: the one the tab is // working in, or the one its layout named. Both halves of that are decided here // rather than in the pane or the view: the pane is a web view, the view is a // split tree, and "which repository is this tab about" is a question only the // window — which can see the tab's terminals and what it was opened from — is in // a position to answer. /// Open the visible tab's review, or go to the one it already has. /// /// The repository is resolved once, here, from the directory the tab is working /// in, and then stays put for as long as the review is open. Re-resolving on /// every fetch was the alternative, and it means a `cd` in a terminal can swap /// the diff out from under someone mid-read; a review you have to reopen is the /// better failure. fn openReview(self: *Window, tab: *Tab) void { // Already open: take them to it rather than reporting a refusal. Asking for // the review twice is a reasonable way to say "where is my review". if (tab.view.reviewPane()) |pane| { self.select(tab); tab.view.setFocused(pane); pane.grabFocus(); return; } const server = review.get() orelse { // The pane still opens, and says this. Better than a shortcut that looks // broken because nothing happened. self.addReviewPane(tab); return; }; var buf: [std.fs.max_path_bytes]u8 = undefined; const dir = self.tabDirectory(tab, &buf); server.openReview(tab.pageName(), dir) catch |err| { // Most often `dir` is simply not inside a repository, which is not a // failure of playpen's and not worth a dialog: the pane's own empty // state explains it, and the log line is here for the rest. std.log.info("review: no repository for {s} at {s}: {s}", .{ tab.pageName(), dir, @errorName(err), }); self.addReviewPane(tab); return; }; // The pane header shows the repository's name, which is only knowable once // the server has resolved the work-tree root. tab.view.review_spec.repo = server.repoPath(tab.pageName()) orelse dir; self.addReviewPane(tab); } /// The directory a layout points its review at, or null if it has no review /// pane or leaves the directory to the tab. /// /// The first review leaf decides it. A layout holding two is refused as the /// second pane is built — one review per tab — so there is never a second /// directory to disagree with this one. fn layoutReviewDir(node: *const Layouts.Node) ?[]const u8 { switch (node.*) { .pane => |p| { if (p.kind != .review or p.cwd.len == 0) return null; return p.cwd; }, .split => |s| return layoutReviewDir(s.first) orelse layoutReviewDir(s.second), } } /// Bind a tab's review to the directory its layout named, while the view is /// still empty. /// /// This is `openReview` without the pane: the layout has already said the tab /// has a review in it, and all that is missing is which repository. Resolving it /// here rather than after the panes are built is what makes the result /// deterministic — the review pane's page is fetched from the server on another /// thread the moment the pane exists, and a repository attached afterwards would /// sometimes arrive first and sometimes second. /// /// The directory goes through the same expansion a terminal's does, so a layout /// can review `{{a parameter}}` or `$(whatever a script prints)`. fn bindLayoutReview( self: *Window, tab: *Tab, template: []const u8, bindings: []const Layouts.Binding, ) void { const server = review.get() orelse return; const dir = Layouts.expandPath(self.alloc, template, bindings) catch |err| { std.log.warn("review: could not resolve \"{s}\": {s}", .{ template, @errorName(err) }); return; }; defer self.alloc.free(dir); server.openReview(tab.pageName(), dir) catch |err| { // Same as opening a review by hand: a directory that isn't in a // repository is the pane's own empty state to explain, not a reason to // refuse the rest of the tab. std.log.info("review: no repository for {s} at {s}: {s}", .{ tab.pageName(), dir, @errorName(err), }); return; }; // Borrowed from the server, which keeps it for as long as the tab's review // lives — longer than any pane in the tab. if (server.repoPath(tab.pageName())) |repo| tab.view.review_spec.repo = repo; } fn addReviewPane(self: *Window, tab: *Tab) void { tab.view.addPane(.plain(.review)) catch |err| { std.log.err("failed to open the review pane: {s}", .{@errorName(err)}); return; }; self.select(tab); } /// The directory a tab is working in, copied into `buf`. /// /// Read from a terminal's own process rather than from anything recorded when /// the tab opened, because the directory that matters is the one you are working /// in now: a tab opened in a monorepo root and `cd`-ed into a worktree is a tab /// about that worktree. The focused pane is asked first, so a split holding two /// repositories reviews the one you are looking at. /// /// Falls back to playpen's own working directory, which at least gives the /// server something to fail on that the user can recognise in the message. fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 { _ = self; if (tab.view.focusedPane()) |focused| { if (focused.terminal()) |terminal| { if (terminal.session.pty.cwd(buf)) |dir| return dir; } } for (tab.view.panes.items) |pane| { const terminal = pane.terminal() orelse continue; if (terminal.session.pty.cwd(buf)) |dir| return dir; } // Playpen's own directory, which at least gives the server something to // fail on that the user can recognise in the message. `std.c` rather than // `std.posix`, matching `Pty.zig`: the latter has been churning across Zig // releases and this is one call. if (std.c.getcwd(buf.ptr, buf.len) != null) { return std.mem.sliceTo(buf, 0); } return "."; } 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), // Not `addPane`: opening a review is more than adding a pane, and asking // for one you already have takes you to it instead of refusing. .new_review => if (self.activeTab()) |tab| self.openReview(tab), .rename_tab => if (self.activeTab()) |tab| self.beginRename(tab), .toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(), .toggle_sidebar => self.toggleSidebar(), .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 pane holding a page. // Both kinds that do put up the same bar. .find => { if (self.focusedBrowser()) |browser| { browser.openFind(); } else if (self.focusedReview()) |pane| { pane.openFind(); } else return false; }, .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); }