Fix hooks.
This commit is contained in:
@@ -279,7 +279,7 @@ in principle, but a terminal grid is small.
|
|||||||
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
||||||
arranging a tab and saving it. See [Layouts](#layouts)
|
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
|
- **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)
|
[Agent status](#agent-status)
|
||||||
- **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row.
|
- **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
|
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 |
|
| purple, pulsing | working |
|
||||||
| amber | waiting for you — a permission prompt, or a question |
|
| 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 |
|
| red | stopped on an error |
|
||||||
| none | idle |
|
| 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
|
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
|
looks exactly like one that never ran, so a pane that finishes latches green and
|
||||||
not the visible one latches green and stays that way until you actually visit
|
stays that way until you deal with it. Nothing else tells you a tab is worth
|
||||||
it. Nothing else tells you a tab is worth going back to.
|
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
|
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
|
four-pane tab still reduces to one dot. The pane headers carry their own dots,
|
||||||
say which pane inside it was the one asking.
|
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
|
### 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`.
|
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.
|
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
|
The hook writes to a pty, not to a socket or a daemon: the bytes land in that
|
||||||
child of Claude Code, so its controlling terminal *is* the pty of the pane
|
pane and no other, with nothing to configure and no way for two concurrent
|
||||||
Claude is running in — the bytes land in that pane and no other, with nothing to
|
sessions to be mistaken for each other. It writes there rather than to stdout
|
||||||
configure and no way for two concurrent sessions to be mistaken for each other.
|
because Claude Code parses hook stdout as the hook's JSON result; an escape
|
||||||
It writes there rather than to stdout because Claude Code parses hook stdout as
|
sequence written there would corrupt the hook protocol instead of reaching the
|
||||||
the hook's JSON result; an escape sequence written there would corrupt the hook
|
terminal.
|
||||||
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.
|
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
|
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:
|
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'
|
b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+83
-16
@@ -8,16 +8,15 @@
|
|||||||
# playpen-status.sh idle # finished
|
# playpen-status.sh idle # finished
|
||||||
# playpen-status.sh error # stopped on an error
|
# playpen-status.sh error # stopped on an error
|
||||||
#
|
#
|
||||||
# The state is written as OSC 9;4 — the ConEmu progress protocol — straight to
|
# The state is written as OSC 9;4 — the ConEmu progress protocol — to the pty
|
||||||
# /dev/tty, and on `busy` the prompt is written as an OSC 0 title alongside it.
|
# 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
|
# Why a pty and not a socket. The bytes then arrive in that pane and no other,
|
||||||
# controlling terminal is the pty of the pane Claude is running in. Writing
|
# with nothing to configure and no way for two concurrent sessions to be
|
||||||
# there means the bytes arrive in that pane and no other, with nothing to
|
# confused for each other. It survives a VM boundary too: run Claude inside a
|
||||||
# configure and no way for two concurrent sessions to be confused for each
|
# microVM and the guest's console passes these through to the host pty
|
||||||
# other. It survives a VM boundary too: run Claude inside a microVM and the
|
# unchanged, which a unix socket on the host could not do.
|
||||||
# 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
|
# 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
|
# and Ghostty already use for taskbar progress, so these hooks light up those
|
||||||
@@ -27,10 +26,78 @@ set -u
|
|||||||
|
|
||||||
state=${1:-idle}
|
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
|
# Find the pty to write to.
|
||||||
# breaks a session, so leave quietly.
|
#
|
||||||
[ -w /dev/tty ] || exit 0
|
# 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
|
case "$state" in
|
||||||
busy) code=3 ;; # indeterminate
|
busy) code=3 ;; # indeterminate
|
||||||
@@ -39,10 +106,10 @@ case "$state" in
|
|||||||
*) code=0 ;; # removed
|
*) code=0 ;; # removed
|
||||||
esac
|
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
|
# parses it as the hook's JSON result; an escape sequence written there would
|
||||||
# corrupt the hook protocol rather than reach the terminal.
|
# 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
|
# 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.
|
# 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.
|
# 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)
|
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
|
exit 0
|
||||||
|
|||||||
+154
-10
@@ -78,13 +78,14 @@ pub const Status = Terminal.Status;
|
|||||||
/// What a content kind reports back to its pane. Shared by both kinds so the
|
/// 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.
|
/// pane can wire either one up with the same handlers.
|
||||||
///
|
///
|
||||||
/// A web pane simply never calls `on_status`; it has no equivalent of a
|
/// A web pane simply never calls `on_status` or `on_input`; it has no
|
||||||
/// long-running job to report.
|
/// equivalent of a long-running job to report.
|
||||||
pub const Callbacks = struct {
|
pub const Callbacks = struct {
|
||||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
|
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
|
||||||
|
on_input: *const fn (ctx: ?*anyopaque) void,
|
||||||
ctx: ?*anyopaque,
|
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.
|
/// What a dot is signalling: the four states content can report, plus one it
|
||||||
pub fn statusClass(status: Status) ?[:0]const u8 {
|
/// 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) {
|
return switch (status) {
|
||||||
.idle => null,
|
.needs_input => .needs_input,
|
||||||
.busy => "playpen-status-busy",
|
.failed => .failed,
|
||||||
.needs_input => "playpen-status-input",
|
.busy => if (done_unanswered) .done else .busy,
|
||||||
.failed => "playpen-status-failed",
|
.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
|
/// 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.
|
/// Latest status reported by the content.
|
||||||
status: Status = .idle,
|
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 {
|
pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
||||||
const self = try alloc.create(Pane);
|
const self = try alloc.create(Pane);
|
||||||
errdefer alloc.destroy(self);
|
errdefer alloc.destroy(self);
|
||||||
@@ -229,6 +330,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
|
|||||||
.on_exit = &onContentExit,
|
.on_exit = &onContentExit,
|
||||||
.on_focus = &onContentFocus,
|
.on_focus = &onContentFocus,
|
||||||
.on_status = &onContentStatus,
|
.on_status = &onContentStatus,
|
||||||
|
.on_input = &onContentInput,
|
||||||
.ctx = self,
|
.ctx = self,
|
||||||
};
|
};
|
||||||
self.content = switch (spec) {
|
self.content = switch (spec) {
|
||||||
@@ -291,6 +393,23 @@ pub fn titleSlice(self: *const Pane) [:0]const u8 {
|
|||||||
return std.mem.sliceTo(&self.title, 0);
|
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.
|
/// Mark this pane as the focused one in its view.
|
||||||
pub fn setActive(self: *Pane, active: bool) void {
|
pub fn setActive(self: *Pane, active: bool) void {
|
||||||
if (active) {
|
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
|
// Sits between the kind icon and the title so a busy pane reads as
|
||||||
// "terminal, working, <title>" left to right.
|
// "terminal, working, <title>" left to right.
|
||||||
self.dot.as(gtk.Widget).addCssClass("playpen-status-dot");
|
self.dot.as(gtk.Widget).addCssClass("playpen-status-dot");
|
||||||
setDot(self.dot, statusClass(self.status));
|
self.refreshDot();
|
||||||
header.append(self.dot.as(gtk.Widget));
|
header.append(self.dot.as(gtk.Widget));
|
||||||
|
|
||||||
self.label.setXalign(0);
|
self.label.setXalign(0);
|
||||||
@@ -529,12 +648,37 @@ fn onContentStatus(ctx: ?*anyopaque, status: Status) void {
|
|||||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||||
if (self.status == status) return;
|
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;
|
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);
|
self.view.paneStatusChanged(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shell exited, or a page called window.close().
|
/// 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 {
|
fn onContentExit(ctx: ?*anyopaque) void {
|
||||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||||
self.view.closePane(self);
|
self.view.closePane(self);
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
|
|||||||
/// it in the tab strip.
|
/// it in the tab strip.
|
||||||
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
|
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,
|
ctx: ?*anyopaque = null,
|
||||||
|
|
||||||
/// What a layout can specify for a terminal pane.
|
/// What a layout can specify for a terminal pane.
|
||||||
@@ -84,6 +89,7 @@ pub fn create(
|
|||||||
.on_exit = cbs.on_exit,
|
.on_exit = cbs.on_exit,
|
||||||
.on_focus = cbs.on_focus,
|
.on_focus = cbs.on_focus,
|
||||||
.on_status = cbs.on_status,
|
.on_status = cbs.on_status,
|
||||||
|
.on_input = cbs.on_input,
|
||||||
.ctx = cbs.ctx,
|
.ctx = cbs.ctx,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,6 +317,7 @@ fn onKeyPressed(
|
|||||||
// Typing should always snap the view back to the prompt.
|
// Typing should always snap the view back to the prompt.
|
||||||
self.session.term.screens.active.pages.scroll(.active);
|
self.session.term.screens.active.pages.scroll(.active);
|
||||||
self.session.write(encoded);
|
self.session.write(encoded);
|
||||||
|
self.on_input(self.ctx);
|
||||||
self.area.as(gtk.Widget).queueDraw();
|
self.area.as(gtk.Widget).queueDraw();
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,12 +81,20 @@ closing: bool = false,
|
|||||||
on_empty: *const fn (ctx: ?*anyopaque) void,
|
on_empty: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_status: *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,
|
ctx: ?*anyopaque = null,
|
||||||
|
|
||||||
pub const Callbacks = struct {
|
pub const Callbacks = struct {
|
||||||
on_empty: *const fn (ctx: ?*anyopaque) void,
|
on_empty: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||||
on_status: *const fn (ctx: ?*anyopaque) void,
|
on_status: *const fn (ctx: ?*anyopaque) void,
|
||||||
|
on_finished: *const fn (ctx: ?*anyopaque) void,
|
||||||
ctx: ?*anyopaque,
|
ctx: ?*anyopaque,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +109,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
|
|||||||
.on_empty = cbs.on_empty,
|
.on_empty = cbs.on_empty,
|
||||||
.on_title = cbs.on_title,
|
.on_title = cbs.on_title,
|
||||||
.on_status = cbs.on_status,
|
.on_status = cbs.on_status,
|
||||||
|
.on_finished = cbs.on_finished,
|
||||||
.ctx = cbs.ctx,
|
.ctx = cbs.ctx,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,6 +322,19 @@ pub fn setFocused(self: *View, pane: *Pane) void {
|
|||||||
self.focused = pane;
|
self.focused = pane;
|
||||||
pane.setActive(true);
|
pane.setActive(true);
|
||||||
self.on_title(self.ctx);
|
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 {
|
pub fn paneTitleChanged(self: *View, pane: *Pane) void {
|
||||||
@@ -347,6 +369,26 @@ pub fn status(self: *View) Status {
|
|||||||
return worst;
|
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
|
// Layouts
|
||||||
|
|
||||||
|
|||||||
+36
-80
@@ -58,43 +58,9 @@ layout_popover: *gtk.Popover,
|
|||||||
/// they belong to are on screen.
|
/// they belong to are on screen.
|
||||||
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
|
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
|
||||||
|
|
||||||
/// What a tab's sidebar row is signalling. The four pane-level states, plus
|
/// What a sidebar row is signalling. Defined with the dots themselves, since
|
||||||
/// one the panes can't know about on their own.
|
/// a row and a pane header show the same five states for the same reasons.
|
||||||
///
|
const Attention = Pane.Attention;
|
||||||
/// `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
|
/// A single tab: a view of one or more panes, plus the sidebar row that
|
||||||
/// selects it.
|
/// selects it.
|
||||||
@@ -119,13 +85,14 @@ const Tab = struct {
|
|||||||
rename_popover: *gtk.Popover,
|
rename_popover: *gtk.Popover,
|
||||||
rename_entry: *gtk.Entry,
|
rename_entry: *gtk.Entry,
|
||||||
|
|
||||||
/// What the panes were reporting last time we looked, so a busy → idle
|
/// Set when a pane in this tab finished since the last time you opened it.
|
||||||
/// transition can be told apart from an idle tab that never ran.
|
///
|
||||||
last_status: View.Status = .idle,
|
/// The row and the panes answer slightly different questions, which is why
|
||||||
|
/// this exists alongside the panes' own latches. The row's question is
|
||||||
/// Set when work finished while this tab was not the visible one, and
|
/// "should I go there?", and opening the tab settles it whether or not you
|
||||||
/// cleared when it is next selected.
|
/// then deal with every pane inside. A pane's question is "have you dealt
|
||||||
done_unseen: bool = false,
|
/// with me?", which only you can answer.
|
||||||
|
finished_since_visit: bool = false,
|
||||||
|
|
||||||
name: [16]u8,
|
name: [16]u8,
|
||||||
name_len: usize,
|
name_len: usize,
|
||||||
@@ -136,15 +103,12 @@ const Tab = struct {
|
|||||||
|
|
||||||
/// What the row should be showing right now.
|
/// What the row should be showing right now.
|
||||||
///
|
///
|
||||||
/// The latch outranks `busy` deliberately: if one pane is still working
|
/// Both halves have to hold. The flag alone would keep a row lit after you
|
||||||
/// but another has already finished unseen, the finished one is the news.
|
/// 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 {
|
fn attention(self: *const Tab) Attention {
|
||||||
return switch (self.view.status()) {
|
const news = self.finished_since_visit and self.view.anyDoneUnanswered();
|
||||||
.needs_input => .needs_input,
|
return .of(self.view.status(), news);
|
||||||
.failed => .failed,
|
|
||||||
.busy => if (self.done_unseen) .done else .busy,
|
|
||||||
.idle => if (self.done_unseen) .done else .none,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,6 +262,7 @@ fn newTabEmpty(self: *Window) !*Tab {
|
|||||||
.on_empty = &onViewEmpty,
|
.on_empty = &onViewEmpty,
|
||||||
.on_title = &onViewTitle,
|
.on_title = &onViewTitle,
|
||||||
.on_status = &onViewStatus,
|
.on_status = &onViewStatus,
|
||||||
|
.on_finished = &onViewFinished,
|
||||||
.ctx = tab,
|
.ctx = tab,
|
||||||
});
|
});
|
||||||
errdefer view.destroy();
|
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
|
// 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.
|
// line up in a column instead of being pushed around by title length.
|
||||||
tab.dot.as(gtk.Widget).addCssClass("playpen-status-dot");
|
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));
|
row_box.append(tab.dot.as(gtk.Widget));
|
||||||
|
|
||||||
const close = gtk.Button.newFromIconName("window-close-symbolic");
|
const close = gtk.Button.newFromIconName("window-close-symbolic");
|
||||||
@@ -710,12 +675,12 @@ fn select(self: *Window, tab: *Tab) void {
|
|||||||
self.list.selectRow(tab.row);
|
self.list.selectRow(tab.row);
|
||||||
tab.view.focus();
|
tab.view.focus();
|
||||||
|
|
||||||
// Visiting the tab is what "seeing it" means, so this is where the
|
// Opening the tab is the acknowledgement the row was asking for, and the
|
||||||
// finished-while-you-were-away flag is spent.
|
// pane you land in counts as answered along with it. Any other pane in a
|
||||||
if (tab.done_unseen) {
|
// split keeps its own dot until you go to it.
|
||||||
tab.done_unseen = false;
|
tab.finished_since_visit = false;
|
||||||
|
tab.view.answerFocused();
|
||||||
self.refreshStatus(tab);
|
self.refreshStatus(tab);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn indexOf(self: *Window, tab: *Tab) ?usize {
|
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.
|
/// pane changing state doesn't change any of the text.
|
||||||
fn refreshStatus(self: *Window, tab: *Tab) void {
|
fn refreshStatus(self: *Window, tab: *Tab) void {
|
||||||
_ = self;
|
_ = self;
|
||||||
|
Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, tab.attention());
|
||||||
const attention = tab.attention();
|
|
||||||
Pane.setDot(tab.dot, attention.class());
|
|
||||||
tab.dot.as(gtk.Widget).setTooltipText(attention.tooltip());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn onViewTitle(ctx: ?*anyopaque) void {
|
fn onViewTitle(ctx: ?*anyopaque) void {
|
||||||
@@ -823,27 +785,21 @@ fn onViewTitle(ctx: ?*anyopaque) void {
|
|||||||
tab.window.refreshLabel(tab);
|
tab.window.refreshLabel(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pane in this tab changed state.
|
/// A pane in this tab changed state, or answered one it was carrying.
|
||||||
///
|
|
||||||
/// 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 {
|
fn onViewStatus(ctx: ?*anyopaque) void {
|
||||||
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
||||||
const self = tab.window;
|
tab.window.refreshStatus(tab);
|
||||||
|
}
|
||||||
|
|
||||||
const previous = tab.last_status;
|
/// A pane in this tab finished work.
|
||||||
const current = tab.view.status();
|
///
|
||||||
tab.last_status = current;
|
/// 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
|
||||||
// Work that finishes in the tab you are already looking at needs no
|
/// when that stops being news.
|
||||||
// flag — you watched it happen.
|
fn onViewFinished(ctx: ?*anyopaque) void {
|
||||||
const visible = self.activeTab() == tab;
|
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
||||||
if (!visible and current == .idle and previous != .idle) {
|
tab.finished_since_visit = true;
|
||||||
tab.done_unseen = true;
|
tab.window.refreshStatus(tab);
|
||||||
}
|
|
||||||
|
|
||||||
self.refreshStatus(tab);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The view lost its last pane, so the tab goes with it.
|
/// The view lost its last pane, so the tab goes with it.
|
||||||
|
|||||||
+89
-2
@@ -38,10 +38,57 @@
|
|||||||
color: #f0ecf8;
|
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;
|
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. */
|
/* Keep the close button unobtrusive until the row is hovered or current. */
|
||||||
.playpen-close {
|
.playpen-close {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -137,7 +184,7 @@
|
|||||||
are picked to survive that: amber and red carry the two states that actually
|
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. */
|
want you, and they are the only warm colours anywhere in the window. */
|
||||||
.playpen-status-dot {
|
.playpen-status-dot {
|
||||||
-gtk-icon-size: 8px;
|
-gtk-icon-size: 11px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +277,46 @@
|
|||||||
opacity: 0.55;
|
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
|
/* The pane being dragged. There is no separate drop indicator: the layout
|
||||||
rearranges live during the drag, so the view itself is the preview. */
|
rearranges live during the drag, so the view itself is the preview. */
|
||||||
.playpen-pane.dragging {
|
.playpen-pane.dragging {
|
||||||
|
|||||||
Reference in New Issue
Block a user