Add hook system.
This commit is contained in:
@@ -69,12 +69,22 @@ pub const Spec = union(Kind) {
|
||||
}
|
||||
};
|
||||
|
||||
/// What the content of a pane is currently doing. Only a terminal ever
|
||||
/// reports this — a web pane is always `.idle` — but it lives here rather
|
||||
/// than on Terminal so the view can aggregate across panes without caring
|
||||
/// which kind each one is.
|
||||
pub const Status = Terminal.Status;
|
||||
|
||||
/// What a content kind reports back to its pane. Shared by both kinds so the
|
||||
/// pane can wire either one up with the same handlers.
|
||||
///
|
||||
/// A web pane simply never calls `on_status`; it has no equivalent of a
|
||||
/// long-running job to report.
|
||||
pub const Callbacks = struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
@@ -106,6 +116,53 @@ pub const Content = union(Kind) {
|
||||
|
||||
const all_sides = [_]Side{ .left, .right, .top, .bottom };
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status dots
|
||||
//
|
||||
// Both the pane header and the sidebar row show the same small filled circle,
|
||||
// recoloured by CSS. Symbolic icons take their colour from the `color`
|
||||
// property, so the state is carried entirely by which class is applied and
|
||||
// there is no second icon to keep in sync.
|
||||
|
||||
/// The dot itself. A filled circle at whatever size the context asks for.
|
||||
pub const status_icon = "media-record-symbolic";
|
||||
|
||||
/// Every class a dot can carry. Listed so that applying one can clear the
|
||||
/// others without the caller having to remember what it set last.
|
||||
pub const status_classes = [_][:0]const u8{
|
||||
"playpen-status-busy",
|
||||
"playpen-status-done",
|
||||
"playpen-status-input",
|
||||
"playpen-status-failed",
|
||||
};
|
||||
|
||||
/// Point a dot at one of `status_classes`, or pass null to hide it.
|
||||
///
|
||||
/// Hiding rather than clearing the colour matters: an idle pane should show
|
||||
/// nothing at all, not a dot in the background colour that still takes up
|
||||
/// space and still catches the eye when the row is hovered.
|
||||
pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void {
|
||||
const w = dot.as(gtk.Widget);
|
||||
for (status_classes) |c| w.removeCssClass(c);
|
||||
|
||||
if (class) |c| {
|
||||
w.addCssClass(c);
|
||||
w.setVisible(1);
|
||||
} else {
|
||||
w.setVisible(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The class a pane-level status shows as, or null for idle.
|
||||
pub fn statusClass(status: Status) ?[:0]const u8 {
|
||||
return switch (status) {
|
||||
.idle => null,
|
||||
.busy => "playpen-status-busy",
|
||||
.needs_input => "playpen-status-input",
|
||||
.failed => "playpen-status-failed",
|
||||
};
|
||||
}
|
||||
|
||||
/// How close to the view's outer border a drop must be, in pixels, to place
|
||||
/// the pane against the whole layout instead of against the pane under the
|
||||
/// pointer. This is what lets a pane dropped along the bottom span the full
|
||||
@@ -125,9 +182,18 @@ box: *gtk.Box,
|
||||
header: *gtk.Box,
|
||||
label: *gtk.Label,
|
||||
|
||||
/// Status dot in the header, hidden while the pane is idle. This is the
|
||||
/// per-pane counterpart of the dot on the sidebar row: with several panes in
|
||||
/// a tab, the row tells you the tab needs attention and this tells you which
|
||||
/// pane inside it does.
|
||||
dot: *gtk.Image,
|
||||
|
||||
/// Latest title reported by the content, kept NUL-terminated for GTK.
|
||||
title: [128:0]u8 = @splat(0),
|
||||
|
||||
/// Latest status reported by the content.
|
||||
status: Status = .idle,
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
||||
const self = try alloc.create(Pane);
|
||||
errdefer alloc.destroy(self);
|
||||
@@ -142,6 +208,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
||||
.box = gtk.Box.new(.vertical, 0),
|
||||
.header = gtk.Box.new(.horizontal, 4),
|
||||
.label = gtk.Label.new(""),
|
||||
.dot = gtk.Image.newFromIconName(status_icon),
|
||||
};
|
||||
setTitle(self, kind.initialTitle());
|
||||
|
||||
@@ -151,6 +218,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
||||
.on_title = &onContentTitle,
|
||||
.on_exit = &onContentExit,
|
||||
.on_focus = &onContentFocus,
|
||||
.on_status = &onContentStatus,
|
||||
.ctx = self,
|
||||
};
|
||||
self.content = switch (spec) {
|
||||
@@ -234,6 +302,12 @@ fn buildHeader(self: *Pane) void {
|
||||
icon.as(gtk.Widget).addCssClass("playpen-pane-icon");
|
||||
header.append(icon.as(gtk.Widget));
|
||||
|
||||
// Sits between the kind icon and the title so a busy pane reads as
|
||||
// "terminal, working, <title>" left to right.
|
||||
self.dot.as(gtk.Widget).addCssClass("playpen-status-dot");
|
||||
setDot(self.dot, statusClass(self.status));
|
||||
header.append(self.dot.as(gtk.Widget));
|
||||
|
||||
self.label.setXalign(0);
|
||||
self.label.setEllipsize(.end);
|
||||
self.label.as(gtk.Widget).setHexpand(1);
|
||||
@@ -406,6 +480,15 @@ fn onContentTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
self.view.paneTitleChanged(self);
|
||||
}
|
||||
|
||||
fn onContentStatus(ctx: ?*anyopaque, status: Status) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
if (self.status == status) return;
|
||||
|
||||
self.status = status;
|
||||
setDot(self.dot, statusClass(status));
|
||||
self.view.paneStatusChanged(self);
|
||||
}
|
||||
|
||||
/// The shell exited, or a page called window.close().
|
||||
fn onContentExit(ctx: ?*anyopaque) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
|
||||
@@ -37,6 +37,10 @@ watch: c_uint = 0,
|
||||
/// True once the child process has exited and the PTY hung up.
|
||||
exited: bool = false,
|
||||
|
||||
/// Latest state reported by the child. Kept so a repeated report of the
|
||||
/// state we are already in doesn't wake the UI for nothing.
|
||||
status: Status = .idle,
|
||||
|
||||
/// A script to run once the shell is ready, from the layout this pane came
|
||||
/// from. Held rather than written at spawn time — see `flushStartupCommand`.
|
||||
startup_command: ?[]u8 = null,
|
||||
@@ -50,12 +54,41 @@ on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
/// Called when the child process exits.
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
/// Called when the running program reports a change of state (OSC 9;4).
|
||||
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
|
||||
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
/// What the program in this session is currently doing, as reported by
|
||||
/// OSC 9;4 — the ConEmu progress protocol.
|
||||
///
|
||||
/// Nothing here is Claude-specific. The protocol is a general "what is this
|
||||
/// program up to" channel that any long-running command can drive; Claude
|
||||
/// Code just happens to be the one we ship hooks for. That is deliberate: a
|
||||
/// build that reports progress lights the same indicator as an agent that
|
||||
/// is thinking, because from across the room they mean the same thing —
|
||||
/// this pane is working, leave it alone for now.
|
||||
pub const Status = enum {
|
||||
/// Nothing running, or whatever was running has finished.
|
||||
idle,
|
||||
|
||||
/// Working. The protocol distinguishes a percentage from an
|
||||
/// indeterminate spinner; a tab strip is too small for a progress bar,
|
||||
/// so both collapse to the same thing here.
|
||||
busy,
|
||||
|
||||
/// Blocked on the user — a permission prompt, or a question.
|
||||
needs_input,
|
||||
|
||||
/// Stopped on an error.
|
||||
failed,
|
||||
};
|
||||
|
||||
pub const Callbacks = struct {
|
||||
on_damage: *const fn (ctx: ?*anyopaque) void,
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
@@ -92,6 +125,7 @@ pub fn create(
|
||||
.on_damage = cbs.on_damage,
|
||||
.on_title = cbs.on_title,
|
||||
.on_exit = cbs.on_exit,
|
||||
.on_status = cbs.on_status,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
errdefer self.term.deinit(alloc);
|
||||
@@ -106,6 +140,7 @@ pub fn create(
|
||||
var effects = vt.TerminalStream.Handler.Effects.readonly;
|
||||
effects.write_pty = &effectWritePty;
|
||||
effects.title_changed = &effectTitleChanged;
|
||||
effects.progress_report = &effectProgressReport;
|
||||
self.stream.handler.effects = effects;
|
||||
|
||||
const shell = try defaultShell(alloc);
|
||||
@@ -233,6 +268,30 @@ fn effectTitleChanged(handler: *vt.TerminalStream.Handler) void {
|
||||
self.on_title(self.ctx, self.term.title.items);
|
||||
}
|
||||
|
||||
/// Effect callback: OSC 9;4 reported what the child is doing.
|
||||
///
|
||||
/// The five states the ConEmu protocol defines collapse onto the four a pane
|
||||
/// can show. `set` carries a percentage as well, which is dropped: a sidebar
|
||||
/// row has no room for a progress bar, and the question this feature answers
|
||||
/// is "does this need me?", not "how far along is it?".
|
||||
fn effectProgressReport(
|
||||
handler: *vt.TerminalStream.Handler,
|
||||
report: vt.osc.Command.ProgressReport,
|
||||
) void {
|
||||
const self = fromHandler(handler);
|
||||
|
||||
const status: Status = switch (report.state) {
|
||||
.remove => .idle,
|
||||
.set, .indeterminate => .busy,
|
||||
.pause => .needs_input,
|
||||
.@"error" => .failed,
|
||||
};
|
||||
|
||||
if (self.status == status) return;
|
||||
self.status = status;
|
||||
self.on_status(self.ctx, status);
|
||||
}
|
||||
|
||||
/// Recover the owning Session from a stream callback. The handler holds a
|
||||
/// pointer to our embedded `term` field, so we can walk back from it.
|
||||
fn fromHandler(handler: *vt.TerminalStream.Handler) *Session {
|
||||
|
||||
@@ -52,11 +52,18 @@ on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
/// Called when this terminal takes keyboard focus.
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
/// Called when the child reports a change of state, so the owner can show
|
||||
/// it in the tab strip.
|
||||
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
|
||||
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
/// What a layout can specify for a terminal pane.
|
||||
pub const Options = Session.Options;
|
||||
|
||||
/// What the child is doing. Reported by the session; passed straight through.
|
||||
pub const Status = Session.Status;
|
||||
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
opts: Options,
|
||||
@@ -76,6 +83,7 @@ pub fn create(
|
||||
.on_title = cbs.on_title,
|
||||
.on_exit = cbs.on_exit,
|
||||
.on_focus = cbs.on_focus,
|
||||
.on_status = cbs.on_status,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
|
||||
@@ -87,6 +95,7 @@ pub fn create(
|
||||
.on_damage = &onDamage,
|
||||
.on_title = &onSessionTitle,
|
||||
.on_exit = &onSessionExit,
|
||||
.on_status = &onSessionStatus,
|
||||
.ctx = self,
|
||||
});
|
||||
errdefer self.session.destroy();
|
||||
@@ -189,6 +198,11 @@ fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
self.on_title(self.ctx, title);
|
||||
}
|
||||
|
||||
fn onSessionStatus(ctx: ?*anyopaque, status: Status) void {
|
||||
const self: *Terminal = @ptrCast(@alignCast(ctx.?));
|
||||
self.on_status(self.ctx, status);
|
||||
}
|
||||
|
||||
fn onNotifyHasFocus(
|
||||
_: *gtk.DrawingArea,
|
||||
_: *gobject.ParamSpec,
|
||||
|
||||
@@ -20,6 +20,7 @@ const View = @This();
|
||||
|
||||
pub const Side = Layout.Side;
|
||||
pub const Kind = Pane.Kind;
|
||||
pub const Status = Pane.Status;
|
||||
|
||||
/// Where a dragged pane would land.
|
||||
pub const Target = struct {
|
||||
@@ -72,11 +73,13 @@ closing: bool = false,
|
||||
|
||||
on_empty: *const fn (ctx: ?*anyopaque) void,
|
||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||
on_status: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub const Callbacks = struct {
|
||||
on_empty: *const fn (ctx: ?*anyopaque) void,
|
||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||
on_status: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
@@ -90,6 +93,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
|
||||
.layout = .{ .alloc = alloc },
|
||||
.on_empty = cbs.on_empty,
|
||||
.on_title = cbs.on_title,
|
||||
.on_status = cbs.on_status,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
|
||||
@@ -215,6 +219,10 @@ pub fn closePane(self: *View, pane: *Pane) void {
|
||||
self.setFocused(self.panes.items[next]);
|
||||
self.panes.items[next].grabFocus();
|
||||
self.on_title(self.ctx);
|
||||
|
||||
// Closing a busy pane changes what the tab as a whole is reporting, so
|
||||
// the row has to be recomputed even though no pane changed its own state.
|
||||
self.on_status(self.ctx);
|
||||
}
|
||||
|
||||
pub fn setFocused(self: *View, pane: *Pane) void {
|
||||
@@ -229,6 +237,34 @@ pub fn paneTitleChanged(self: *View, pane: *Pane) void {
|
||||
if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
pub fn paneStatusChanged(self: *View, pane: *Pane) void {
|
||||
_ = pane;
|
||||
if (self.closing) return;
|
||||
self.on_status(self.ctx);
|
||||
}
|
||||
|
||||
/// The view's status: the most urgent thing any of its panes is reporting.
|
||||
///
|
||||
/// A tab is one row however many panes it holds, so the row has to say
|
||||
/// something about all of them at once. Urgency wins rather than, say, the
|
||||
/// focused pane's status, because the whole point of the indicator is to be
|
||||
/// read from another tab — a pane blocked on a permission prompt matters
|
||||
/// even if it isn't the one you left focused.
|
||||
pub fn status(self: *View) Status {
|
||||
var worst: Status = .idle;
|
||||
for (self.panes.items) |pane| {
|
||||
worst = switch (pane.status) {
|
||||
// Nothing outranks a pane waiting on the user, so this can
|
||||
// return the moment one is found.
|
||||
.needs_input => return .needs_input,
|
||||
.failed => .failed,
|
||||
.busy => if (worst == .failed) worst else .busy,
|
||||
.idle => worst,
|
||||
};
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Layouts
|
||||
|
||||
|
||||
+272
-3
@@ -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
|
||||
|
||||
@@ -129,6 +129,61 @@
|
||||
color: #b29df5;
|
||||
}
|
||||
|
||||
/* Status dots. One shared appearance for the sidebar row and the pane header,
|
||||
coloured entirely by which state class is on the icon. Symbolic icons take
|
||||
their colour from `color`, so nothing here needs a second asset.
|
||||
|
||||
These are read at a glance from across a screen full of tabs, so the colours
|
||||
are picked to survive that: amber and red carry the two states that actually
|
||||
want you, and they are the only warm colours anywhere in the window. */
|
||||
.playpen-status-dot {
|
||||
-gtk-icon-size: 8px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.playpen-status-busy,
|
||||
.playpen-status-done,
|
||||
.playpen-status-input,
|
||||
.playpen-status-failed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Working: the same accent as an active pane's border, so "busy" reads as
|
||||
ordinary activity rather than something gone wrong. */
|
||||
.playpen-status-busy {
|
||||
color: #b29df5;
|
||||
animation: playpen-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Finished while you were elsewhere. Green rather than the accent so it is
|
||||
distinct from still-working at a glance, which is the one distinction this
|
||||
whole indicator exists to make. */
|
||||
.playpen-status-done {
|
||||
color: #7ddc9a;
|
||||
}
|
||||
|
||||
/* Blocked on you. Amber, and not animated — a pulsing dot reads as progress,
|
||||
and this is the opposite of progress. */
|
||||
.playpen-status-input {
|
||||
color: #f0c069;
|
||||
}
|
||||
|
||||
.playpen-status-failed {
|
||||
color: #f2a0a0;
|
||||
}
|
||||
|
||||
@keyframes playpen-pulse {
|
||||
0% { opacity: 0.35; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* Renaming a tab: a single entry in a popover hanging off the row. */
|
||||
.playpen-rename {
|
||||
padding: 8px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* A web pane's navigation bar, below the pane header. Kept visually quieter
|
||||
than the header so the two rows don't compete. */
|
||||
.playpen-nav {
|
||||
|
||||
Reference in New Issue
Block a user