diff options
| author | user <user@clank> | 2026-08-14 13:52:42 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 13:52:42 +0200 |
| commit | c7c8111bb3d20acc926fa28db6bccc6752a5e87e (patch) | |
| tree | 450cdfc188c1128dd53eb5203a7efe15b89345ba | |
| parent | sl0ppty: terminal shaders — a colour pass over pane contents (diff) | |
| download | slosh-c7c8111bb3d20acc926fa28db6bccc6752a5e87e.tar.gz slosh-c7c8111bb3d20acc926fa28db6bccc6752a5e87e.tar.xz slosh-c7c8111bb3d20acc926fa28db6bccc6752a5e87e.zip | |
sl0ppty: drag greys the other panes, and dim_unfocused
The first two consumers of the shader pass, and between them they settle how a
pane gets one.
Dragging a pane by its title now greys every *other* pane, so the one that is
moving lifts off the page — which is the thing that made a colour pass worth
building. It engages only once the pointer has actually moved, because a press
on a title that turns out to be a click would otherwise flash the whole
session grey on its way to doing nothing. dim_unfocused fades everything but
the focused pane and is off by default: an always-on effect is the kind you
stop seeing and start fighting, so it should be asked for.
Both are derived every frame rather than attached and cleared. Focus and drags
move through far too many paths — hover, click, the finder, a close, a layout
apply, any keystroke ending a drag — to keep an attachment in sync with, and a
pane left grey by the one path that forgot is a bug that only turns up in
front of someone. The rect and the collapsed flag are recomputed every pass
for exactly this reason; this is the same rule applied to colour. It also
makes the awkward cases free: dropping into the margin, a keystroke ending the
drag, or the dragged pane being closed under it all un-grey without anywhere
having to say so, and there are tests for each because "it turns off again" is
the whole risk.
The two policies replace rather than stack. Dragging an unfocused pane while
dim_unfocused is on would otherwise give it two reasons to be grey, and they
compound into one muddy grey that reads as neither; the drag wins, so the
dragged pane is at full colour whatever the focus says.
shade_apply no longer caps at SHADE_MAX. That cap was right when the only
shaders were the ones a pane had attached, and wrong the moment the session
derives one on top of them: a full stack would have silently dropped it. The
count is the caller's to bound.
The chrome check that mattered was already there: test_drop_target_is_visible
samples a frame corner mid-drag, and it still passes, because the pass runs
between the contents and the chrome drawn over them.
| -rw-r--r-- | DESIGN.md | 2 | ||||
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 15 | ||||
| -rw-r--r-- | config/config.kdl | 18 | ||||
| -rw-r--r-- | src/app.c | 44 | ||||
| -rw-r--r-- | src/config.c | 6 | ||||
| -rw-r--r-- | src/config.h | 4 | ||||
| -rw-r--r-- | src/shader.c | 5 | ||||
| -rw-r--r-- | tests/test_shaders.py | 286 |
9 files changed, 372 insertions, 10 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. 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. | +| **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 — dimmed because it is not focused, greyed because something else is being dragged — 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. | ### D6 — responsive layout as a pure function @@ -65,7 +65,7 @@ test: $(BIN) $(TEST_BIN) $(KDL_TEST) $(SHADER_TEST) ## unit + headless checks (f @echo cd tests && python3 test_screen.py && python3 test_layout.py && python3 test_tabs.py && python3 test_osc5577.py && \ python3 test_responsive.py && python3 test_finder.py && \ - python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py && python3 test_clipboard.py && python3 test_split_ux.py && python3 test_graphics.py && python3 test_pane_title.py + python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py && python3 test_clipboard.py && python3 test_split_ux.py && python3 test_graphics.py && python3 test_pane_title.py && python3 test_shaders.py test-live: $(BIN) ## the checks that need a real tty (slow) python3 tests/live_m0.py @@ -58,8 +58,9 @@ it and shows a dashed line where the split would land. Click a pane to focus it, a tab to switch, `+tab` to open one, a collapsed header to expand it, and a pane's own buttons to talk to the program inside it. **Drag a pane's title bar onto another pane to swap them, or drag the gap between two panes to move the -boundary.** Focus follows the pointer (`focus_follows_mouse`, on by default, -and it knows when not to). +boundary.** While you drag, every *other* pane greys out so the one you are +moving lifts off the page. Focus follows the pointer (`focus_follows_mouse`, +on by default, and it knows when not to). **Double-click a pane's name to rename it**, in the title itself rather than in a dialog. Enter keeps the new name, Escape abandons it, clicking away keeps it. @@ -75,6 +76,16 @@ copy the same way (OSC 52) and can raise a notification (OSC 9), which appears as a toast in the corner — as do `{"cmd":"notify","text":"..."}` and things like "copied 13 chars". +**Shaders** are colour passes over a pane's *contents* — never the frame, the +title or the tab strip, so chrome stays legible over a pane that has been +dimmed underneath it. `dim_unfocused` (off by default) fades every pane but +the focused one; `drag_grayscale` is the greying you get while dragging. Both +are strengths from 0 to 255. Because a cell's colour is often "whatever the +terminal calls default", shaders resolve those through `theme`'s `default_fg` +and `default_bg` — set them to your terminal's real colours, or a shaded pane +will shift as it turns on. Panes with no shader defer to your terminal exactly +as before. + Configuration is [`config/config.kdl`](config/config.kdl), which documents every setting by being the defaults. Copy it to `~/.config/sl0ppty/config.kdl`. diff --git a/config/config.kdl b/config/config.kdl index 6442255..f114719 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -52,6 +52,24 @@ hover_delay_ms 250 // program calls itself. double_click_ms 400 +// ── shaders ───────────────────────────────────────────────────────────────── +// +// Colour passes over a pane's contents (never the frame, title or tab strip). +// Both are strengths from 0 to 255, where 0 is off and 255 is fully applied. +// Both resolve default-coloured text through theme's default_fg/default_bg, +// so set those to your terminal's real colours or a shaded pane will shift. + +// Dim every pane except the focused one, all the time. Off by default: it is +// a taste, and an always-on effect is the kind you stop seeing and start +// fighting. 40 is a hint, 120 is obvious. +dim_unfocused 0 + +// While a pane is being dragged by its title, grey every *other* pane, so the +// one that is moving lifts off the page. Transient — it appears once the drag +// actually moves and is gone on release — so unlike the above it costs +// nothing when you are not doing it. +drag_grayscale 200 + // How long an announcement ("copied 13 chars", a pane's OSC 9 notification, // "config reloaded") stays in the bottom-right corner. toast_ms 2500 @@ -1448,18 +1448,52 @@ size_t app_shade_count(app_t *a, uint32_t pane_id) { return n ? n->nshaders : 0; } -/* A pane with no shaders costs nothing: no context is built and no cell is - * visited, so an unshaded session emits the same bytes it did before. */ +/* The session's opinion about this pane at this moment, as a shader. + * + * Derived every frame rather than attached and remembered. Focus and drags + * move through too many paths — hover, click, the finder, a close, a layout — + * 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 shows up in front of someone. + * The rect and the collapsed flag are recomputed every pass for exactly this + * reason; this is the same rule applied to colour. */ +static size_t policy_shader(app_t *a, node_t *n, shader_t *out) { + /* Only once the pointer has actually moved: a press that turns out to be a + * click would otherwise flash the whole session grey on its way to nothing. */ + if (a->drag.kind == DRAG_TITLE && a->drag.moved) { + /* The pane being dragged keeps its colour and everything else goes grey, + * so the one that is moving lifts off the page. This replaces the focus + * dimming rather than stacking with it: two reasons to be grey compound + * into one muddy grey that reads as neither. */ + if (n->id == a->drag.src || !CFG.drag_grayscale) return 0; + return shader_make(out, "grayscale", (color_t){0}, CFG.drag_grayscale) ? 1 + : 0; + } + if (CFG.dim_unfocused && n != cur(a)->focus) + return shader_make(out, "dim", (color_t){0}, CFG.dim_unfocused) ? 1 : 0; + return 0; +} + +/* A pane with nothing to apply costs nothing: no context is built and no cell + * is visited, so an unshaded session emits the same bytes it did before. */ static void shade_leaf(app_t *a, screen_t *s, node_t *n) { - if (!n->nshaders) return; + shader_t chain[SHADE_MAX + 1]; + size_t nc = 0; + + /* Attached first, policy last: a pane's own colour is the thing the + * session's opinion of the moment is then applied to. */ + for (size_t i = 0; i < n->nshaders && nc < SHADE_MAX; i++) + chain[nc++] = n->shaders[i]; + nc += policy_shader(a, n, &chain[nc]); + + if (!nc) return; shade_ctx_t base = { .now_ms = now_ms_(), .focused = n == cur(a)->focus, .default_fg = CFG.default_fg, .default_bg = CFG.default_bg, }; - shade_apply(s, n->shaders, n->nshaders, n->content.x, n->content.y, - n->content.w, n->content.h, &base); + shade_apply(s, chain, nc, n->content.x, n->content.y, n->content.w, + n->content.h, &base); } static void draw_node(app_t *a, screen_t *s, node_t *n); diff --git a/src/config.c b/src/config.c index c41a6f7..40a1cc9 100644 --- a/src/config.c +++ b/src/config.c @@ -129,6 +129,8 @@ void config_defaults(config_t *c) { c->toast_ms = 2500; c->hover_delay_ms = 250; c->double_click_ms = 400; + c->dim_unfocused = 0; + c->drag_grayscale = 200; c->status_bar = true; c->focus_follows_mouse = true; @@ -254,6 +256,10 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { c->hover_delay_ms); c->double_click_ms = (uint16_t)kdl_arg_int(kdl_child(root, "double_click_ms"), 0, c->double_click_ms); + c->dim_unfocused = (uint8_t)kdl_arg_int(kdl_child(root, "dim_unfocused"), 0, + c->dim_unfocused); + c->drag_grayscale = (uint8_t)kdl_arg_int(kdl_child(root, "drag_grayscale"), 0, + c->drag_grayscale); const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL); if (sh) { diff --git a/src/config.h b/src/config.h index c006ca0..17c3645 100644 --- a/src/config.h +++ b/src/config.h @@ -61,6 +61,10 @@ typedef struct { uint16_t toast_ms; uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */ uint16_t double_click_ms; /* how close two clicks must be to be a double */ + /* Shader strengths, 0..255, 0 being off. Ambient dimming is a taste and is + * off by default; the drag greying is transient and on. */ + uint8_t dim_unfocused; + uint8_t drag_grayscale; bool status_bar; bool focus_follows_mouse; diff --git a/src/shader.c b/src/shader.c index ae47929..ec49a07 100644 --- a/src/shader.c +++ b/src/shader.c @@ -78,7 +78,10 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0, ctx.cols = w; ctx.rows = h; - for (size_t i = 0; i < n && i < SHADE_MAX; i++) { + /* `n` is the caller's to bound: SHADE_MAX is how many a pane may have + * *attached*, and a caller may legitimately pass that many plus the one the + * session derived for this frame. Capping here would silently drop it. */ + for (size_t i = 0; i < n; i++) { const shader_t *sh = &shaders[i]; if (!sh->kind || !sh->fn) continue; diff --git a/tests/test_shaders.py b/tests/test_shaders.py new file mode 100644 index 0000000..be86d35 --- /dev/null +++ b/tests/test_shaders.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""The two things that drive shaders: focus, and a drag in progress. + +Both are derived from the current frame rather than attached and remembered, +so most of what is worth checking is that they turn *off* again — a pane left +grey by a path that forgot to clear it is the failure mode this design exists +to make impossible. + +Colours are asserted through the style runs the snapshot already reports, so +these are real end-to-end checks: a program's output, composed, shaded, and +read back the way a client would see it. +""" +import sys +import tempfile + +from harness import Session, check, report + +# A pane that paints a known, non-default colour into its own content. +GREEN = "#00ff00" +SH = ["/bin/sh", "-c", + 'printf "\\033]2;p\\007"; printf "\\033[38;2;0;255;0mBLOCK\\033[0m\\n"; ' + 'stty raw -echo; cat'] + + +def cfg(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +def content_fg(snap, pane): + """The colour of the pane's own first content cell.""" + run = snap.style_at(pane["content_x"], pane["content_y"]) + return (run or {}).get("fg") + + +def frame_fg(snap, pane): + run = snap.style_at(pane["x"], pane["y"]) + return (run or {}).get("fg") + + +def split(s): + s.key("\\\\") + s.settle() + return s.panes() + + +def by_focus(s): + panes = s.panes() + focused = [p for p in panes if p["focused"]][0] + other = [p for p in panes if not p["focused"]][0] + return focused, other + + +def press(s, x, y): + s.send(rf"\e[<0;{x + 1};{y + 1}M") + + +def motion(s, x, y): + s.send(rf"\e[<32;{x + 1};{y + 1}M") + + +def release(s, x, y): + s.send(rf"\e[<0;{x + 1};{y + 1}m") + + +# ---- dim_unfocused --------------------------------------------------------- + +def test_off_by_default(): + with Session(SH, cols=80, rows=14) as s: + s.settle(200) + split(s) + s.settle(200) + focused, other = by_focus(s) + snap = s.snapshot() + check("by default an unfocused pane is not dimmed", + content_fg(snap, other) == GREEN, str(content_fg(snap, other))) + check("and the focused one is not either", + content_fg(snap, focused) == GREEN, + str(content_fg(snap, focused))) + + +def test_dims_everything_but_the_focused_pane(): + with Session(SH, cols=80, rows=14, config=cfg("dim_unfocused 128\n")) as s: + s.settle(200) + split(s) + s.settle(200) + focused, other = by_focus(s) + snap = s.snapshot() + check("the focused pane keeps its colour", + content_fg(snap, focused) == GREEN, + str(content_fg(snap, focused))) + check("an unfocused pane is dimmed", + content_fg(snap, other) not in (GREEN, None), + str(content_fg(snap, other))) + check("dimmed means darker, not merely different", + int(content_fg(snap, other)[3:5], 16) < 0xff, + str(content_fg(snap, other))) + + +def test_the_dimming_follows_focus(): + with Session(SH, cols=80, rows=14, config=cfg("dim_unfocused 128\n")) as s: + s.settle(200) + split(s) + s.settle(200) + first_focused, first_other = by_focus(s) + + s.api("focus", id=first_other["id"]) + s.settle(200) + snap = s.snapshot() + now_focused = [p for p in s.panes() if p["id"] == first_other["id"]][0] + now_other = [p for p in s.panes() if p["id"] == first_focused["id"]][0] + check("the newly focused pane is undimmed", + content_fg(snap, now_focused) == GREEN, + str(content_fg(snap, now_focused))) + check("and the one that lost focus is dimmed now", + content_fg(snap, now_other) not in (GREEN, None), + str(content_fg(snap, now_other))) + + +def test_dimming_never_touches_the_chrome(): + with Session(SH, cols=80, rows=14, config=cfg("dim_unfocused 200\n")) as s: + s.settle(200) + split(s) + s.settle(200) + _, other = by_focus(s) + snap = s.snapshot() + check("a dimmed pane's frame keeps its full colour", + frame_fg(snap, other) == "#45454a", str(frame_fg(snap, other))) + check("and the tab strip is untouched", + "#" in str(snap.style_at(3, 1)), str(snap.style_at(3, 1))) + + +# ---- drag_grayscale -------------------------------------------------------- + +def test_dragging_greys_the_other_panes(): + with Session(SH, cols=90, rows=16) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + press(s, left["x"] + 4, left["y"]) + motion(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + snap = s.snapshot() + check("the pane being dragged keeps its colour", + content_fg(snap, left) == GREEN, str(content_fg(snap, left))) + check("every other pane goes grey", + content_fg(snap, right) not in (GREEN, None), + str(content_fg(snap, right))) + # The default strength is 200, not 255: a partial desaturation that + # keeps a trace of the original hue. So the test is that the channels + # have converged, not that they are equal. + grey = content_fg(snap, right) + ch = [int(grey[i:i + 2], 16) for i in (1, 3, 5)] + check("the colour has collapsed towards grey", + max(ch) - min(ch) < 80, f"{grey} spread={max(ch) - min(ch)}") + check("the two channels that started equal stay equal", + ch[0] == ch[2], str(grey)) + + release(s, right["x"] + 5, right["y"] + 3) + s.settle(200) + snap = s.snapshot() + for p in s.panes(): + check(f"pane {p['id']} is back to its own colour after the drop", + content_fg(snap, p) == GREEN, str(content_fg(snap, p))) + + +def test_full_strength_is_a_true_grey(): + with Session(SH, cols=90, rows=16, + config=cfg("drag_grayscale 255\n")) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + press(s, left["x"] + 4, left["y"]) + motion(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + grey = content_fg(s.snapshot(), right) + check("at 255 every channel agrees exactly", + grey and grey[1:3] == grey[3:5] == grey[5:7], str(grey)) + release(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + + +def test_a_press_that_never_moves_greys_nothing(): + """A click on a title is a click. It must not flash the session grey.""" + with Session(SH, cols=90, rows=16) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + press(s, left["x"] + 4, left["y"]) + s.settle(120) + snap = s.snapshot() + check("pressing without moving greys nothing", + content_fg(snap, right) == GREEN, str(content_fg(snap, right))) + release(s, left["x"] + 4, left["y"]) + s.settle(120) + + +def test_the_drag_greying_outranks_the_focus_dimming(): + """Two reasons to be grey would compound into one muddy grey.""" + with Session(SH, cols=90, rows=16, config=cfg("dim_unfocused 128\n")) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + # Drag the *unfocused* pane, so focus policy would dim it if it applied. + focused, other = by_focus(s) + press(s, other["x"] + 4, other["y"]) + motion(s, focused["x"] + 5, focused["y"] + 3) + s.settle(120) + snap = s.snapshot() + check("the dragged pane lifts off the page, dimming or not", + content_fg(snap, other) == GREEN, str(content_fg(snap, other))) + release(s, focused["x"] + 5, focused["y"] + 3) + s.settle(200) + + +def test_drag_greying_can_be_turned_off(): + with Session(SH, cols=90, rows=16, config=cfg("drag_grayscale 0\n")) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + press(s, left["x"] + 4, left["y"]) + motion(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + snap = s.snapshot() + check("drag_grayscale 0 leaves every pane alone", + content_fg(snap, right) == GREEN, str(content_fg(snap, right))) + release(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + + +def test_a_drag_that_ends_off_a_pane_still_clears(): + """The clear is derived, so even a drop into nowhere cannot leave it on.""" + with Session(SH, cols=90, rows=16) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + press(s, left["x"] + 4, left["y"]) + motion(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + motion(s, 0, 0) # out into the margin + release(s, 0, 0) + s.settle(200) + snap = s.snapshot() + for p in s.panes(): + check(f"pane {p['id']} is un-greyed after a drop into nowhere", + content_fg(snap, p) == GREEN, str(content_fg(snap, p))) + + +def test_a_keystroke_mid_drag_clears_the_greying(): + """Any key ends a drag (so the mouse cannot wedge); colour must follow.""" + with Session(SH, cols=90, rows=16) as s: + s.settle(200) + left, right = split(s) + s.settle(200) + + press(s, left["x"] + 4, left["y"]) + motion(s, right["x"] + 5, right["y"] + 3) + s.settle(120) + s.send("x") + s.settle(200) + snap = s.snapshot() + check("the greying goes with the drag it belonged to", + content_fg(snap, right) == GREEN, str(content_fg(snap, right))) + + +if __name__ == "__main__": + test_off_by_default() + test_dims_everything_but_the_focused_pane() + test_the_dimming_follows_focus() + test_dimming_never_touches_the_chrome() + test_dragging_greys_the_other_panes() + test_full_strength_is_a_true_grey() + test_a_press_that_never_moves_greys_nothing() + test_the_drag_greying_outranks_the_focus_dimming() + test_drag_greying_can_be_turned_off() + test_a_drag_that_ends_off_a_pane_still_clears() + test_a_keystroke_mid_drag_clears_the_greying() + sys.exit(report()) |
