From 4e0e40037246288df1822d3a0a5f213105e699d1 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 12 Aug 2026 18:50:16 -0400 Subject: [PATCH] Fix hooks. --- README.md | 72 +++++++++++++---- hooks/playpen-status.sh | 99 +++++++++++++++++++---- src/Pane.zig | 168 +++++++++++++++++++++++++++++++++++++--- src/Terminal.zig | 7 ++ src/View.zig | 42 ++++++++++ src/Window.zig | 118 +++++++++------------------- src/style.css | 91 +++++++++++++++++++++- 7 files changed, 471 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 8003cfc..debaf05 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ in principle, but a terminal grid is small. and scripts — opened in one go, parameterised by `{{name}}`, authored by arranging a tab and saving it. See [Layouts](#layouts) - **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether - it is working, waiting on you, or finished while you were elsewhere. See + it is working, waiting on you, or finished and still unanswered. See [Agent status](#agent-status) - **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row. A typed name pins the label; clearing it hands the label back to the panes @@ -345,18 +345,45 @@ So a tab can carry a dot: |---|---| | purple, pulsing | working | | amber | waiting for you — a permission prompt, or a question | -| green | **finished while you were looking at another tab** | +| green | **finished, and you haven't answered it** | | red | stopped on an error | | none | idle | +Each colour is carried at three sizes, so it survives being glanced at rather +than read: an 11px dot on the row, a bar down the row's leading edge, and a wash +behind the whole row. Working gets the bar without the wash — it is the resting +state of a busy afternoon, and tinting half the sidebar all day would only teach +you to stop looking. Inside a tab, a pane that is asking colours its own frame +and header the same way, which is what picks it out of a four-way split. + The green one is the point of the feature. A session that has gone back to idle -looks exactly like one that never ran, so a tab that finishes work while it is -not the visible one latches green and stays that way until you actually visit -it. Nothing else tells you a tab is worth going back to. +looks exactly like one that never ran, so a pane that finishes latches green and +stays that way until you deal with it. Nothing else tells you a tab is worth +going back to. + +**Opening the tab clears the row.** A pane's own dot clears when you go to that +pane or type in it. The two answer slightly different questions: the row's is +"should I go there?", which visiting settles whether or not you then deal with +everything inside, and a pane's is "have you dealt with me?", which only you can +answer. In a split that difference is the whole point — you open a flagged tab, +the row goes quiet, and the panes you haven't been to yet are still marked. + +Work that finishes while you are sitting in the tab flags it too, deliberately. +Having a tab on screen when a session stopped says only that the pixels were in +front of you; watching it finish and then moving on to something else is the +case this is most needed for. It goes quiet as soon as you answer that pane, or +the next time you come back to the tab. + +Green also outranks purple. A tab holding three sessions goes green as soon as +any one of them finishes rather than waiting for the last one to stop, because +"one of these is ready for you" is the news. The two states that actually want +something from you — amber and red — outrank it in turn. Panes report this individually and the tab shows the most urgent of them, so a -four-pane tab still reduces to one dot. The pane headers carry their own dots to -say which pane inside it was the one asking. +four-pane tab still reduces to one dot. The pane headers carry their own dots, +so once the row has brought you to the tab, they say which pane inside it was +the one asking; the row stays lit until every finished pane in it has been +answered. ### How it gets there @@ -385,13 +412,28 @@ comment, so re-running replaces Playpen's own entries rather than stacking duplicates, and the previous file is kept at `settings.json.playpen-backup`. Hooks are read at startup, so open a new session to pick them up. -The hook writes to `/dev/tty`, not to a socket or a daemon. A hook runs as a -child of Claude Code, so its controlling terminal *is* the pty of the pane -Claude is running in — the bytes land in that pane and no other, with nothing to -configure and no way for two concurrent sessions to be mistaken for each other. -It writes there rather than to stdout because Claude Code parses hook stdout as -the hook's JSON result; an escape sequence written there would corrupt the hook -protocol instead of reaching the terminal. +The hook writes to a pty, not to a socket or a daemon: the bytes land in that +pane and no other, with nothing to configure and no way for two concurrent +sessions to be mistaken for each other. It writes there rather than to stdout +because Claude Code parses hook stdout as the hook's JSON result; an escape +sequence written there would corrupt the hook protocol instead of reaching the +terminal. + +Finding that pty is the one genuinely fiddly part, and `/dev/tty` — the obvious +answer, and what this used to do — is the wrong one. **Claude Code starts each +hook in its own session**, so a hook has no controlling terminal at all and +opening `/dev/tty` fails with `ENXIO`. It fails invisibly, too: `[ -w /dev/tty ]` +returns true regardless, because it stats a path whose mode is `0666` rather +than opening it, so guarding on that reports success and then writes into +nothing. Every state change was being dropped on the floor with no error +anywhere. + +The pty is only one hop away, though — it is on Claude Code's own standard file +descriptors. So the script tries its controlling terminal by *opening* it, and +failing that walks up `/proc` for the nearest ancestor holding a pty, with a +`ps -o tty=` fallback for systems without `/proc`. The nearest ancestor is the +right answer even with something in between: a Claude running inside tmux inside +a pane finds tmux's pty, which is where its output actually goes. A tab's title follows your prompt for as long as Claude holds the foreground. Once it exits, the shell's own prompt sets the title back, which is the right @@ -403,7 +445,7 @@ Claude in a microVM (`smolvm`, `krunvm`, anything libkrun-based) still works, because the guest console passes these bytes through to the host pty unchanged: ``` -$ # written to /dev/tty inside the guest, observed on the host pty: +$ # written to the guest's console, observed on the host pty: b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07' ``` diff --git a/hooks/playpen-status.sh b/hooks/playpen-status.sh index 278a97a..091e08d 100755 --- a/hooks/playpen-status.sh +++ b/hooks/playpen-status.sh @@ -8,16 +8,15 @@ # playpen-status.sh idle # finished # playpen-status.sh error # stopped on an error # -# The state is written as OSC 9;4 — the ConEmu progress protocol — straight to -# /dev/tty, and on `busy` the prompt is written as an OSC 0 title alongside it. +# The state is written as OSC 9;4 — the ConEmu progress protocol — to the pty +# Claude Code is running on, and on `busy` the prompt is written as an OSC 0 +# title alongside it. # -# Why /dev/tty and not a socket. A hook runs as a child of Claude Code, so its -# controlling terminal is the pty of the pane Claude is running in. Writing -# there means the bytes arrive in that pane and no other, with nothing to -# configure and no way for two concurrent sessions to be confused for each -# other. It survives a VM boundary too: run Claude inside a microVM and the -# guest's console passes these through to the host pty unchanged, which a unix -# socket on the host could not do. +# Why a pty and not a socket. The bytes then arrive in that pane and no other, +# with nothing to configure and no way for two concurrent sessions to be +# confused for each other. It survives a VM boundary too: run Claude inside a +# microVM and the guest's console passes these through to the host pty +# unchanged, which a unix socket on the host could not do. # # Nothing here is Playpen-specific. OSC 9;4 is what Windows Terminal, ConEmu # and Ghostty already use for taskbar progress, so these hooks light up those @@ -27,10 +26,78 @@ set -u state=${1:-idle} -# No controlling terminal — running headless, in CI, or under a harness that -# detached us. Nothing to report to, and a hook must never be the thing that -# breaks a session, so leave quietly. -[ -w /dev/tty ] || exit 0 +# --------------------------------------------------------------------------- +# Find the pty to write to. +# +# Not /dev/tty, which is the obvious answer and the wrong one: Claude Code +# starts each hook in its own session, so a hook has no controlling terminal +# and opening /dev/tty fails with ENXIO. Worse, `[ -w /dev/tty ]` still says +# yes — it stats the path, whose mode is 0666, rather than opening it — so +# guarding on that reports success and then writes into nothing. +# +# The pty is still one hop away: it is on Claude Code's own standard fds. So +# test our controlling terminal by actually opening it, and otherwise walk up +# the process tree for the nearest ancestor holding a pty open. That nearest +# ancestor is the right answer even when something else is in between: a +# Claude running inside tmux inside a pane finds tmux's pty, which is where +# its output is really going. +tty_target="" + +if (: > /dev/tty) 2>/dev/null; then + tty_target=/dev/tty +fi + +if [ -z "$tty_target" ] && [ -d /proc ]; then + pid=$PPID + hops=0 + while [ "$hops" -lt 16 ] && [ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" != 1 ]; do + for fd in 0 1 2; do + link=$(readlink "/proc/$pid/fd/$fd" 2>/dev/null) || continue + case "$link" in + # Guarded to terminal devices before opening it for write, so this + # can never truncate a regular file an fd happened to point at. + /dev/pts/[0-9]* | /dev/tty[0-9]*) + if (: > "$link") 2>/dev/null; then + tty_target=$link + break + fi + ;; + esac + done + [ -n "$tty_target" ] && break + + # Walk to the parent. The comm field of /proc/pid/stat is parenthesised + # and may itself contain spaces, so cut through it rather than counting + # fields from the start: after the trim, $2 is the ppid. + stat=$(cat "/proc/$pid/stat" 2>/dev/null) || break + # shellcheck disable=SC2086 + set -- ${stat#*") "} + pid=${2:-} + hops=$((hops + 1)) + done +fi + +# No /proc to walk: ask ps for the parent's terminal instead. Linux spells it +# "pts/4" and macOS "s004", so try it both as given and with the tty prefix. +if [ -z "$tty_target" ]; then + name=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d ' \n') + case "$name" in + "" | "?" | "??") ;; + *) + for cand in "/dev/$name" "/dev/tty$name"; do + if (: > "$cand") 2>/dev/null; then + tty_target=$cand + break + fi + done + ;; + esac +fi + +# Nothing anywhere to report to — headless, in CI, or a harness that detached +# us from every terminal. A hook must never be the thing that breaks a +# session, so leave quietly. +[ -n "$tty_target" ] || exit 0 case "$state" in busy) code=3 ;; # indeterminate @@ -39,10 +106,10 @@ case "$state" in *) code=0 ;; # removed esac -# Every write goes to /dev/tty explicitly. stdout belongs to Claude Code, which +# Every write goes to the pty explicitly. stdout belongs to Claude Code, which # parses it as the hook's JSON result; an escape sequence written there would # corrupt the hook protocol rather than reach the terminal. -printf '\033]9;4;%s\007' "$code" > /dev/tty 2>/dev/null || true +printf '\033]9;4;%s\007' "$code" > "$tty_target" 2>/dev/null || true # Only a starting turn carries a task worth naming. The other states leave the # title alone so the shell's own title comes back when the session ends. @@ -72,6 +139,6 @@ fi # and leave the rest to be printed as garbage in the pane. title=$(printf '%s' "$title" | head -n 1 | tr -d '[:cntrl:]' | cut -c1-72) -[ -n "$title" ] && printf '\033]0;%s\007' "$title" > /dev/tty 2>/dev/null +[ -n "$title" ] && printf '\033]0;%s\007' "$title" > "$tty_target" 2>/dev/null exit 0 diff --git a/src/Pane.zig b/src/Pane.zig index d1c1661..4c452fd 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -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, " 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); diff --git a/src/Terminal.zig b/src/Terminal.zig index 03f160a..d6c41e2 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -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; } diff --git a/src/View.zig b/src/View.zig index ca69519..ffca0e1 100644 --- a/src/View.zig +++ b/src/View.zig @@ -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 diff --git a/src/Window.zig b/src/Window.zig index b053ab4..8c32ec3 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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. diff --git a/src/style.css b/src/style.css index 81f380f..8c71362 100644 --- a/src/style.css +++ b/src/style.css @@ -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 {