diff options
| author | user <user@clank> | 2026-08-15 00:20:34 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 00:20:34 +0200 |
| commit | 7f38aa2205ff5823693565593d8340c5f199d2fe (patch) | |
| tree | 705bb42bc8dc207d5d11159018b2fa1cec783dcd | |
| parent | contrib: the shadertoy presets, usable in sl0ppty (diff) | |
| download | slosh-7f38aa2205ff5823693565593d8340c5f199d2fe.tar.gz slosh-7f38aa2205ff5823693565593d8340c5f199d2fe.tar.xz slosh-7f38aa2205ff5823693565593d8340c5f199d2fe.zip | |
sl0ppty: colour the states that mean a pane is not live
The shader machinery has been able to do this since it existed, and shipped
an opinion about exactly one state. Now it ships three, and the line between
those and the rest is the point:
a pane that is *not live* gets a default -- dead, suspended, scrolled. In
all three the cells are showing something other than a running program's
present: output from something that has exited, a pane that never started,
or the past. None of that is discoverable by looking unless something says
so, which is the whole argument for the feature.
a pane that is merely *not the one you are in* does not -- unfocused, and
one being dragged. That is ambient contrast, which is a taste, and a taste
shipped as a default is how a tool gets a reputation for fighting you. It
is one line of config away for anyone who disagrees, and there is now a
test asserting we have not quietly changed our minds.
So: suspended is greyed and dimmed (inert, still legible -- its contents are
the command it is waiting to run), and scrollback takes a 9% wash of the
scrollback indicator's own colour, so a theme moves the indicator and the
wash together. Checked against phosphor, amber and paper: each gets its own
hue rather than a hardcoded one.
The other half is the gap this leaves. A collapsed header never reaches the
shader pass -- a header is chrome and shaders colour contents (D13) -- so
collapsing a tab used to hide precisely the facts the colour exists to show.
The header now carries them in words, in the same order the status line ranks
them: `exited: status 3`, `not started`, `▲ 12`.
tests/test_states.py asserts the line in both directions, including that an
unfocused pane is left exactly as it was, and that a flattened tab still says
which of its panes never started.
| -rw-r--r-- | DESIGN.md | 2 | ||||
| -rw-r--r-- | HANDOVER.md | 10 | ||||
| -rw-r--r-- | README.md | 14 | ||||
| -rw-r--r-- | config/config.kdl | 26 | ||||
| -rw-r--r-- | src/app.c | 22 | ||||
| -rw-r--r-- | src/config.c | 34 | ||||
| -rw-r--r-- | tests/test_states.py | 128 |
7 files changed, 212 insertions, 24 deletions
@@ -155,7 +155,7 @@ unreadable. | **D10** | **One attached client** in the MVP. The protocol allows N (read-only observers are what sl0p.foo actually wants); the multi-user *rendering* path is not built, because it is where the fork's phantom-client and `MY FOCUS AND:` bugs live. | | **D11** | **No terminfo/ncurses.** We are opinionated about the outer terminal: emit a known-good modern subset, probe with DA/XTGETTCAP where we must. | | **D12** | **Vendor lib-vt source**, pinned by commit in `vendor/libghostty-vt.vendor.json`, with a patches file if we ever need one. We needed one: `vendor/patches/0001` stops a screen clear from freeing transmitted kitty images, which broke every program that uploads once and places per frame (see D17). Patches are applied to the committed tree and listed in the vendor json, so a build has them and a re-vendor knows what to re-apply. zig is a build-time dependency only, and doubles as our C compiler and static/musl cross-linker. | -| **D13** | **Shaders are a colour pass over pane *contents*, never our chrome.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so "contents, not chrome" is the paint order, not a rule anyone has to remember. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. Fixed rather than configured, because it is a ranking by urgency and not a preference. They do not stack, because two reasons to be grey compound into one muddy grey that reads as neither. `dead` is one of them because a pane whose program exits is now kept until it is dismissed (D14); it was excluded while there was no frame it could ever draw. All of it is **derived every frame, never attached and remembered**: focus and drags move through too many paths to keep an attachment in sync with, and a pane left grey by the one path that forgot to clear it is a bug that only appears in front of someone. Same rule as the rect and the collapsed flag. | +| **D13** | **Shaders are a colour pass over pane *contents*, never our chrome.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so "contents, not chrome" is the paint order, not a rule anyone has to remember. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. **Which states ship a default is a line, not a mood**: a pane that is *not live* gets one — dead, suspended, scrolled, where the cells are showing something other than a running program's present and nothing else would tell you — and a pane that is merely *not the one you are in* does not, because ambient contrast is a taste and a taste shipped as a default is how a tool earns a reputation for fighting you. The corollary is that a collapsed header, which the pass never reaches because a header is chrome, has to carry those same three facts in words. Fixed rather than configured, because it is a ranking by urgency and not a preference. They do not stack, because two reasons to be grey compound into one muddy grey that reads as neither. `dead` is one of them because a pane whose program exits is now kept until it is dismissed (D14); it was excluded while there was no frame it could ever draw. All of it is **derived every frame, never attached and remembered**: focus and drags move through too many paths to keep an attachment in sync with, and a pane left grey by the one path that forgot to clear it is a bug that only appears in front of someone. Same rule as the rect and the collapsed flag. | | **D14** | **A pane outlives its program.** When the program in a pane exits, the pane stays: it keeps everything it printed, writes one line into its own backlog saying how it ended (`[process exited: status 3]`, `[process exited: signal 9]`), says the same thing in its frame and in the status line, and offers the only two verbs still meaningful — `[re-run]` and `[close]`. Re-running spawns the same argv in the same cwd, in the *same pane*: same id, same place in the tree, same terminal, so the run that ended stays above the new one in the scrollback and nothing that referred to that pane has to be told anything. The old behaviour reaped the pane before the next paint, which meant the last thing a failing command printed vanished together with the status that would have explained it, and a mistyped command in a fresh session closed the session. `keep_dead false` restores it. Two consequences are load-bearing rather than incidental: the pty fd is **closed** the moment EOF is seen, because an EOF fd left in the poll set is readable forever and would spin a session at 100% CPU behind a pane that looks idle; and the exit status is collected with a **bounded** wait, never a blocking one, because a program that closes its terminal and keeps running must not be able to hang the session — losing that race costs the words "status 0" and nothing else. | | **D15** | **Shaders may be loaded as native shared libraries**, from a directory beside the config. This is the one hole in D9 and it is argued for rather than drifted into (and D16 has since removed most of the *need* for it). A shader is called per cell, per pass, per frame — tens of thousands of times at up to 120Hz — so the subprocess model D9 prescribes cannot reach that path at all, and the measured alternative, interpreting a bytecode per cell, is 56ns against 8ns for the compiled function. What is loaded is also the narrowest possible thing: a pure function from one cell and its position to that cell's colours, unable to touch text, width, neighbours, panes or the session, because `shader_abi.h` is the only header it sees and nothing else is declared in it. On trust: the config file can already say `shell` and a layout can already say `command=`, so a file in the config directory can already run anything as you — a `.so` in the same directory is not a new boundary. It is refused unless it declares our ABI version *and* matching struct sizes, so a stale plugin fails at load instead of garbling colours. Nothing is ever unloaded: a `shade_fn` may be sitting in a live config or on a pane, so `dlclose()` would be a jump into an unmapped page — a reload therefore picks up newly *added* plugins only, and replacing one needs a new session. | | **D16** | **A shader's `amount` may be an expression**, compiled to bytecode at config load. The scope is the argument: every built-in here is already `colour_op(amount)` with the amount a pure function of position, so the part worth handing to a config is the *number*, not the colour maths. That keeps mixing in C, gives an expression no way to produce an invalid cell (it produces one byte), and makes the result cacheable — a program that does not read the clock is evaluated once per cell into a map and reused until something it reads changes, so a config-written positional shader measures 5.2ns/cell against a compiled vignette's 6.1ns. Reading the clock costs 35ns/cell, per frame, which is the honest price of animation and is opt-in. The language is **total**: no loops, no recursion, no jumps — even `?:` evaluates both sides and selects, because nothing in it has an effect. So evaluation is O(program length), a config cannot spin, and there is nothing to sandbox; division by zero is 0 rather than a trap, because an expression that is wrong should look wrong rather than take the session with it. What a program reads is **derived by the compiler**, not declared, and that dependency set *is* the cache key — a stale map is unrepresentable rather than merely unlikely, which is the same rule the layout follows. An expression that does not compile drops that one shader with a warning: not the config, and not a half-strength version of what was asked for. | diff --git a/HANDOVER.md b/HANDOVER.md index 73b3331..bd04ef0 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -222,10 +222,12 @@ after-the-frame output owes the same debt. - **Tab-level shaders**, and the bigger one: **OSC 5577 → shader**, letting a pane colour itself by reporting its own state. That is the idea most likely to make this feel unlike other multiplexers. -- **Collapsed headers do not reach the shader pass**, so a `suspended`, - `dead` or `scrolled` colour is invisible on one. This is now the most - visible gap: a tab that has flattened into a list is exactly where you would - want a dead pane to stand out, and it is the one place it does not. +- **Collapsed headers do not reach the shader pass** and never will: a header + is chrome, and shaders colour contents (D13). The gap is closed the only + honest way, by having the header *say* what the colour says elsewhere — + `exited: status 3`, `not started`, `▲ 12` — in the same order the status + line ranks them. If you add a not-live state, add it in both places or a + flattened tab will hide it. - **A dead pane cannot be re-run from the finder or the minimised bar** — only from its own frame, `C-a r`, or `{"cmd":"rerun"}`. Fine while a dead pane is something you are looking at; less fine once you have six of them put away. @@ -112,14 +112,18 @@ mistake in it is refused rather than half-applied. Geometry, key bindings and every colour are configurable. Six ready-made themes are in [`contrib/themes`](contrib/themes). -Panes can also be tinted by *state* — dimmed when unfocused, greyed when their -program has exited, tinted when you are reading scrollback: +Panes are tinted by *state*, and out of the box only for states that mean +**this pane is not live**: its program exited, it never started, or you are +looking at scrollback rather than the present. None of those is discoverable +by looking unless something says so. A pane that is merely not the one you are +in is left alone — that is a taste, and it is one line away if it is yours: ```kdl states { - dead { grayscale amount=200; dim amount=90 } - scrolled { tint amount=30 color="#ffcc88" } - unfocused { dim amount=90 } + dead { grayscale amount=200; dim amount=90 } // shipped + suspended { grayscale amount=170; dim amount=60 } + scrolled { tint amount=22 color="#ff5fd7" } + unfocused { dim amount=90 } // yours, if you want it } ``` diff --git a/config/config.kdl b/config/config.kdl index 099c34d..3aa0e38 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -210,16 +210,32 @@ double_click_ms 400 // Naming a state replaces its default outright — including with nothing, which // is how you turn one off: states { drop_target { } } // -// Only drop_hover, drop_target and dead have defaults, shown here. `dead` is -// gentler than the drag greying on purpose: a dead pane is something you are -// still meant to read. +// Which states have a default, and why the line falls where it does: +// +// a pane that is *not live* gets one -- dead, suspended, scrolled. In all +// three the cells are showing something other than a running program's +// present: output from something that exited, a pane that never started, or +// the past. None of that is discoverable by looking unless something says +// so, which is the entire argument for colouring it. +// +// a pane that is merely *not the one you are in* does not -- unfocused, and +// one being dragged. That is ambient contrast, which is a taste, and a taste +// shipped as a default is how a tool gets a reputation for fighting you. +// `unfocused { dim amount=90 }` is one line away if you disagree. +// +// All the defaults are gentle: a dead pane, a suspended one and scrollback are +// all things you still want to read. The compiled-in scrolled tint uses the +// scrollback indicator's own colour (theme's scroll_bg) so a theme moves both +// together; written here it is spelled out, because a `color=` you do not give +// falls back to theme's frame_focus, which is a different setting that happens +// to hold the same value in every theme we ship. states { drop_hover { grayscale amount=200; dim amount=140 } drop_target { grayscale amount=200; dim amount=140 } dead { grayscale amount=200; dim amount=90 } + suspended { grayscale amount=170; dim amount=60 } + scrolled { tint amount=22 color="#ff5fd7" } // = theme's scroll_bg // dragging { } - // suspended { grayscale amount=180; dim amount=90 } - // scrolled { tint amount=30 color="#ffcc88" } // unfocused { dim amount=90 } } @@ -2332,16 +2332,28 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) { const char *title = pane_title(leaf->pane); const char *status = pane_status(leaf->pane); - /* A dead pane says so here too, in place of whatever its program last asked - * us to show — which is stale by definition. This row is the only thing a - * flattened tab draws of a pane, and "it is over" is the one fact you - * cannot afford to have to open it to discover. The shader pass does not - * reach a header, so the words have to carry it. */ + /* A pane that is not live says so here, in place of whatever its program + * last asked us to show — which is stale by definition once the program is + * gone or was never started. + * + * This row is the only thing a flattened tab draws of a pane, and the + * shader pass deliberately never reaches it: shaders colour *contents*, and + * a header is chrome (D13). So the states that get a colour everywhere else + * have to be carried by the words here, in the same order the status line + * ranks them. Without this, collapsing a tab hides exactly the facts the + * colour exists to show. */ bool dead = !pane_alive(leaf->pane) && !pane_suspended(leaf->pane); char words[64]; if (dead) { exit_words(leaf->pane, words, sizeof words); status = words; + } else if (pane_suspended(leaf->pane)) { + status = "not started"; + } else if (pane_scrolled(leaf->pane)) { + uint32_t above = 0, total = 0; + pane_scroll_pos(leaf->pane, &above, &total); + snprintf(words, sizeof words, "\u25b2 %u", total > above ? total - above : 0); + status = words; } char line[256]; /* A space on each side, always. A title welded to the rule beside it reads diff --git a/src/config.c b/src/config.c index e06e25a..9ebb202 100644 --- a/src/config.c +++ b/src/config.c @@ -245,15 +245,33 @@ void config_defaults(config_t *c) { shader_make(&c->state_shaders[PSTATE_DROP_HOVER][1], "dim", (color_t){0}, 140); c->state_n[PSTATE_DROP_HOVER] = 2; - /* The other state with an opinion, and for the same reason the drag states - * have one: what is on those cells is no longer live, and colour is the - * only thing that says so without reading a word. Gentler than the drag - * greying, because a dead pane is something you still want to read. */ + /* The states with an opinion, and the line between those and the ones + * without it: + * + * a pane that is *not live* gets one — dead, suspended, scrolled. In all + * three the cells are showing something other than a running program's + * present: output from something that has exited, a pane that never + * started, or the past. You cannot discover any of that by looking unless + * something says so, and that is the whole argument for the feature; + * + * a pane that is merely *not the one you are in* does not — unfocused, + * dragging. That is ambient contrast, which is a taste, and shipping a + * taste as a default is how a tool gets a reputation for fighting you. + * + * All three are gentle on purpose. A dead pane, a suspended one and + * scrollback are all things you still want to read. */ shader_make(&c->state_shaders[PSTATE_DEAD][0], "grayscale", (color_t){0}, 200); shader_make(&c->state_shaders[PSTATE_DEAD][1], "dim", (color_t){0}, 90); c->state_n[PSTATE_DEAD] = 2; + /* Laid out and never started: inert, and still legible — a suspended pane's + * contents are the command it is waiting to run. */ + shader_make(&c->state_shaders[PSTATE_SUSPENDED][0], "grayscale", (color_t){0}, + 170); + shader_make(&c->state_shaders[PSTATE_SUSPENDED][1], "dim", (color_t){0}, 60); + c->state_n[PSTATE_SUSPENDED] = 2; + const color_t accent = rgb(0xff, 0x5f, 0xd7); const color_t ink = rgb(0x14, 0x14, 0x18); const color_t dim = rgb(0x45, 0x45, 0x4a); @@ -275,6 +293,14 @@ void config_defaults(config_t *c) { c->scroll_fg = ink; c->scroll_bg = accent; + /* Looking at the past, said in colour as well as in the ▲ count: a wash of + * the same accent the scroll indicator uses, so a theme moves both together + * rather than leaving a hardcoded hue nobody can reach. Weak (about 9%) — + * it has to survive being read through. Attached here rather than beside + * the other states because it is the one that needs the palette. */ + shader_make(&c->state_shaders[PSTATE_SCROLLED][0], "tint", c->scroll_bg, 22); + c->state_n[PSTATE_SCROLLED] = 1; + c->header = dim; c->header_hover = accent; c->header_hover_title = bright; diff --git a/tests/test_states.py b/tests/test_states.py new file mode 100644 index 0000000..7bcd6e7 --- /dev/null +++ b/tests/test_states.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""What sl0ppty says, out of the box, about a pane that is not live. + +Three states mean "these cells are not a running program's present": its +program exited, it never started, or you are looking at the past. None of them +is discoverable by looking unless something says so, which is the whole +argument for shading them, so all three are shaded by default. + +Two states mean "this is not the pane you are in" — unfocused, and one being +dragged. Those are ambient contrast, which is a taste, and a taste shipped as +a default is how a tool gets a reputation for fighting you. This file asserts +that line in both directions: the first three are coloured, the fourth is not. +""" +import os +import sys +import tempfile + +from harness import Session, check, report + +# A pane with a screenful of known-coloured text. +SH = ["/bin/sh", "-c", + 'printf "\\033]2;p\\007"; i=0; ' + 'while [ $i -lt 40 ]; do printf "line %s ABCDEFGHIJKLMNOP\\n" $i; i=$((i+1)); done; ' + 'read x'] + +# Only the theme, so "what colour is this cell" has one answer. No states: the +# point is what sl0ppty does when you have said nothing. +CFG = None + + +def cfg(): + global CFG + if CFG is None: + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write('theme { default_fg "#ffffff" default_bg "#000000" }\n') + f.close() + CFG = f.name + return CFG + + +def fg(snap, pane, row=0, col=0): + run = snap.style_at(pane["content_x"] + col, pane["content_y"] + row) + return (run or {}).get("fg") + + +def test_a_dead_pane_is_coloured_by_default(): + with Session(["/bin/sh", "-c", 'printf "\\033]2;p\\007"; echo DONE; exit 3'], + cols=50, rows=10, config=cfg()) as s: + snap = s.until_text("[process exited") + check("its contents are not left looking live", + fg(snap, s.pane()) not in (None, "#ffffff"), str(fg(snap, s.pane()))) + + +def test_a_suspended_pane_is_coloured_by_default(): + layout = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + layout.write('layout {\n tab name="t" {\n' + ' pane command="echo hello; read x" suspended=true\n' + ' }\n}\n') + layout.close() + with Session(SH, cols=60, rows=12, config=cfg(), layout=layout.name) as s: + s.settle(60) + pane = s.pane() + snap = s.snapshot() + check("a pane that never started does not look like one that did", + fg(snap, pane) not in (None, "#ffffff"), + f"{fg(snap, pane)} — {snap.pane_text(pane)[:40]!r}") + os.unlink(layout.name) + + +def test_scrollback_is_coloured_by_default(): + with Session(SH, cols=60, rows=12, config=cfg()) as s: + s.until_text("line 39") + pane = s.pane() + live = fg(s.snapshot(), pane) + + s.send(r"\x01\x1b[5~") # C-a PgUp: into the scrollback + s.settle(20) + past = fg(s.snapshot(), pane) + check("the pane is scrolled", s.panes() and "▲" in s.snapshot().screen(), + s.snapshot().screen()[:200]) + check("looking at the past looks different from the present", + past != live, f"live {live}, scrolled {past}") + + s.send(r"\x01\x1b[F") # C-a End: back to the bottom + s.settle(20) + check("and it goes back when you do", fg(s.snapshot(), pane) == live, + f"{fg(s.snapshot(), pane)} vs {live}") + + +def test_an_unfocused_pane_is_left_alone(): + """The deliberate non-opinion. If this ever fails because someone shipped a + default dim, that was a taste, and it needs an argument rather than a + commit.""" + with Session(SH, cols=90, rows=14, config=cfg()) as s: + s.until_text("line 39") + before = fg(s.snapshot(), s.pane()) + s.key("\\\\") # split; the left pane loses focus + s.settle(40) + unfocused = [p for p in s.panes() if not p["focused"]][0] + check("an unfocused pane keeps its colours", + fg(s.snapshot(), unfocused) == before, + f"{fg(s.snapshot(), unfocused)} vs {before}") + + +def test_a_flattened_tab_still_says_which_panes_are_not_live(): + """A collapsed header is chrome, and the shader pass never touches chrome + (D13) — so the words have to carry what the colour carries everywhere + else. Collapsing a tab must not hide the fact.""" + layout = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + layout.write('layout {\n tab name="t" {\n' + ' pane command="stty raw -echo; cat"\n' + ' pane command="echo hi; read x" suspended=true\n' + ' }\n}\n') + layout.close() + with Session(SH, cols=100, rows=14, config=cfg(), layout=layout.name) as s: + s.settle(60) + s.resize(40, 12) # too small for two: the tab flattens + s.settle(40) + screen = s.snapshot().screen() + check("the header says the pane never started", + "not started" in screen, screen) + os.unlink(layout.name) + + +for name, fn in sorted(list(globals().items())): + if name.startswith("test_"): + fn() +sys.exit(report()) |
