Add hook system.

This commit is contained in:
Greyson Parrelli
2026-08-11 16:24:53 -04:00
parent 73a0187db5
commit dd60e52005
9 changed files with 788 additions and 3 deletions
+272 -3
View File
@@ -16,6 +16,7 @@ const vt = @import("ghostty-vt");
const Layouts = @import("Layouts.zig");
const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
const Pane = @import("Pane.zig");
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
@@ -57,6 +58,44 @@ layout_popover: *gtk.Popover,
/// they belong to are on screen.
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
/// What a tab's sidebar row is signalling. The four pane-level states, plus
/// one the panes can't know about on their own.
///
/// `done` is the whole reason this is a separate enum rather than just
/// `View.Status`. A pane that has gone back to idle is indistinguishable from
/// one that never ran, and "it finished" is exactly the thing worth knowing
/// when you're deciding which tab to go back to. So a tab that finishes work
/// while you are looking somewhere else latches into `done` and stays there
/// until you actually visit it.
const Attention = enum {
none,
busy,
done,
needs_input,
failed,
/// The dot's CSS class, or null when the row should show no dot at all.
fn class(self: Attention) ?[:0]const u8 {
return switch (self) {
.none => null,
.busy => "playpen-status-busy",
.done => "playpen-status-done",
.needs_input => "playpen-status-input",
.failed => "playpen-status-failed",
};
}
fn tooltip(self: Attention) [:0]const u8 {
return switch (self) {
.none => "",
.busy => "Working",
.done => "Finished while you were away",
.needs_input => "Waiting for you",
.failed => "Stopped on an error",
};
}
};
/// A single tab: a view of one or more panes, plus the sidebar row that
/// selects it.
const Tab = struct {
@@ -69,12 +108,44 @@ const Tab = struct {
/// the sidebar without reading the title.
icon: *gtk.Image,
/// Status dot, hidden unless the tab has something to report.
dot: *gtk.Image,
/// A name the user typed, which wins over whatever the panes report.
/// Null means the label tracks the content, which is the default.
custom_name: ?[]u8 = null,
/// Popover holding the rename entry, parented to this tab's row.
rename_popover: *gtk.Popover,
rename_entry: *gtk.Entry,
/// What the panes were reporting last time we looked, so a busy → idle
/// transition can be told apart from an idle tab that never ran.
last_status: View.Status = .idle,
/// Set when work finished while this tab was not the visible one, and
/// cleared when it is next selected.
done_unseen: bool = false,
name: [16]u8,
name_len: usize,
fn pageName(self: *const Tab) [:0]const u8 {
return self.name[0..self.name_len :0];
}
/// What the row should be showing right now.
///
/// The latch outranks `busy` deliberately: if one pane is still working
/// but another has already finished unseen, the finished one is the news.
fn attention(self: *const Tab) Attention {
return switch (self.view.status()) {
.needs_input => .needs_input,
.failed => .failed,
.busy => if (self.done_unseen) .done else .busy,
.idle => if (self.done_unseen) .done else .none,
};
}
};
pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
@@ -226,6 +297,7 @@ fn newTabEmpty(self: *Window) !*Tab {
const view = try View.create(self.alloc, .{
.on_empty = &onViewEmpty,
.on_title = &onViewTitle,
.on_status = &onViewStatus,
.ctx = tab,
});
errdefer view.destroy();
@@ -239,6 +311,9 @@ fn newTabEmpty(self: *Window) !*Tab {
.row = gtk.ListBoxRow.new(),
.label = gtk.Label.new("shell"),
.icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"),
.dot = gtk.Image.newFromIconName(Pane.status_icon),
.rename_popover = gtk.Popover.new(),
.rename_entry = gtk.Entry.new(),
.name = undefined,
.name_len = 0,
};
@@ -256,6 +331,12 @@ fn newTabEmpty(self: *Window) !*Tab {
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.setDot(tab.dot, null);
row_box.append(tab.dot.as(gtk.Widget));
const close = gtk.Button.newFromIconName("window-close-symbolic");
close.as(gtk.Widget).addCssClass("flat");
close.as(gtk.Widget).addCssClass("playpen-close");
@@ -263,6 +344,7 @@ fn newTabEmpty(self: *Window) !*Tab {
row_box.append(close.as(gtk.Widget));
tab.row.setChild(row_box.as(gtk.Widget));
self.buildRename(tab, row_box);
self.list.append(tab.row.as(gtk.Widget));
_ = self.stack.addNamed(view.widget(), tab.pageName());
@@ -272,6 +354,128 @@ fn newTabEmpty(self: *Window) !*Tab {
return 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));
// Right-click is the discoverable route; double-click matches how tab
// strips elsewhere behave. Both land in the same place.
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));
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 onRowSecondary(
_: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
tab: *Tab,
) callconv(.c) void {
tab.window.beginRename(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
@@ -505,6 +709,13 @@ fn select(self: *Window, tab: *Tab) void {
self.stack.setVisibleChildName(tab.pageName());
self.list.selectRow(tab.row);
tab.view.focus();
// Visiting the tab is what "seeing it" means, so this is where the
// finished-while-you-were-away flag is spent.
if (tab.done_unseen) {
tab.done_unseen = false;
self.refreshStatus(tab);
}
}
fn indexOf(self: *Window, tab: *Tab) ?usize {
@@ -518,10 +729,16 @@ fn closeTab(self: *Window, tab: *Tab) void {
const index = self.indexOf(tab) orelse return;
self.stack.remove(tab.view.widget());
// 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.rename_popover.as(gtk.Widget).unparent();
self.list.remove(tab.row.as(gtk.Widget));
_ = self.tabs.orderedRemove(index);
tab.view.destroy();
if (tab.custom_name) |name| self.alloc.free(name);
self.alloc.destroy(tab);
if (self.tabs.items.len == 0) {
@@ -559,14 +776,26 @@ fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv
}
}
/// Refresh a sidebar row from its view's current state.
fn refreshLabel(self: *Window, tab: *Tab) void {
/// 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 = tab.view.label(scratch[0 .. scratch.len - 1]);
const text = self.tabName(tab, scratch[0 .. scratch.len - 1]);
var buf: [192]u8 = undefined;
@memcpy(buf[0..text.len], text);
@@ -575,6 +804,18 @@ fn refreshLabel(self: *Window, tab: *Tab) void {
tab.label.setText(buf[0..text.len :0]);
tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
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;
const attention = tab.attention();
Pane.setDot(tab.dot, attention.class());
tab.dot.as(gtk.Widget).setTooltipText(attention.tooltip());
}
fn onViewTitle(ctx: ?*anyopaque) void {
@@ -582,6 +823,29 @@ fn onViewTitle(ctx: ?*anyopaque) void {
tab.window.refreshLabel(tab);
}
/// A pane in this tab changed state.
///
/// The latch is set here rather than anywhere else because this is the only
/// place that sees the transition: by the time the user looks at the sidebar,
/// a tab that finished and a tab that never started look identical.
fn onViewStatus(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
const self = tab.window;
const previous = tab.last_status;
const current = tab.view.status();
tab.last_status = current;
// Work that finishes in the tab you are already looking at needs no
// flag — you watched it happen.
const visible = self.activeTab() == tab;
if (!visible and current == .idle and previous != .idle) {
tab.done_unseen = true;
}
self.refreshStatus(tab);
}
/// The view lost its last pane, so the tab goes with it.
fn onViewEmpty(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
@@ -597,6 +861,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
// Dropping them here reaps the children rather than orphaning them.
for (self.tabs.items) |tab| {
tab.view.destroy();
if (tab.custom_name) |name| self.alloc.free(name);
self.alloc.destroy(tab);
}
self.tabs.deinit(self.alloc);
@@ -684,6 +949,10 @@ fn onShortcut(
self.addPane(.web);
return 1;
},
gdk.KEY_R, gdk.KEY_r => {
if (self.activeTab()) |tab| self.beginRename(tab);
return 1;
},
gdk.KEY_V, gdk.KEY_v => {
// Only a terminal needs us to encode a paste for it. A web
// pane has its own clipboard handling, so the key is left