2763 lines
106 KiB
Zig
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);
|
|
}
|