Fix hooks.

This commit is contained in:
Greyson Parrelli
2026-08-12 18:50:16 -04:00
parent dca5ec0b7d
commit 4e0e400372
7 changed files with 471 additions and 126 deletions
+156 -12
View File
@@ -78,13 +78,14 @@ 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.
/// A web pane simply never calls `on_status` or `on_input`; 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,
on_input: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
@@ -158,14 +159,106 @@ pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void {
}
}
/// 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",
};
/// What a dot is signalling: the four states content can report, plus one it
/// cannot know about on its own.
///
/// `done` is the whole reason this is a separate type from `Status`. Content
/// that has gone back to idle is indistinguishable from content that never
/// ran, and "it finished" is exactly the thing worth knowing when you are
/// deciding what to go back to. So work that lands while you are looking
/// somewhere else latches here and stays until you answer it.
///
/// The same five states drive both dots. A pane's dot is spent by going to
/// that pane; a row's by visiting that tab. Two scopes of the same question —
/// which tab wants me, and which pane inside it — so they share the mapping
/// rather than each having their own.
pub const Attention = enum {
none,
busy,
done,
needs_input,
failed,
/// What a status and an unanswered latch add up to.
///
/// `done` outranks `busy` deliberately: when one pane has finished and is
/// still waiting on you while another is working, the finished one is the
/// news. The two states that actually want you outrank it in turn.
pub fn of(status: Status, done_unanswered: bool) Attention {
return switch (status) {
.needs_input => .needs_input,
.failed => .failed,
.busy => if (done_unanswered) .done else .busy,
.idle => if (done_unanswered) .done else .none,
};
}
/// The classes a state paints with: one on the dot, one on the surface
/// carrying it — a sidebar row, or a pane's frame.
///
/// Returned as a pair so the two can never drift into disagreeing about
/// what colour a state is. Null means idle, which paints nothing at all.
pub fn classes(self: Attention) ?struct {
dot: [:0]const u8,
surface: [:0]const u8,
} {
return switch (self) {
.none => null,
.busy => .{ .dot = "playpen-status-busy", .surface = "playpen-attn-busy" },
.done => .{ .dot = "playpen-status-done", .surface = "playpen-attn-done" },
.needs_input => .{ .dot = "playpen-status-input", .surface = "playpen-attn-input" },
.failed => .{ .dot = "playpen-status-failed", .surface = "playpen-attn-failed" },
};
}
/// The dot's CSS class, or null when nothing should be shown at all.
pub fn class(self: Attention) ?[:0]const u8 {
return if (self.classes()) |c| c.dot else null;
}
/// The class for the row or pane the dot sits on, or null when idle.
pub fn surfaceClass(self: Attention) ?[:0]const u8 {
return if (self.classes()) |c| c.surface else null;
}
pub 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",
};
}
};
/// Every class a surface can carry, for the same reason as `status_classes`.
pub const attention_classes = [_][:0]const u8{
"playpen-attn-busy",
"playpen-attn-done",
"playpen-attn-input",
"playpen-attn-failed",
};
/// Bring a dot in line with what it should be signalling. The tooltip comes
/// along with the colour, since a colour on its own doesn't say what it means.
pub fn applyDot(dot: *gtk.Image, state: Attention) void {
setDot(dot, state.class());
dot.as(gtk.Widget).setTooltipText(state.tooltip());
}
/// Paint a state onto a dot and the surface around it at once.
///
/// A dot alone is a few pixels, which is fine once you know where to look and
/// no use at all for the thing this is for: sweeping a sidebar and picking out
/// the row that wants you. The surface class is what lets the row and the pane
/// frame carry the same colour at a size you can catch out of the corner of
/// your eye.
pub fn applyAttention(surface: *gtk.Widget, dot: *gtk.Image, state: Attention) void {
applyDot(dot, state);
for (attention_classes) |c| surface.removeCssClass(c);
if (state.surfaceClass()) |c| surface.addCssClass(c);
}
/// How close to the view's outer border a drop must be, in pixels, to place
@@ -203,6 +296,14 @@ title: [128:0]u8 = @splat(0),
/// Latest status reported by the content.
status: Status = .idle,
/// Set when this pane's content finished and you haven't answered it yet.
///
/// Latched here because this is the only place that sees the transition: by
/// the time you glance at the pane, work that finished and work that never
/// started look identical. It is spent by typing into the pane — see
/// `onContentInput` for why that and not merely looking.
done_unanswered: bool = false,
pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
const self = try alloc.create(Pane);
errdefer alloc.destroy(self);
@@ -229,6 +330,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
.on_exit = &onContentExit,
.on_focus = &onContentFocus,
.on_status = &onContentStatus,
.on_input = &onContentInput,
.ctx = self,
};
self.content = switch (spec) {
@@ -291,6 +393,23 @@ pub fn titleSlice(self: *const Pane) [:0]const u8 {
return std.mem.sliceTo(&self.title, 0);
}
/// What this pane's dot is signalling right now.
pub fn attention(self: *const Pane) Attention {
return .of(self.status, self.done_unanswered);
}
/// Bring the header dot and the pane's own frame in line with its state.
fn refreshDot(self: *Pane) void {
applyAttention(self.widget(), self.dot, self.attention());
}
/// You have answered whatever this pane finished, so it stops asking.
pub fn markAnswered(self: *Pane) void {
if (!self.done_unanswered) return;
self.done_unanswered = false;
self.refreshDot();
}
/// Mark this pane as the focused one in its view.
pub fn setActive(self: *Pane, active: bool) void {
if (active) {
@@ -315,7 +434,7 @@ fn buildHeader(self: *Pane) void {
// 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));
self.refreshDot();
header.append(self.dot.as(gtk.Widget));
self.label.setXalign(0);
@@ -529,12 +648,37 @@ fn onContentStatus(ctx: ?*anyopaque, status: Status) void {
const self: *Pane = @ptrCast(@alignCast(ctx.?));
if (self.status == status) return;
// Work going quiet is the transition worth remembering, and it is worth
// remembering whether or not anyone was watching: a session you saw finish
// and then left alone is still a session waiting on you.
const finished = status == .idle and self.status != .idle;
if (finished) self.done_unanswered = true;
self.status = status;
setDot(self.dot, statusClass(status));
self.refreshDot();
// The tab needs to hear about a finish as an event, not just see the
// state afterwards, so that opening the tab can clear it.
if (finished) self.view.paneFinished(self);
self.view.paneStatusChanged(self);
}
/// The shell exited, or a page called window.close().
/// The user typed into this pane.
///
/// This, rather than focus or visibility, is what clears the dot. Having the
/// tab on screen when a session finished says only that the pixels were in
/// front of you; typing into it is the first moment anything says you dealt
/// with what it had to say. The pane going busy again is a natural consequence
/// of the same keystrokes, so the two never disagree.
fn onContentInput(ctx: ?*anyopaque) void {
const self: *Pane = @ptrCast(@alignCast(ctx.?));
if (!self.done_unanswered) return;
self.markAnswered();
self.view.paneStatusChanged(self);
}
fn onContentExit(ctx: ?*anyopaque) void {
const self: *Pane = @ptrCast(@alignCast(ctx.?));
self.view.closePane(self);
+7
View File
@@ -56,6 +56,11 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
/// it in the tab strip.
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
/// Called when the user types into this terminal. Focus isn't enough for
/// this: the owner uses it as the sign that whatever the session had to say
/// has been dealt with, and that is answering, not looking.
on_input: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
/// What a layout can specify for a terminal pane.
@@ -84,6 +89,7 @@ pub fn create(
.on_exit = cbs.on_exit,
.on_focus = cbs.on_focus,
.on_status = cbs.on_status,
.on_input = cbs.on_input,
.ctx = cbs.ctx,
};
@@ -311,6 +317,7 @@ fn onKeyPressed(
// Typing should always snap the view back to the prompt.
self.session.term.screens.active.pages.scroll(.active);
self.session.write(encoded);
self.on_input(self.ctx);
self.area.as(gtk.Widget).queueDraw();
return 1;
}
+42
View File
@@ -81,12 +81,20 @@ closing: bool = false,
on_empty: *const fn (ctx: ?*anyopaque) void,
on_title: *const fn (ctx: ?*anyopaque) void,
on_status: *const fn (ctx: ?*anyopaque) void,
/// A pane in here just finished work. Separate from `on_status` because the
/// tab needs the *edge*, not the state: "something finished since you were
/// last here" cannot be recovered by looking at the panes afterwards, since
/// one that finished before your last visit looks identical to one that
/// finished after it.
on_finished: *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,
on_finished: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
@@ -101,6 +109,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
.on_empty = cbs.on_empty,
.on_title = cbs.on_title,
.on_status = cbs.on_status,
.on_finished = cbs.on_finished,
.ctx = cbs.ctx,
};
@@ -313,6 +322,19 @@ pub fn setFocused(self: *View, pane: *Pane) void {
self.focused = pane;
pane.setActive(true);
self.on_title(self.ctx);
// Deliberately moving into a pane answers it, the same as typing would.
// The identity check above is what keeps this honest: work finishing in
// the pane you are already sitting in leaves the focus unchanged, so it
// stays flagged — which is the case the whole indicator exists for.
pane.markAnswered();
self.on_status(self.ctx);
}
pub fn paneFinished(self: *View, pane: *Pane) void {
_ = pane;
if (self.closing) return;
self.on_finished(self.ctx);
}
pub fn paneTitleChanged(self: *View, pane: *Pane) void {
@@ -347,6 +369,26 @@ pub fn status(self: *View) Status {
return worst;
}
/// Whether any pane here has finished work that hasn't been answered.
///
/// Any one pane is enough, and deliberately so: the view's own status can't
/// answer this, because a tab holding three agents never goes idle as a whole
/// until the last of them stops, and "one of them is ready for you" is worth
/// hearing before then.
pub fn anyDoneUnanswered(self: *View) bool {
for (self.panes.items) |pane| if (pane.done_unanswered) return true;
return false;
}
/// Answer the pane the user has landed in, without touching its siblings.
///
/// Called when a tab is opened: the pane you arrive in is the one you are
/// looking at, while the others in a split are still holding news you have not
/// gone to yet, and their dots are the only thing that says which.
pub fn answerFocused(self: *View) void {
if (self.focusedPane()) |pane| pane.markAnswered();
}
// -------------------------------------------------------------------------
// Layouts
+37 -81
View File
@@ -58,43 +58,9 @@ 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",
};
}
};
/// What a sidebar row is signalling. 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;
/// A single tab: a view of one or more panes, plus the sidebar row that
/// selects it.
@@ -119,13 +85,14 @@ const Tab = struct {
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,
/// 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,
name: [16]u8,
name_len: usize,
@@ -136,15 +103,12 @@ const Tab = struct {
/// 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.
/// 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 {
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,
};
const news = self.finished_since_visit and self.view.anyDoneUnanswered();
return .of(self.view.status(), news);
}
};
@@ -298,6 +262,7 @@ fn newTabEmpty(self: *Window) !*Tab {
.on_empty = &onViewEmpty,
.on_title = &onViewTitle,
.on_status = &onViewStatus,
.on_finished = &onViewFinished,
.ctx = tab,
});
errdefer view.destroy();
@@ -334,7 +299,7 @@ fn newTabEmpty(self: *Window) !*Tab {
// 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);
Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, .none);
row_box.append(tab.dot.as(gtk.Widget));
const close = gtk.Button.newFromIconName("window-close-symbolic");
@@ -710,12 +675,12 @@ fn select(self: *Window, tab: *Tab) void {
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);
}
// Opening the tab is the acknowledgement 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);
}
fn indexOf(self: *Window, tab: *Tab) ?usize {
@@ -812,10 +777,7 @@ fn refreshLabel(self: *Window, tab: *Tab) void {
/// 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());
Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, tab.attention());
}
fn onViewTitle(ctx: ?*anyopaque) void {
@@ -823,27 +785,21 @@ 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.
/// A pane in this tab changed state, or answered one it was carrying.
fn onViewStatus(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
const self = tab.window;
tab.window.refreshStatus(tab);
}
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);
/// 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) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.finished_since_visit = true;
tab.window.refreshStatus(tab);
}
/// The view lost its last pane, so the tab goes with it.
+89 -2
View File
@@ -38,10 +38,57 @@
color: #f0ecf8;
}
.playpen-list > row:selected image {
/* The kind icon picks up the accent on the current row. The status dot is
excluded: it carries a colour that *is* the information, and this rule is
specific enough to beat the state classes, which used to leave the selected
row's dot accent-coloured whatever it was trying to say. */
.playpen-list > row:selected image:not(.playpen-status-dot) {
color: #b29df5;
}
/* State on the row itself, not just on its dot.
A bar down the leading edge and a wash behind the whole row, both in the
state's colour. The dot says what a row is doing once you are reading it;
these are what let you sweep a sidebar of a dozen tabs and land on the one
that wants you without reading any of them.
The bar is an inset shadow rather than a real border because a border takes
part in layout: 3px of it would shove every label sideways as a tab changed
state, and a sidebar that twitches is worse than one that is hard to read.
These come after the :selected rules deliberately. Equal specificity means
source order decides, and a state outranks "this is the tab you are on". */
.playpen-list > row.playpen-attn-busy {
box-shadow: inset 3px 0 0 #b29df5;
}
/* No wash for working. It is the most common state by far, and a sidebar where
half the rows are tinted all afternoon teaches you to stop looking. */
.playpen-list > row.playpen-attn-done {
box-shadow: inset 3px 0 0 #7ddc9a;
background-color: rgba(125, 220, 154, 0.13);
}
.playpen-list > row.playpen-attn-input {
box-shadow: inset 3px 0 0 #f0c069;
background-color: rgba(240, 192, 105, 0.13);
}
.playpen-list > row.playpen-attn-failed {
box-shadow: inset 3px 0 0 #f2a0a0;
background-color: rgba(242, 160, 160, 0.13);
}
/* The current row keeps its own background: the wash and the selection would
otherwise blend into a colour that reads as neither. The bar survives, which
is the part that carries the state. */
.playpen-list > row:selected.playpen-attn-done,
.playpen-list > row:selected.playpen-attn-input,
.playpen-list > row:selected.playpen-attn-failed {
background-color: #342c4a;
}
/* Keep the close button unobtrusive until the row is hovered or current. */
.playpen-close {
opacity: 0;
@@ -137,7 +184,7 @@
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;
-gtk-icon-size: 11px;
opacity: 0;
}
@@ -230,6 +277,46 @@
opacity: 0.55;
}
/* State on a pane's own frame, so a tab full of splits says which pane is
asking without being read pane by pane.
Only the three states that are news get a border. Working deliberately does
not: it is the resting state of every pane you have set going, and colouring
the frame for it would leave the whole view repainting itself all afternoon
while saying nothing you did not already know. Its pulsing dot is enough.
After `.active` and `.zoomed` in source order, so a state outranks both —
they say where you are, which you know, and this says what happened, which
you don't. `.dragging` still wins over all of it, further down: while you are
moving a pane, feedback about the move is the only thing that matters. */
.playpen-pane.playpen-attn-done {
border-color: #7ddc9a;
}
.playpen-pane.playpen-attn-input {
border-color: #f0c069;
}
.playpen-pane.playpen-attn-failed {
border-color: #f2a0a0;
}
/* The header carries a wash of the same colour. The border alone is a hairline
around a large shape; the header is a solid band right next to the dot and
the title, which is what makes a pane readable at a glance in a four-way
split. */
.playpen-pane.playpen-attn-done .playpen-pane-header {
background-color: rgba(125, 220, 154, 0.12);
}
.playpen-pane.playpen-attn-input .playpen-pane-header {
background-color: rgba(240, 192, 105, 0.12);
}
.playpen-pane.playpen-attn-failed .playpen-pane-header {
background-color: rgba(242, 160, 160, 0.12);
}
/* The pane being dragged. There is no separate drop indicator: the layout
rearranges live during the drag, so the view itself is the preview. */
.playpen-pane.dragging {