//! 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 Snapshot = @import("Snapshot.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 notify = @import("notify.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, /// The arrangement the last session exited with, read once at launch. Empty /// on a first run, and after any launch that ended with every tab closed. snapshot: Snapshot, /// The banner at the foot of the sidebar offering that snapshot back, and the /// line under its title saying how much there is to put back. /// /// Built with the sidebar and hidden until there is something to offer, rather /// than created when the offer arrives: a launch is already busy opening tabs, /// and this has no state worth building twice. restore_banner: *gtk.Box, restore_detail: *gtk.Label, /// Whether the offer is currently standing. Separate from the banner's own /// visibility because a collapsed sidebar hides the banner without answering /// it — see `applyRestoreVisible`. restore_offered: bool = false, /// What a sidebar row is signaling. 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 canceled 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 canceled drag can /// put it back. origin: usize, /// Set once a drop has been accepted; a drag that ends without this was /// canceled, 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 recognizable 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, /// Whether this tab's finishes are posting notifications, and until when. /// /// Session state rather than a setting: a mute is set from the row menu to /// get through the next hour, and a tab does not survive the app anyway, so /// there is nothing here worth writing to a file. See `notify.Mute`. mute: notify.Mute = .off, 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"), .snapshot = .init(alloc), .restore_banner = gtk.Box.new(.horizontal, 4), .restore_detail = gtk.Label.new(null), }; 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 canceled // 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)); // Under the tab list and above the footer. It is an offer about the tabs, so // it belongs against them rather than up by the new-tab button — and at the // foot it is out of the way of the list on every launch that has nothing to // offer, which is most of them. sidebar.append(self.buildRestoreBanner()); // 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(); // After the startup tabs, not before: the offer is withdrawn by anything // that changes the tab set, and the window opening its own tabs would // otherwise withdraw it before it was ever made. self.offerRestore(); 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 { self.withdrawRestoreOffer(); 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 labeling 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. /// /// The menu's contents are not built here — see `fillRowMenu`, which builds them /// as it opens. fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void { _ = self; 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)); } /// Build the menu's contents, immediately before it opens. /// /// Rebuilt per opening rather than built once and switched about, because half /// of what it says is a deadline that has been quietly passing while the popover /// sat there unopened. There is no moment other than "now" at which "muted for /// another forty minutes" can be made true. fn fillRowMenu(tab: *Tab) void { 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)); box.append(gtk.Separator.new(.horizontal).as(gtk.Widget)); // Cleared as the menu opens as well as when something wants to post, so a // mute that ran out an hour ago isn't still described as one. const now = notify.nowMs(); if (tab.mute.expired(now)) tab.mute = .off; var buf: [80]u8 = undefined; if (tab.mute.describe(now, &buf)) |state| { // A label rather than an insensitive menu item: this is the answer to // "why has this tab gone quiet", and there is nothing to click. const label = gtk.Label.new(state); label.setXalign(0); label.as(gtk.Widget).addCssClass("playpen-row-menu-state"); box.append(label.as(gtk.Widget)); box.append(menuItem("Unmute", &onMenuUnmute, tab)); } // Offered whether or not the tab is already muted: re-picking is how you // change your mind about how long, and having to unmute first to mute again // for longer would be a menu arguing with you. inline for (std.enums.values(notify.Duration)) |duration| { const Item = struct { fn clicked(_: *gtk.Button, clicked_tab: *Tab) callconv(.c) void { clicked_tab.menu_popover.popdown(); clicked_tab.mute = duration.mute(notify.nowMs()); } }; box.append(menuItem(duration.label(), &Item.clicked, tab)); } tab.menu_popover.setChild(box.as(gtk.Widget)); } /// 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 centered 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, }; fillRowMenu(tab); 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); } fn onMenuUnmute(_: *gtk.Button, tab: *Tab) callconv(.c) void { tab.menu_popover.popdown(); tab.mute = .off; } // ------------------------------------------------------------------------- // 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 { self.withdrawRestoreOffer(); 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.applyTabChrome(tab, entry.name, entry.emoji); 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 a saved entry pins on its tab, both behaving exactly as /// though they had been set by hand once it was open. /// /// Shared by the startup list and the session snapshot, which record the same /// two strings for the same reason: a tab you named "deploy" should still say /// so when it comes back, whichever of the two files brought it back. fn applyTabChrome(self: *Window, tab: *Tab, name: []const u8, glyph: []const u8) void { if (name.len > 0) { tab.custom_name = self.alloc.dupe(u8, name) catch |err| blk: { std.log.warn("could not name a reopened 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 file naming something this build can't draw. if (glyph.len > 0) { tab.emoji = emoji.lookup(glyph); if (tab.emoji == null) { std.log.warn("\"{s}\" is not an emoji this build knows", .{glyph}); } } } /// 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", .{}); }; } // ------------------------------------------------------------------------- // The session snapshot // // Every exit photographs the window — see `Snapshot` for what is in the // photograph and what deliberately is not — and every launch that finds one // offers it back from a banner at the foot of the sidebar. The offer is // optional in both directions: dismissing it costs nothing, and taking it is a // single click rather than a dialog. // // The offer stands only until the tab set changes. That is the price of // restoring *over* the startup tabs rather than beside them, and it is worth // paying: a window holding both your startup tabs and the session they were // standing in for is two of everything, and nobody wants to close half a window // by hand. But "the tabs the window opened for itself" is a set that only // exists for as long as nobody has touched it — the moment you open or close one // yourself, closing that set would be closing your work. So `newTab`, // `openLayout` and `closeTab` all take the offer down, and the banner is only // ever the launch-time gesture it looks like. /// The banner: a row that puts the last session back, and a ✕ that says no. fn buildRestoreBanner(self: *Window) *gtk.Widget { const banner = self.restore_banner; banner.as(gtk.Widget).addCssClass("playpen-restore"); banner.as(gtk.Widget).setVisible(0); // The whole row is the button rather than a "Restore" beside a description // of what it would do. There is one action here, and a banner whose text is // inert invites a click on the half of itself that does nothing. const action = gtk.Button.new(); action.as(gtk.Widget).addCssClass("flat"); action.as(gtk.Widget).addCssClass("playpen-restore-action"); action.as(gtk.Widget).setHexpand(1); action.setHasFrame(0); action.as(gtk.Widget).setTooltipText("Reopen the tabs this window had when it last closed"); const content = gtk.Box.new(.horizontal, 8); const icon = gtk.Image.newFromIconName("view-refresh-symbolic"); icon.as(gtk.Widget).addCssClass("playpen-restore-icon"); content.append(icon.as(gtk.Widget)); const text = gtk.Box.new(.vertical, 0); text.as(gtk.Widget).setHexpand(1); const title = gtk.Label.new("Restore session"); title.setXalign(0); title.as(gtk.Widget).addCssClass("playpen-restore-title"); text.append(title.as(gtk.Widget)); // Filled in by `offerRestore`, which is the only moment the count is known. self.restore_detail.setXalign(0); self.restore_detail.setEllipsize(.end); self.restore_detail.as(gtk.Widget).addCssClass("playpen-restore-detail"); text.append(self.restore_detail.as(gtk.Widget)); content.append(text.as(gtk.Widget)); action.setChild(content.as(gtk.Widget)); _ = gtk.Button.signals.clicked.connect( action, *Window, &onRestoreClicked, self, .{}, ); banner.append(action.as(gtk.Widget)); const dismiss = gtk.Button.newFromIconName("window-close-symbolic"); dismiss.as(gtk.Widget).addCssClass("flat"); dismiss.as(gtk.Widget).addCssClass("playpen-restore-dismiss"); dismiss.as(gtk.Widget).setValign(.center); dismiss.as(gtk.Widget).setTooltipText("Dismiss"); _ = gtk.Button.signals.clicked.connect( dismiss, *Window, &onRestoreDismissed, self, .{}, ); banner.append(dismiss.as(gtk.Widget)); return banner.as(gtk.Widget); } /// Read the snapshot and, if there is anything in it, put the offer up. fn offerRestore(self: *Window) void { self.snapshot.load(); if (!self.snapshot.any()) return; // The count is the whole of what the banner can honestly promise, and it is // also what tells you whether this is the session you meant. The buffer is // sized so the format cannot fail; the fallback still says what the offer // is, just without the number. const count = self.snapshot.tabs.items.len; var buf: [64]u8 = undefined; const detail: [:0]const u8 = std.fmt.bufPrintZ(&buf, "{d} tab{s} from last time", .{ count, if (count == 1) "" else "s", }) catch "from last time"; self.restore_detail.setText(detail.ptr); self.restore_offered = true; self.applyRestoreVisible(); } /// Take the offer down for the rest of this launch. /// /// The snapshot file itself is left alone. Nothing needs to delete it — the next /// exit overwrites it — and leaving it means a dismissal followed by a crash /// still has last session's tabs to offer, which is the direction to err in. fn withdrawRestoreOffer(self: *Window) void { if (!self.restore_offered) return; self.restore_offered = false; self.applyRestoreVisible(); } /// The banner shows while the offer stands *and* the sidebar is wide enough to /// read it. At the emoji column there is no version of this that is smaller /// rather than clipped, and it is not urgent enough to be the one thing that /// forces the column open. fn applyRestoreVisible(self: *Window) void { self.restore_banner.as(gtk.Widget).setVisible( @intFromBool(self.restore_offered and !self.sidebar_collapsed), ); } fn onRestoreClicked(_: *gtk.Button, self: *Window) callconv(.c) void { self.restoreSession(); } fn onRestoreDismissed(_: *gtk.Button, self: *Window) callconv(.c) void { self.withdrawRestoreOffer(); } /// Put the last session back, in place of the tabs the window opened itself. /// /// The new tabs are built *before* the old ones are closed. That order is what /// makes a failed restore harmless: a snapshot whose every tab refuses to build /// leaves the window exactly as it was, rather than empty and with the offer /// spent. It also means both sets are briefly in the sidebar at once, which is /// why the selection moves to the first restored tab before anything is /// discarded — the stack should never be showing a page that is about to go. fn restoreSession(self: *Window) void { self.withdrawRestoreOffer(); // Taken before the list grows: `tabs` is about to hold both sets, and these // are the ones on their way out. const previous = self.alloc.dupe(*Tab, self.tabs.items) catch |err| { std.log.err("could not restore the session: {s}", .{@errorName(err)}); return; }; defer self.alloc.free(previous); var first: ?*Tab = null; for (self.snapshot.tabs.items) |entry| { const tab = self.buildSnapshotTab(entry) catch |err| { // One tab short is a much better outcome than none: the other five // are still the session you asked for. Note that a directory that // has since been deleted does *not* land here — the child's `chdir` // fails and the shell simply starts where the app did, exactly as it // does for a layout that has gone stale. std.log.warn("could not restore a tab: {s}", .{@errorName(err)}); continue; }; if (first == null) first = tab; } const restored = first orelse { // Nothing was built, so nothing is closed and the window is exactly as // it was — only the banner has gone. Reaching here takes an allocation // failure per tab, at which point there is nothing better to offer. std.log.err("nothing in the session snapshot could be reopened", .{}); return; }; self.select(restored); for (previous) |tab| self.discardTab(tab); } /// One tab out of the snapshot. /// /// The tree is applied with no bindings, and that is not an omission: a snapshot /// is taken *after* substitution, so every directory in it is the one a shell /// was actually sitting in. There is nothing left to expand, and a path that /// really did contain a `{{` would be a path rather than a parameter. fn buildSnapshotTab(self: *Window, entry: Snapshot.Tab) !*Tab { const tab = try self.newTabEmpty(); // Before the panes, for the reason `buildLayoutTab` gives: a review pane // starts fetching its page the moment it exists, so the repository has to be // attached first. if (layoutReviewDir(entry.root)) |dir| self.bindLayoutReview(tab, dir, &.{}); tab.view.applyLayout(entry.root, &.{}) catch |err| { // As in `buildLayoutTab`: a view with no panes has nothing to work in, // so it goes rather than sitting there empty. Discarded rather than // closed, since closing the only tab would take the window with it. if (tab.view.panes.items.len == 0) { self.discardTab(tab); return err; } std.log.err("a restored tab is only partly built: {s}", .{@errorName(err)}); }; // The recipe the tab was originally opened from, carried through the // snapshot so that a restored window can still be captured as a startup // list. It plays no part in the restore itself — the tree above did that. if (entry.layout.len > 0) self.recordSource(tab, entry.layout, entry.values); self.applyTabChrome(tab, entry.name, entry.emoji); self.refreshLabel(tab); return tab; } /// Photograph the window for the next launch. /// /// Called from `onDestroy`, which is the one funnel every route out of the /// window passes through — the quit confirmation being accepted, the last tab /// closing, the compositor closing the window — and which runs while the tabs /// and their shells are all still alive. That last part is the reason it is /// there and not in `quit`: a terminal's directory is read out of its live /// child process, and after teardown there is nothing left to ask. /// /// Every failure below is a warning and a carry-on. This runs while the app is /// already leaving, there is nowhere to report anything, and the worst case is /// one launch that has nothing to offer. fn saveSnapshot(self: *Window) void { var snapshot: Snapshot = .init(self.alloc); defer snapshot.deinit(); // Reused across tabs rather than allocated per tab: `add` copies what it is // given, so this only ever has to hold one tab's worth. var values: std.ArrayListUnmanaged(Layouts.Binding) = .empty; defer values.deinit(self.alloc); for (self.tabs.items) |tab| { const root = tab.view.capture(snapshot.builder()) catch |err| { std.log.warn("could not capture a tab for the snapshot: {s}", .{@errorName(err)}); continue; } orelse continue; values.clearRetainingCapacity(); if (tab.source) |source| { for (source.values) |v| { values.append(self.alloc, .{ .name = v.name, .value = v.value }) catch break; } } snapshot.add(.{ .root = root, .name = if (tab.custom_name) |name| name else "", .emoji = if (tab.emoji) |glyph| glyph else "", .layout = if (tab.source) |source| source.layout else "", .values = values.items, }) catch |err| { std.log.warn("could not record a tab in the snapshot: {s}", .{@errorName(err)}); }; } snapshot.save() catch |err| { std.log.warn("could not write the session snapshot: {s}", .{@errorName(err)}); }; } /// 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 acknowledgment 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); // And it answers the popup too, which would otherwise sit in the tray // telling you about a tab you are now looking at. if (self.application()) |app| notify.withdraw(app, tab.pageName()); } 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 canceled. 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.withdrawRestoreOffer(); 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)"); self.applyRestoreVisible(); for (self.tabs.items) |tab| self.applyRowCollapsed(tab); } /// One row's share of the collapse: the title and the close button go, the /// emoji (or kind icon) and the status dot stay. /// /// The dot used to go as well, back when every state also drew a bar down the /// row's leading edge and that bar could carry the state on its own. Only /// "finished" draws one now, so at this width the dot is the whole signal for /// the other three — and a collapsed sidebar is exactly where you are relying on /// a glance rather than on reading anything. fn applyRowCollapsed(self: *Window, tab: *Tab) void { const shown: c_int = @intFromBool(!self.sidebar_collapsed); tab.label.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 color 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 colors /// 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, task: []const u8) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.finished_since_visit = true; // The row first, always, and the popup second, maybe: the dot is the half // that is never wrong and never unwanted. tab.window.refreshStatus(tab); tab.window.notifyFinished(tab, task); } /// Tell the desktop that a pane finished, unless something says not to. /// /// See `notify.wanted` for the three things that can say not to. fn notifyFinished(self: *Window, tab: *Tab, task: []const u8) void { // A timed mute is cleared here rather than on a timer. Nothing needs to // know it has run out until something wants to post, and a GLib timeout per // muted tab would be a great deal of machinery for a deadline nobody is // watching. const now = notify.nowMs(); if (tab.mute.expired(now)) tab.mute = .off; // "You watched it happen" is the window having the focus and this being the // tab it is showing. Everything the decision rests on is gathered here and // weighed in `notify.wanted`, which is where it can be tested. const watching = self.window.as(gtk.Window).isActive() != 0 and self.activeTab() == tab; if (!notify.wanted(.{ .enabled = Settings.get().notifications, .mute = tab.mute, .watching = watching, }, now)) return; const app = self.application() orelse return; var buf: [192]u8 = undefined; notify.post(app, tab.pageName(), self.tabName(tab, &buf), task); } /// The `GApplication` this window belongs to, which is what carries a /// notification to the session. /// /// Fetched each time rather than held. It is the same object for the life of the /// process, but a window part-way through teardown has already been unparented /// from it, and a session exiting during teardown is exactly when a finish can /// still arrive. fn application(self: *Window) ?*gio.Application { const app = self.window.as(gtk.Window).getApplication() orelse return null; return app.as(gio.Application); } /// 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; // First, while every tab is still whole and every shell still running: the // directories in the snapshot are read out of live processes. self.saveSnapshot(); // 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.snapshot.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(); } /// Whether the keyboard focus sits on `widget` or on something inside it. /// /// Finer-grained than the `focused*` lookups above, which answer at the /// granularity of a pane: a pane is one of these even while the focus is in a /// bar it draws around its content rather than in the content itself. fn focusIsIn(self: *Window, widget: *gtk.Widget) bool { const focus = self.window.as(gtk.Window).getFocus() orelse return false; return focus == widget or focus.isAncestor(widget) != 0; } /// 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 recognize 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 recognize 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(), // Ctrl+Shift+V is the terminal's paste chord, and this window means it // to be *the* paste chord — but nothing below us binds the shifted // form. GTK's entries and WebKit's pages both paste on plain Ctrl+V // and neither has a binding for Ctrl+Shift+V, so leaving the key alone // over a web pane doesn't hand the paste to the page, it drops it. A // pane that isn't a terminal has to be handed the paste explicitly. // // Only when the page itself holds the focus, though: a web pane's // address bar is an ordinary GTK entry sitting above the view, and // pasting into the page while the caret is in the address bar would // put the text somewhere the user isn't looking. .paste => { if (self.focusedTerminal()) |terminal| { terminal.pasteFrom(.standard); } else if (self.focusedBrowser()) |browser| { if (!self.focusIsIn(browser.view.as(gtk.Widget))) return false; browser.view.executeEditingCommand("Paste"); } else if (self.focusedReview()) |pane| { if (!self.focusIsIn(pane.view.as(gtk.Widget))) return false; pane.view.executeEditingCommand("Paste"); } else return false; }, // 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); }