Files
playpen/src/Window.zig
T
2026-08-27 11:02:32 -04:00

2763 lines
106 KiB
Zig

//! 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:<port>/t/<name>` — 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);
}