aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 14:25:13 +0200
committeruser <user@clank>2026-08-14 14:25:13 +0200
commit858234c05031a7bffc3ffe2a9b2aa4530d3ff0d7 (patch)
tree951d60c94949dc43c468252142a93006663b9f76
parentsl0ppty: positional shaders, and a config block to ask for them (diff)
downloadslosh-858234c05031a7bffc3ffe2a9b2aa4530d3ff0d7.tar.gz
slosh-858234c05031a7bffc3ffe2a9b2aa4530d3ff0d7.tar.xz
slosh-858234c05031a7bffc3ffe2a9b2aa4530d3ff0d7.zip
sl0ppty: pane states are a table, not three hardcoded knobs
dim_unfocused, drag_grayscale and drag_dim were three knobs describing two situations, and there was no way to say anything about the third, fourth or fifth. They are replaced by a table: a state names a chain, and the chain is exactly as expressive as the global `shaders` block because it is the same parser. states { dragging { } drop_target { grayscale amount=200; dim amount=140 } suspended { grayscale amount=180; dim amount=90 } scrolled { tint amount=30 color="#ffcc88" } unfocused { dim amount=90 } } A pane is usually in several states 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 read from the block, because it is a ranking by urgency rather than a preference — a pane you are holding should not be recoloured by anything, and a mode the whole screen is in outranks an ambient hint about one pane. They do not stack, which is the same call the drag policy already had to make by hand: two reasons to be grey compound into a muddy grey that reads as neither. Defaults are the old drag behaviour and nothing else, so out of the box this changes nothing. drop_hover is now separable from drop_target. The pane under the pointer was already distinguished by its border; it can now differ in colour too, and defaults to the same chain so it does not have to. Two states asked for did not survive contact with the code, and it is worth writing down why: `dead` cannot exist. app_reap() runs every iteration of the server loop, before the paint, and closing the last pane closes the tab — so a pane whose program exits is gone before a frame containing it is ever composed. A `dead` shader would name something that could never draw. Making it real means keeping dead panes until they are dismissed, which is a feature about lifetimes and not about colour. `suspended` could not be seen either, for a much smaller reason: draw_cb drew its "press a key to run" label and returned before reaching the shader pass. It falls through now. A pane that has not started is a state worth colouring, and its label is the only contents it has.
-rw-r--r--DESIGN.md2
-rw-r--r--README.md21
-rw-r--r--config/config.kdl55
-rw-r--r--src/app.c55
-rw-r--r--src/config.c119
-rw-r--r--src/config.h35
-rw-r--r--tests/test_shaders.py163
7 files changed, 349 insertions, 101 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 637fd3c..78e2f5a 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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. 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. |
+| **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, 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. There is no `dead` state: a pane whose program exits is reaped before the next paint, so it could never 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. |
### D6 — responsive layout as a pure function
diff --git a/README.md b/README.md
index 2cb86e8..983b7ef 100644
--- a/README.md
+++ b/README.md
@@ -80,12 +80,23 @@ 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` and `drag_dim` are the greying and the
-recession you get while dragging. All are strengths from 0 to 255.
+dimmed underneath it. A `states { }` block says what a pane looks like while
+it is in a state — being dragged, somewhere a drag could land, suspended,
+scrolled back, or simply not focused. A pane is usually in several at once, so
+exactly one wins, the most deliberate rather than the most ambient:
-Beyond those the session runs itself, a `shaders { }` block is a chain you
-choose, in the order you write it:
+```kdl
+states {
+ dragging { } // untouched, in your hand
+ drop_target { grayscale amount=200; dim amount=140 }
+ suspended { grayscale amount=180; dim amount=90 }
+ scrolled { tint amount=30 color="#ffcc88" } // you are in the past
+ unfocused { dim amount=90 }
+}
+```
+
+A `shaders { }` block is the same thing every pane gets regardless, in the
+order you write it:
```kdl
shaders {
diff --git a/config/config.kdl b/config/config.kdl
index c4930bc..7e68c9b 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -59,24 +59,45 @@ double_click_ms 400
// 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, push every *other* pane back so
-// the one that is moving lifts off the page: drag_grayscale drains its colour,
-// drag_dim drops its brightness. Two knobs because "colourless" and "recessed"
-// are different things to want, and grey alone still competes for attention at
-// the same brightness. Transient — they appear once the drag actually moves and
-// are gone on release — so unlike dim_unfocused they cost nothing when you are
-// not dragging. Set both to 0 to turn the effect off.
+// What a pane looks like while it is in a particular state. Each state takes
+// the same chain of passes `shaders` does.
+//
+// A pane is usually in several states at once — unfocused *and* scrolled,
+// suspended *and* something you could drop onto — so exactly one wins: the
+// first that matches, tested in this order.
+//
+// dragging the pane you have hold of
+// drop_hover the one under the pointer, where it would land
+// drop_target the others, all of them somewhere it could go
+// suspended laid out, never started (its label is its contents)
+// scrolled looking at scrollback rather than at the present
+// unfocused the weakest thing that can be true of a pane
+//
+// The order is fixed rather than read from this block, because it is a ranking
+// by urgency and not a preference: a pane you are holding should not be
+// recoloured by anything, and a mode the whole screen is in outranks an
+// ambient hint about one pane. They do not stack, for the same reason two
+// reasons to be grey would compound into one muddy grey that reads as neither.
//
-// The dragged pane's border also stays solid while every other pane's turns
-// dashed, marking them as places it could be dropped. That is chrome, not a
-// shader, so it happens whatever these are set to.
-drag_grayscale 200
-drag_dim 140
+// Naming a state replaces its default outright — including with nothing, which
+// is how you turn one off: states { drop_target { } }
+//
+// There is deliberately no `dead`: a pane whose program exits is reaped before
+// the next frame is painted, so a shader for it could never draw.
+//
+// Only drop_hover and drop_target have defaults, shown here.
+states {
+ drop_hover { grayscale amount=200; dim amount=140 }
+ drop_target { grayscale amount=200; dim amount=140 }
+ // dragging { }
+ // suspended { grayscale amount=180; dim amount=90 }
+ // scrolled { tint amount=30 color="#ffcc88" }
+ // unfocused { dim amount=90 }
+}
+
+// While a pane is being dragged its border stays solid and every other pane's
+// turns dashed, marking them as places it could be dropped. That is chrome,
+// not a shader, so it happens whatever the states above are set to.
// Colour passes every pane gets, in the order written — a chain is a sequence,
// so `grayscale` then `tint` is not `tint` then `grayscale`. Each node is one
diff --git a/src/app.c b/src/app.c
index 4a3c348..9996f0e 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1468,31 +1468,34 @@ size_t app_shade_count(app_t *a, uint32_t pane_id) {
* 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_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) {
- size_t k = 0;
+/* Which state this pane is in, or PSTATE_COUNT for none.
+ *
+ * A pane is usually in several at once, so the order here is the answer: the
+ * first that matches wins and the rest are not asked. It runs from the most
+ * transient and deliberate to the most ambient, because a pane you are holding
+ * should not be recoloured by anything, a mode the whole screen is in outranks
+ * a hint about one pane, and "not focused" is the weakest thing that can be
+ * true of a pane. */
+static pane_state_t pane_state(app_t *a, node_t *n) {
/* 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. */
+ * click would otherwise flash the whole session on its way to nothing. */
if (a->drag.kind == DRAG_TITLE && a->drag.moved) {
- /* The pane being dragged is left alone and everything else is pushed back
- * — desaturated *and* darkened, because grey alone still competes for
- * attention at the same brightness. Two passes rather than one shader
- * that does both: they are independently tunable, and "colourless" and
- * "recessed" are different things to want.
- *
- * This replaces the focus dimming rather than stacking with it: two
- * reasons to be grey compound into a muddy grey that reads as neither. */
- if (n->id == a->drag.src) return 0;
- if (CFG.drag_grayscale && k < cap &&
- shader_make(&out[k], "grayscale", (color_t){0}, CFG.drag_grayscale))
- k++;
- if (CFG.drag_dim && k < cap &&
- shader_make(&out[k], "dim", (color_t){0}, CFG.drag_dim))
- k++;
- return k;
+ if (n->id == a->drag.src) return PSTATE_DRAGGING;
+ if (n->id == a->drag.target) return PSTATE_DROP_HOVER;
+ return PSTATE_DROP_TARGET;
}
- if (CFG.dim_unfocused && n != cur(a)->focus && k < cap &&
- shader_make(&out[k], "dim", (color_t){0}, CFG.dim_unfocused))
- k++;
+ if (pane_suspended(n->pane)) return PSTATE_SUSPENDED;
+ if (pane_scrolled(n->pane)) return PSTATE_SCROLLED;
+ if (n != cur(a)->focus) return PSTATE_UNFOCUSED;
+ return PSTATE_COUNT;
+}
+
+static size_t policy_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) {
+ pane_state_t st = pane_state(a, n);
+ if (st >= PSTATE_COUNT) return 0;
+ size_t k = CFG.state_n[st];
+ if (k > cap) k = cap;
+ for (size_t i = 0; i < k; i++) out[i] = CFG.state_shaders[st][i];
return k;
}
@@ -1550,6 +1553,9 @@ static void draw_cb(node_t *n, void *ud) {
draw_frame(d->a, d->s, n);
if (pane_suspended(n->pane)) {
+ /* Falls through to the shader pass rather than returning: a pane that has
+ * not started is a state you can want to colour, and its label is the only
+ * content it has. */
/* The pane exists, is laid out, and has run nothing. Say what it would. */
const char *label = pane_label(n->pane);
char line[256];
@@ -1572,9 +1578,10 @@ static void draw_cb(node_t *n, void *ud) {
screen_text(d->s, (uint16_t)(n->content.x + (n->content.w - w) / 2),
(uint16_t)(n->content.y + n->content.h / 2), line, FRAME_IDLE,
NO_COLOR, 0);
- return;
+ } else {
+ pane_compose(n->pane, d->s, n->content.x, n->content.y,
+ n == cur(d->a)->focus);
}
- pane_compose(n->pane, d->s, n->content.x, n->content.y, n == cur(d->a)->focus);
/* Between the contents and the chrome that goes over them: the frame was
* painted before this and lies outside the content rect, and the split guide
* is painted after, so it stays legible on top of a shaded pane. */
diff --git a/src/config.c b/src/config.c
index 92ee560..fee7d54 100644
--- a/src/config.c
+++ b/src/config.c
@@ -106,6 +106,51 @@ static bool parse_color(const char *text, color_t *out) {
return true;
}
+static const char *const PSTATE_NAMES[PSTATE_COUNT] = {
+ "dragging", "drop_hover", "drop_target", "suspended", "scrolled",
+ "unfocused",
+};
+
+const char *pane_state_name(pane_state_t s) {
+ return s < PSTATE_COUNT ? PSTATE_NAMES[s] : "";
+}
+
+/* Reads the children of `node` as a shader chain into `out` (SHADE_MAX slots),
+ * returning how many were understood. */
+static size_t parse_shader_list(config_t *c, const kdl_node_t *node,
+ shader_t *out, char *err, size_t errcap) {
+ size_t n = 0;
+ for (size_t i = 0; i < node->nkids && n < SHADE_MAX; i++) {
+ const kdl_node_t *k = node->kids[i];
+ if (!k || !k->name) continue;
+
+ long amount = kdl_prop_int(k, "amount", 128);
+ if (amount < 0) amount = 0;
+ if (amount > 255) amount = 255;
+ /* Every shader that takes a number calls it something different, so accept
+ * each name rather than making you remember which belongs to which. */
+ long param = kdl_prop_int(k, "at", -1);
+ if (param < 0) param = kdl_prop_int(k, "radius", -1);
+ if (param < 0) param = kdl_prop_int(k, "band", -1);
+ if (param < 0) param = kdl_prop_int(k, "direction", -1);
+ if (param < 0) param = 0;
+ if (param > 65535) param = 65535;
+
+ color_t col = c->frame_focus; /* a sensible default for `ruler` */
+ const char *cs = kdl_prop(k, "color", NULL);
+ if (cs && !parse_color(cs, &col) && err && !err[0])
+ snprintf(err, errcap, "bad colour for shader %s: %s", k->name, cs);
+
+ if (!shader_make_p(&out[n], k->name, col, (uint8_t)amount,
+ (uint16_t)param)) {
+ if (err && !err[0]) snprintf(err, errcap, "unknown shader: %s", k->name);
+ continue;
+ }
+ n++;
+ }
+ return n;
+}
+
static void bind_add(config_t *c, int key, uint16_t mods, action_t action) {
for (size_t i = 0; i < c->nbinds; i++)
if (c->binds[i].key == key && c->binds[i].mods == mods) {
@@ -129,15 +174,26 @@ 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->drag_dim = 140;
c->status_bar = true;
c->focus_follows_mouse = true;
c->default_fg = rgb(0xff, 0xff, 0xff);
c->default_bg = rgb(0x00, 0x00, 0x00);
+ /* The one state with an opinion out of the box: while a pane is being
+ * dragged, everything it could be dropped onto is pushed back so the pane in
+ * your hand stands out. Everything else is empty until asked for. */
+ shader_make(&c->state_shaders[PSTATE_DROP_TARGET][0], "grayscale",
+ (color_t){0}, 200);
+ shader_make(&c->state_shaders[PSTATE_DROP_TARGET][1], "dim", (color_t){0},
+ 140);
+ c->state_n[PSTATE_DROP_TARGET] = 2;
+ shader_make(&c->state_shaders[PSTATE_DROP_HOVER][0], "grayscale",
+ (color_t){0}, 200);
+ shader_make(&c->state_shaders[PSTATE_DROP_HOVER][1], "dim", (color_t){0},
+ 140);
+ c->state_n[PSTATE_DROP_HOVER] = 2;
+
c->frame_focus = rgb(0xff, 0x5f, 0xd7);
c->frame_idle = rgb(0x45, 0x45, 0x4a);
c->title_focus = rgb(0xff, 0xff, 0xff);
@@ -257,12 +313,7 @@ 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);
- c->drag_dim =
- (uint8_t)kdl_arg_int(kdl_child(root, "drag_dim"), 0, c->drag_dim);
+
const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL);
if (sh) {
@@ -270,41 +321,29 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
c->shell = strdup(sh);
}
- /* `shaders { vignette amount=60; ruler amount=70 at=80 }` — one node per
- * pass, and the order they are written in is the order they run, because a
- * chain is a sequence. A block that names nothing known leaves the previous
- * set alone rather than silently clearing it. */
+ /* One node per pass, in the order written, because a chain is a sequence.
+ * Shared by `shaders { }` and by every state, so a state's chain is exactly
+ * as expressive as the global one and there is one parser to be wrong. */
const kdl_node_t *shaders = kdl_child(root, "shaders");
- if (shaders) {
- c->nshaders = 0;
- for (size_t i = 0; i < shaders->nkids && c->nshaders < SHADE_MAX; i++) {
- const kdl_node_t *k = shaders->kids[i];
- if (!k || !k->name) continue;
+ if (shaders) c->nshaders = parse_shader_list(c, shaders, c->shaders, err, errcap);
- long amount = kdl_prop_int(k, "amount", 128);
- if (amount < 0) amount = 0;
- if (amount > 255) amount = 255;
- /* Every shader that takes a number calls it something different, so
- * accept each name rather than making you remember which is which. */
- long param = kdl_prop_int(k, "at", -1);
- if (param < 0) param = kdl_prop_int(k, "radius", -1);
- if (param < 0) param = kdl_prop_int(k, "band", -1);
- if (param < 0) param = kdl_prop_int(k, "direction", -1);
- if (param < 0) param = 0;
- if (param > 65535) param = 65535;
-
- color_t col = c->frame_focus; /* a sensible default for `ruler` */
- const char *cs = kdl_prop(k, "color", NULL);
- if (cs && !parse_color(cs, &col) && err && !err[0])
- snprintf(err, errcap, "bad colour for shader %s: %s", k->name, cs);
-
- if (!shader_make_p(&c->shaders[c->nshaders], k->name, col,
- (uint8_t)amount, (uint16_t)param)) {
- if (err && !err[0])
- snprintf(err, errcap, "unknown shader: %s", k->name);
+ /* `states { drop_target { grayscale amount=200; dim amount=140 } }` — what a
+ * pane looks like while it is in a state. Naming a state at all replaces its
+ * default outright, including with nothing, which is how you turn one off. */
+ const kdl_node_t *states = kdl_child(root, "states");
+ if (states) {
+ for (size_t i = 0; i < states->nkids; i++) {
+ const kdl_node_t *k = states->kids[i];
+ if (!k || !k->name) continue;
+ pane_state_t st = PSTATE_COUNT;
+ for (size_t j = 0; j < PSTATE_COUNT; j++)
+ if (strcmp(pane_state_name((pane_state_t)j), k->name) == 0)
+ st = (pane_state_t)j;
+ if (st == PSTATE_COUNT) {
+ if (err && !err[0]) snprintf(err, errcap, "unknown pane state: %s", k->name);
continue;
}
- c->nshaders++;
+ c->state_n[st] = parse_shader_list(c, k, c->state_shaders[st], err, errcap);
}
}
diff --git a/src/config.h b/src/config.h
index a6bd2f2..098c9bc 100644
--- a/src/config.h
+++ b/src/config.h
@@ -14,6 +14,23 @@
#include "shader.h"
#include "sl0ppty.h"
+/* Pane states a shader can be hung off, in the order they are tested. Only
+ * states a pane can actually be seen in: a pane whose program exits is reaped
+ * before the next paint, so there is deliberately no "dead" here — it would
+ * name a shader that could never draw a frame. */
+typedef enum {
+ PSTATE_DRAGGING, /* the pane you have hold of */
+ PSTATE_DROP_HOVER, /* the one under the pointer, where it would land */
+ PSTATE_DROP_TARGET, /* the others, all of them somewhere it could go */
+ PSTATE_SUSPENDED, /* laid out, never started */
+ PSTATE_SCROLLED, /* looking at scrollback rather than the present */
+ PSTATE_UNFOCUSED,
+ PSTATE_COUNT,
+} pane_state_t;
+
+/* The config name for a state, e.g. "drop_target". */
+const char *pane_state_name(pane_state_t s);
+
typedef enum {
ACT_NONE = 0,
ACT_SPLIT_COLS,
@@ -64,15 +81,25 @@ typedef struct {
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;
- uint8_t drag_dim;
-
/* Colour passes every pane gets, in the order they were written. Ordinary
* shaders rather than policy: the session has no opinion about these, you
* asked for them. */
shader_t shaders[SHADE_MAX];
size_t nshaders;
+
+ /* What a pane looks like when it is in a particular state. A pane is
+ * usually in several at once — unfocused *and* scrolled, suspended *and*
+ * something you could drop onto — so exactly one wins, the first that
+ * matches in the order below. That order is fixed rather than taken from
+ * the config, because it is a ranking by urgency and not a preference: a
+ * pane you are holding should not be recoloured by anything, and a mode the
+ * whole screen is in outranks an ambient hint about one pane.
+ *
+ * Stacking them instead would let two reasons to be grey compound into one
+ * muddy grey that reads as neither, which is the mistake the drag policy
+ * already had to avoid by hand. */
+ shader_t state_shaders[PSTATE_COUNT][SHADE_MAX];
+ size_t state_n[PSTATE_COUNT];
bool status_bar;
bool focus_follows_mouse;
diff --git a/tests/test_shaders.py b/tests/test_shaders.py
index 5620e78..b3e92ee 100644
--- a/tests/test_shaders.py
+++ b/tests/test_shaders.py
@@ -29,6 +29,16 @@ def cfg(text):
return f.name
+def unfocused_cfg(amount):
+ return "states {\n unfocused { dim amount=%d }\n}\n" % amount
+
+
+def drag_cfg(chain):
+ """Both drop states get the same chain, which is what the defaults do."""
+ return "states {\n drop_target { %s }\n drop_hover { %s }\n}\n" % (
+ chain, chain)
+
+
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"])
@@ -65,7 +75,7 @@ def release(s, x, y):
s.send(rf"\e[<0;{x + 1};{y + 1}m")
-# ---- dim_unfocused ---------------------------------------------------------
+# ---- the unfocused state ---------------------------------------------------
def test_off_by_default():
with Session(SH, cols=80, rows=14) as s:
@@ -82,7 +92,7 @@ def test_off_by_default():
def test_dims_everything_but_the_focused_pane():
- with Session(SH, cols=80, rows=14, config=cfg("dim_unfocused 128\n")) as s:
+ with Session(SH, cols=80, rows=14, config=cfg(unfocused_cfg(128))) as s:
s.settle(200)
split(s)
s.settle(200)
@@ -100,7 +110,7 @@ def test_dims_everything_but_the_focused_pane():
def test_the_dimming_follows_focus():
- with Session(SH, cols=80, rows=14, config=cfg("dim_unfocused 128\n")) as s:
+ with Session(SH, cols=80, rows=14, config=cfg(unfocused_cfg(128))) as s:
s.settle(200)
split(s)
s.settle(200)
@@ -120,7 +130,7 @@ def test_the_dimming_follows_focus():
def test_dimming_never_touches_the_chrome():
- with Session(SH, cols=80, rows=14, config=cfg("dim_unfocused 200\n")) as s:
+ with Session(SH, cols=80, rows=14, config=cfg(unfocused_cfg(200))) as s:
s.settle(200)
split(s)
s.settle(200)
@@ -132,7 +142,7 @@ def test_dimming_never_touches_the_chrome():
"#" in str(snap.style_at(3, 1)), str(snap.style_at(3, 1)))
-# ---- drag_grayscale --------------------------------------------------------
+# ---- the drag states -------------------------------------------------------
def test_dragging_greys_the_other_panes():
with Session(SH, cols=90, rows=16) as s:
@@ -169,7 +179,7 @@ def test_dragging_greys_the_other_panes():
def test_full_strength_is_a_true_grey():
with Session(SH, cols=90, rows=16,
- config=cfg("drag_grayscale 255\n")) as s:
+ config=cfg(drag_cfg("grayscale amount=255"))) as s:
s.settle(200)
left, right = split(s)
s.settle(200)
@@ -202,7 +212,7 @@ def test_a_press_that_never_moves_greys_nothing():
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:
+ with Session(SH, cols=90, rows=16, config=cfg(unfocused_cfg(128))) as s:
s.settle(200)
left, right = split(s)
s.settle(200)
@@ -235,18 +245,18 @@ def drag_and_sample(conf):
def test_the_two_drag_knobs_are_independent():
- both_off = drag_and_sample("drag_grayscale 0\ndrag_dim 0\n")
+ both_off = drag_and_sample(drag_cfg(""))
check("with both off a drag leaves every pane alone", both_off == GREEN,
str(both_off))
# Desaturate only: channels converge, brightness roughly survives.
- gray_only = drag_and_sample("drag_grayscale 255\ndrag_dim 0\n")
+ gray_only = drag_and_sample(drag_cfg("grayscale amount=255"))
ch = [int(gray_only[i:i + 2], 16) for i in (1, 3, 5)]
check("grayscale alone desaturates without darkening much",
ch[0] == ch[1] == ch[2] and ch[1] > 0x60, str(gray_only))
# Darken only: the hue survives, the brightness does not.
- dim_only = drag_and_sample("drag_grayscale 0\ndrag_dim 200\n")
+ dim_only = drag_and_sample(drag_cfg("dim amount=200"))
ch = [int(dim_only[i:i + 2], 16) for i in (1, 3, 5)]
check("dim alone darkens without desaturating",
ch[0] == 0 and ch[2] == 0 and 0 < ch[1] < 0x60, str(dim_only))
@@ -415,6 +425,133 @@ def test_positional_shaders_stay_off_the_chrome():
frame_fg(snap, p) == "#ff5fd7", str(frame_fg(snap, p)))
+# ---- states as a table -----------------------------------------------------
+
+def lay(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+def test_the_dragged_pane_can_have_a_state_of_its_own():
+ conf = 'states {\n dragging { tint amount=255 color="#0000ff" }\n}\n'
+ with Session(SH, cols=90, rows=16, config=cfg(conf)) 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 in your hand takes the dragging state",
+ content_fg(snap, left) == "#0000ff", str(content_fg(snap, left)))
+ release(s, right["x"] + 5, right["y"] + 3)
+ s.settle(200)
+ check("and gives it back on release",
+ content_fg(s.snapshot(), s.panes()[0]) == GREEN, "")
+
+
+def test_the_hovered_pane_is_a_different_state_from_the_rest():
+ conf = ('states {\n'
+ ' drop_hover { tint amount=255 color="#ff0000" }\n'
+ ' drop_target { tint amount=255 color="#0000ff" }\n'
+ '}\n')
+ with Session(SH, cols=120, rows=20, config=cfg(conf)) as s:
+ s.settle(200)
+ s.key("\\\\")
+ s.settle(150)
+ s.key("\\\\")
+ s.settle(250)
+ panes = sorted(s.panes(), key=lambda p: p["x"])
+ if len(panes) < 3:
+ check("three panes for the drop-state test", False, str(len(panes)))
+ return
+ a, b, c = panes
+ press(s, a["x"] + 4, a["y"])
+ motion(s, b["x"] + 3, b["y"] + 3)
+ s.settle(150)
+ snap = s.snapshot()
+ check("the pane under the pointer is the hover state",
+ content_fg(snap, b) == "#ff0000", str(content_fg(snap, b)))
+ check("the other candidates are the plain target state",
+ content_fg(snap, c) == "#0000ff", str(content_fg(snap, c)))
+ release(s, b["x"] + 3, b["y"] + 3)
+ s.settle(150)
+
+
+def test_a_suspended_pane_is_a_state():
+ """It draws a label rather than a program, and that is still contents."""
+ l = lay('layout {\n tab name="t" {\n pane\n'
+ ' pane suspended=true command="sleep 1"\n }\n}\n')
+ conf = 'states {\n suspended { tint amount=255 color="#00aaff" }\n}\n'
+ with Session(SH, cols=90, rows=14, config=cfg(conf), layout=l) as s:
+ s.settle(250)
+ panes = s.panes()
+ susp = [p for p in panes if p["suspended"]]
+ check("the layout made a suspended pane", len(susp) == 1, str(panes))
+ if not susp:
+ return
+ snap = s.snapshot()
+ run = snap.style_at(susp[0]["content_x"] + susp[0]["content_w"] // 2,
+ susp[0]["content_y"] + susp[0]["content_h"] // 2)
+ check("a suspended pane reaches the shader pass at all",
+ (run or {}).get("fg") == "#00aaff", str(run))
+
+
+def test_scrollback_is_a_state():
+ noisy = ["/bin/sh", "-c",
+ 'printf "\\033]2;p\\007"; i=0; while [ $i -lt 200 ]; do '
+ 'printf "\\033[38;2;0;255;0mline %d\\033[0m\\n" $i; i=$((i+1)); done; '
+ 'stty raw -echo; cat']
+ conf = 'states {\n scrolled { tint amount=255 color="#ffaa00" }\n}\n'
+ with Session(noisy, cols=80, rows=14, config=cfg(conf)) as s:
+ s.settle(300)
+ p = s.pane()
+ check("not scrolled yet, so no state colour",
+ content_fg(s.snapshot(), p) == GREEN,
+ str(content_fg(s.snapshot(), p)))
+ # No scroll command exists, so drive it the way a person does: the
+ # wheel. SGR button 64 is wheel-up.
+ for _ in range(6):
+ s.send(rf"\e[<64;{p['content_x'] + 2};{p['content_y'] + 2}M")
+ s.settle(250)
+ check("looking at the past is a state you can colour",
+ content_fg(s.snapshot(), p) == "#ffaa00",
+ str(content_fg(s.snapshot(), p)))
+
+
+def test_exactly_one_state_wins():
+ """A pane is in several at once; the ranking decides, and does not stack."""
+ l = lay('layout {\n tab name="t" {\n pane\n'
+ ' pane suspended=true command="sleep 1"\n }\n}\n')
+ conf = ('states {\n'
+ ' suspended { tint amount=255 color="#00aaff" }\n'
+ ' unfocused { tint amount=255 color="#ff0000" }\n'
+ '}\n')
+ with Session(SH, cols=90, rows=14, config=cfg(conf), layout=l) as s:
+ s.settle(250)
+ susp = [p for p in s.panes() if p["suspended"]]
+ if not susp or susp[0]["focused"]:
+ check("a suspended, unfocused pane to test with", bool(susp),
+ str(s.panes()))
+ return
+ run = s.snapshot().style_at(
+ susp[0]["content_x"] + susp[0]["content_w"] // 2,
+ susp[0]["content_y"] + susp[0]["content_h"] // 2)
+ check("suspended outranks unfocused, and they do not mix",
+ (run or {}).get("fg") == "#00aaff", str(run))
+
+
+def test_an_unknown_state_is_refused():
+ conf = 'states {\n haunted { dim amount=255 }\n}\n'
+ with Session(SH, cols=60, rows=12, config=cfg(conf)) as s:
+ s.settle(200)
+ check("an unknown state name leaves the session running",
+ s.alive() and content_fg(s.snapshot(), s.pane()) == GREEN,
+ str(content_fg(s.snapshot(), s.pane())))
+
+
if __name__ == "__main__":
test_off_by_default()
test_dims_everything_but_the_focused_pane()
@@ -435,4 +572,10 @@ if __name__ == "__main__":
test_config_shaders_run_in_written_order()
test_an_unknown_shader_is_refused_not_guessed()
test_positional_shaders_stay_off_the_chrome()
+ test_the_dragged_pane_can_have_a_state_of_its_own()
+ test_the_hovered_pane_is_a_different_state_from_the_rest()
+ test_a_suspended_pane_is_a_state()
+ test_scrollback_is_a_state()
+ test_exactly_one_state_wins()
+ test_an_unknown_state_is_refused()
sys.exit(report())