aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 13:45:21 +0200
committeruser <user@clank>2026-08-15 13:45:21 +0200
commit015ba12fde4fcce15440b39cb6369a4ad66238cf (patch)
treed5cf6edb0d7de3535a07e9249380d14ec391fe4e
parentsl0ppty: centre the middle of the status line on the row (diff)
downloadslosh-015ba12fde4fcce15440b39cb6369a4ad66238cf.tar.gz
slosh-015ba12fde4fcce15440b39cb6369a4ad66238cf.tar.xz
slosh-015ba12fde4fcce15440b39cb6369a4ad66238cf.zip
sl0ppty: dim the panes you are not in, and a knob to say how much
`dim_unfocused 60`, on by default, 0 to turn it off. The dimming itself was already possible -- `states { unfocused { dim } }` -- and deliberately not the default. The argument then was that ambient contrast is a taste, and shipping a taste as a default is how a tool earns a reputation for fighting you. That argument was mostly about the escape hatch: "turn it off" meant knowing the states block exists and what a shader chain is. A single number does not have that problem, so the objection goes away and the default can be the one most people want. The knob and the table are one mechanism -- the knob fills the unfocused chain in when the config has not -- so writing the chain by hand still wins, including writing an empty one. `states { unfocused { } }` means nothing at all, and is not quietly refilled, which needed the parser to distinguish "said nothing" from "said none". 60 by choice: white text lands on #c3c3c3, which reads as "not this one" without reading as "not available", because half the time you are reading the other pane. Picked by looking at 40, 55, 70, 90 and 120 rather than by taste alone. The tests that had to change are the interesting part. Six in test_shaders.py asserted "back to green after the drag", which is no longer what normal looks like -- and rewriting them to compare against a snapshot taken before the drag failed too, because a drag *moves focus*: press a title and that pane is yours, so the two panes legitimately swap appearances across one. They assert the rule now (the pane you are in keeps its colour, the others are pushed back), which is both what we mean and immune to the next change of defaults. test_states.py's "an unfocused pane is left alone" is inverted, with the reason written where the assertion used to be, because a test that flips should say why it flipped.
-rw-r--r--DESIGN.md2
-rw-r--r--README.md18
-rw-r--r--config/config.kdl23
-rw-r--r--src/config.c30
-rw-r--r--src/config.h13
-rw-r--r--tests/test_shaders.py64
-rw-r--r--tests/test_states.py61
7 files changed, 166 insertions, 45 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 0b77f2a..c97101f 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. 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. **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. |
+| **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. `unfocused` is a taste rather than a fact and gets one anyway, through a knob of its own (`dim_unfocused`) rather than through the table: the objection to shipping a taste was never the taste, it was that turning it off should not require knowing the states block exists, and a single number fixes that. Writing the chain by hand still wins over the knob, including writing an empty one. `dragging` keeps no default, because the pane in your hand keeping its colour is what makes it lift off the page. 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/README.md b/README.md
index d0b2ed8..dc6c11a 100644
--- a/README.md
+++ b/README.md
@@ -137,18 +137,20 @@ 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 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:
+Panes are tinted by *state*. Out of the box: the panes you are **not in** are
+gently pushed back (`dim_unfocused 60`, or `0` if you hate it), and so is any
+pane that is **not live** — its program exited, it never started, or you are
+looking at scrollback rather than the present. That second group is the one
+you cannot discover by looking unless something says so:
```kdl
-states {
- dead { grayscale amount=200; dim amount=90 } // shipped
+dim_unfocused 60 // the one knob most people want
+
+states { // ...and the whole table underneath it
+ dead { grayscale amount=200; dim amount=90 }
suspended { grayscale amount=170; dim amount=60 }
scrolled { tint amount=22 color="#ff5fd7" }
- unfocused { dim amount=90 } // yours, if you want it
+ unfocused { dim amount=60 } // writing this replaces dim_unfocused
}
```
diff --git a/config/config.kdl b/config/config.kdl
index 1d0857e..c8b6ff2 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -113,6 +113,17 @@ newtab_mark "+"
// last thing a failing command printed.
keep_dead true
+// How far the panes you are *not* in are pushed back, 0..255, and 0 for not
+// at all. On by default and gently: at 60 white text lands on #c3c3c3, which
+// reads as "not this one" without reading as "not available", because half
+// the time you are still reading the other pane.
+//
+// This is the one taste shipped as a default, and it is shipped because
+// turning it off is a single obvious number rather than a shader incantation.
+// Writing the chain yourself still wins -- `states { unfocused { ... } }`
+// replaces it, and `states { unfocused { } }` means nothing at all.
+dim_unfocused 60
+
bell_indicator true
// What to draw. A short string rather than a fixed glyph, because the obvious
@@ -245,10 +256,12 @@ modal_scrim 120
// 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.
+// a pane that is merely *not the one you are in* is a taste rather than a
+// fact, and gets a default anyway -- but through `dim_unfocused` above,
+// which is one number to change or zero out. The objection to shipping a
+// taste was never the taste, it was that turning it off should not require
+// knowing this block exists. `dragging` still has no default: the pane in
+// your hand keeping its colour is what makes it lift off the page.
//
// 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
@@ -263,7 +276,7 @@ states {
suspended { grayscale amount=170; dim amount=60 }
scrolled { tint amount=22 color="#ff5fd7" } // = theme's scroll_bg
// dragging { }
- // unfocused { dim amount=90 }
+ // unfocused { dim amount=60 } // = dim_unfocused above; yours replaces it
}
// While a pane is being dragged its border stays solid and every other pane's
diff --git a/src/config.c b/src/config.c
index c31ca76..71dbb49 100644
--- a/src/config.c
+++ b/src/config.c
@@ -201,6 +201,21 @@ static void bind_add(config_t *c, int key, uint16_t mods, action_t action,
c->binds[c->nbinds++] = (binding_t){key, mods, action, direct};
}
+/* The knob and the states table are one mechanism: the knob writes the table.
+ * `declared` is whether the config named `unfocused` itself, in which case it
+ * has said what it wants -- including `states { unfocused { } }`, which says
+ * "nothing", and must not be quietly refilled. */
+static void apply_dim_unfocused(config_t *c, bool declared) {
+ if (declared) return;
+ if (!c->dim_unfocused) {
+ c->state_n[PSTATE_UNFOCUSED] = 0;
+ return;
+ }
+ shader_make(&c->state_shaders[PSTATE_UNFOCUSED][0], "dim", (color_t){0},
+ c->dim_unfocused);
+ c->state_n[PSTATE_UNFOCUSED] = 1;
+}
+
void config_defaults(config_t *c) {
memset(c, 0, sizeof *c);
c->gap = 1;
@@ -220,6 +235,10 @@ void config_defaults(config_t *c) {
c->bell_indicator = true;
snprintf(c->bell_mark, sizeof c->bell_mark, "\u2022");
c->keep_dead = true;
+ /* Gentle: an unfocused pane is one you are still reading half the time.
+ * At 60 white text lands on #c3c3c3, which reads as "not this one" without
+ * reading as "not available". */
+ c->dim_unfocused = 60;
c->min_pane_cols = 24;
c->min_pane_rows = 6;
c->min_split_cols = 32;
@@ -280,6 +299,8 @@ void config_defaults(config_t *c) {
shader_make(&c->state_shaders[PSTATE_SUSPENDED][1], "dim", (color_t){0}, 60);
c->state_n[PSTATE_SUSPENDED] = 2;
+ apply_dim_unfocused(c, false);
+
const color_t accent = rgb(0xff, 0x5f, 0xd7);
const color_t ink = rgb(0x14, 0x14, 0x18);
const color_t dim = rgb(0x45, 0x45, 0x4a);
@@ -634,6 +655,10 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
const char *nt = kdl_arg(kdl_child(root, "newtab_mark"), 0, NULL);
if (nt) snprintf(c->newtab_mark, sizeof c->newtab_mark, "%s", nt);
c->keep_dead = kdl_arg_bool(kdl_child(root, "keep_dead"), 0, c->keep_dead);
+ {
+ long v = kdl_arg_int(kdl_child(root, "dim_unfocused"), 0, c->dim_unfocused);
+ c->dim_unfocused = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v);
+ }
c->bell_indicator =
kdl_arg_bool(kdl_child(root, "bell_indicator"), 0, c->bell_indicator);
const char *bm = kdl_arg(kdl_child(root, "bell_mark"), 0, NULL);
@@ -710,6 +735,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
* 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");
+ bool unfocused_declared = false;
if (states) {
for (size_t i = 0; i < states->nkids; i++) {
const kdl_node_t *k = states->kids[i];
@@ -722,9 +748,13 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
if (err && !err[0]) snprintf(err, errcap, "unknown pane state: %s", k->name);
continue;
}
+ if (st == PSTATE_UNFOCUSED) unfocused_declared = true;
c->state_n[st] = parse_shader_list(c, k, c->state_shaders[st], err, errcap);
}
}
+ /* After the states block, so a config that wrote its own chain keeps it and
+ * one that did not gets the knob's. */
+ apply_dim_unfocused(c, unfocused_declared);
const kdl_node_t *theme = kdl_child(root, "theme");
if (theme) {
diff --git a/src/config.h b/src/config.h
index 2f844ee..6a93b45 100644
--- a/src/config.h
+++ b/src/config.h
@@ -141,8 +141,17 @@ typedef struct {
* A modal that floats over a screen as bright as it is reads as another
* pane rather than as something in front. */
uint8_t modal_scrim;
- /* Shader strengths, 0..255, 0 being off. Ambient dimming is a taste and is
- * off by default; the drag greying is transient and on. */
+ /* How far the panes you are *not* in are pushed back, 0..255 and 0 for not
+ * at all. A knob rather than only a `states { unfocused { } }` chain,
+ * because "dim the other panes" is a thing people want by name and should
+ * not have to know the shader vocabulary to ask for -- and because the
+ * argument against shipping it on was that turning it off should be
+ * obvious, which a single number is and a chain is not.
+ *
+ * Writing the state chain by hand still wins: the knob only fills it in
+ * when the config has not. */
+ uint8_t dim_unfocused;
+
/* 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. */
diff --git a/tests/test_shaders.py b/tests/test_shaders.py
index b8e3b2c..214d4d7 100644
--- a/tests/test_shaders.py
+++ b/tests/test_shaders.py
@@ -50,6 +50,22 @@ def frame_fg(snap, pane):
return (run or {}).get("fg")
+def check_at_rest(s, label):
+ """Assert every pane looks the way it should with nothing transient going
+ on: the one you are in keeps its colour, the others are pushed back.
+
+ Stated as the rule rather than as "the same as before the drag", because a
+ drag *moves focus* -- press a title and that pane is yours -- so the two
+ panes legitimately swap appearances across one. Comparing to a remembered
+ snapshot said they had both changed, which was true and not the point."""
+ snap = s.snapshot()
+ for p in s.panes():
+ fg = content_fg(snap, p)
+ ok = fg == GREEN if p["focused"] else fg not in (GREEN, None)
+ check(f"{label}: pane {p['id']} is at rest",
+ ok, f"{fg} (focused={p['focused']})")
+
+
def split(s):
s.key("\\\\")
s.settle()
@@ -77,19 +93,35 @@ def release(s, x, y):
# ---- the unfocused state ---------------------------------------------------
-def test_off_by_default():
+def test_the_default_dims_the_pane_you_are_not_in():
+ """`dim_unfocused` writes the unfocused chain when a config has not, so out
+ of the box the pane you left is pushed back and the one you are in is not.
+ (This asserted the opposite until the knob existed to turn it off with.)"""
with Session(SH, cols=80, rows=14) as s:
s.until_text("BLOCK")
split(s)
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",
+ check("the unfocused pane is dimmed",
+ content_fg(snap, other) not in (GREEN, None),
+ str(content_fg(snap, other)))
+ check("and the focused one is left alone",
content_fg(snap, focused) == GREEN,
str(content_fg(snap, focused)))
+def test_nothing_is_dimmed_with_the_knob_at_zero():
+ with Session(SH, cols=80, rows=14, config=cfg("dim_unfocused 0\n")) as s:
+ s.until_text("BLOCK")
+ split(s)
+ focused, other = by_focus(s)
+ snap = s.snapshot()
+ check("neither pane is touched",
+ content_fg(snap, other) == GREEN and
+ content_fg(snap, focused) == GREEN,
+ f"{content_fg(snap, other)} {content_fg(snap, focused)}")
+
+
def test_dims_everything_but_the_focused_pane():
with Session(SH, cols=80, rows=14, config=cfg(unfocused_cfg(128))) as s:
s.until_text("BLOCK")
@@ -168,10 +200,7 @@ def test_dragging_greys_the_other_panes():
ch[0] == ch[2], str(grey))
release(s, right["x"] + 5, right["y"] + 3)
- 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)))
+ check_at_rest(s, "after the drop")
def test_full_strength_is_a_true_grey():
@@ -197,9 +226,7 @@ def test_a_press_that_never_moves_greys_nothing():
left, right = split(s)
press(s, left["x"] + 4, left["y"])
- snap = s.snapshot()
- check("pressing without moving greys nothing",
- content_fg(snap, right) == GREEN, str(content_fg(snap, right)))
+ check_at_rest(s, "a press that never moved")
release(s, left["x"] + 4, left["y"])
s.settle(120)
@@ -333,10 +360,7 @@ def test_a_drag_that_ends_off_a_pane_still_clears():
s.settle(120)
motion(s, 0, 0) # out into the margin
release(s, 0, 0)
- 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)))
+ check_at_rest(s, "a drop into nowhere")
def test_a_keystroke_mid_drag_clears_the_greying():
@@ -349,9 +373,7 @@ def test_a_keystroke_mid_drag_clears_the_greying():
motion(s, right["x"] + 5, right["y"] + 3)
s.settle(120)
s.send("x")
- snap = s.snapshot()
- check("the greying goes with the drag it belonged to",
- content_fg(snap, right) == GREEN, str(content_fg(snap, right)))
+ check_at_rest(s, "a keystroke mid-drag")
# ---- the config block ------------------------------------------------------
@@ -424,8 +446,7 @@ def test_the_dragged_pane_can_have_a_state_of_its_own():
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)
- check("and gives it back on release",
- content_fg(s.snapshot(), s.panes()[0]) == GREEN, "")
+ check_at_rest(s, "after the drop")
def test_the_hovered_pane_is_a_different_state_from_the_rest():
@@ -527,7 +548,8 @@ def test_an_unknown_state_is_refused():
if __name__ == "__main__":
- test_off_by_default()
+ test_the_default_dims_the_pane_you_are_not_in()
+ test_nothing_is_dimmed_with_the_knob_at_zero()
test_dims_everything_but_the_focused_pane()
test_the_dimming_follows_focus()
test_dimming_never_touches_the_chrome()
diff --git a/tests/test_states.py b/tests/test_states.py
index 7bcd6e7..13776f7 100644
--- a/tests/test_states.py
+++ b/tests/test_states.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""What sl0ppty says, out of the box, about a pane that is not live.
+"""What sl0ppty says, out of the box, about a pane you are not in.
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
@@ -87,19 +87,64 @@ def test_scrollback_is_coloured_by_default():
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."""
+def test_an_unfocused_pane_is_dimmed_by_default():
+ """This used to assert the opposite, deliberately: ambient contrast is a
+ taste, and the argument against shipping a taste was that turning it off
+ should be obvious. It is now — `dim_unfocused 0` — which is what changed.
+ The knob is the escape hatch that makes the default defensible."""
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}")
+ after = fg(s.snapshot(), unfocused)
+ check("the pane you left is pushed back", after != before,
+ f"{before} -> {after}")
+ check("but still plainly readable", after not in (None, "#000000"),
+ str(after))
+
+
+def test_dim_unfocused_zero_turns_it_off():
+ conf = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ conf.write('theme { default_fg "#ffffff" default_bg "#000000" }\n'
+ 'dim_unfocused 0\n')
+ conf.close()
+ with Session(SH, cols=90, rows=14, config=conf.name) as s:
+ s.until_text("line 39")
+ before = fg(s.snapshot(), s.pane())
+ s.key("\\\\")
+ s.settle(40)
+ unfocused = [p for p in s.panes() if not p["focused"]][0]
+ check("nothing is done to it", fg(s.snapshot(), unfocused) == before,
+ f"{before} -> {fg(s.snapshot(), unfocused)}")
+ os.unlink(conf.name)
+
+
+def test_a_states_block_beats_the_knob():
+ """Both directions, asserted on the colour rather than on "did it change":
+ a chain replaces the knob's dim, and an empty block means "nothing" rather
+ than "you did not say". (A grayscale chain makes a poor probe here — it
+ leaves white text white, so it looks like nothing happened.)"""
+ cases = [
+ ('dim_unfocused 60\nstates { unfocused { tint amount=255 color="#00ff00" } }\n',
+ "#00ff00", "a chain replaces the knob"),
+ ('dim_unfocused 60\nstates { unfocused { } }\n',
+ None, "an empty block means nothing at all"),
+ ('dim_unfocused 60\n', "#c3c3c3", "and the knob applies when nobody said"),
+ ]
+ for text, want, label in cases:
+ conf = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ conf.write('theme { default_fg "#ffffff" default_bg "#000000" }\n' + text)
+ conf.close()
+ with Session(SH, cols=90, rows=14, config=conf.name) as s:
+ s.until_text("line 39")
+ s.key("\\\\")
+ s.settle(40)
+ unfocused = [p for p in s.panes() if not p["focused"]][0]
+ got = fg(s.snapshot(), unfocused)
+ check(label, got == want, f"{got}, wanted {want}")
+ os.unlink(conf.name)
def test_a_flattened_tab_still_says_which_panes_are_not_live():