aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 23:06:32 +0200
committeruser <user@clank>2026-08-15 23:06:32 +0200
commitf6281fbec93d92bfc7e9164492e436d0236d9179 (patch)
treec78b435dec7f62c948e2e8144f29f47ccab2fa7d /tests
parentsl0ppty: a command palette, on a key you choose (diff)
downloadslosh-f6281fbec93d92bfc7e9164492e436d0236d9179.tar.gz
slosh-f6281fbec93d92bfc7e9164492e436d0236d9179.tar.xz
slosh-f6281fbec93d92bfc7e9164492e436d0236d9179.zip
sl0ppty: shaders on the chrome, and two keys for the arrangement
Five things, in one commit because they land in the same few files -- the action table, the shader pass, config.kdl -- and splitting them by hunk would buy a tidier log at the price of commits that do not build. Each is verified end to end; the suite is 46 files green. Chrome shaders. `where="chrome"` on any entry in `shaders` or in a `states` block runs that pass over a pane's frame -- border, padding, title, buttons -- instead of its contents. Nothing else changes: same registry, same `amount`, same expressions, same chain order, one new word, because a frame is another rect and so it is another pass. It runs over the frame's whole rect with the contents punched out as a hole, rather than four passes over four sides: an effect has to be able to travel round a border, and four passes would each count x/y from zero and restart at every corner. The hole keeps the two honest -- a skipped cell is never visited, so a full-strength chrome tint cannot reach your text. It is also the only kind of pass that can colour a pane a small window has collapsed to a single row (D6), which is the state where a colour is worth the most. `channel="fg"|"bg"|"both"` came out of looking at the first border flash: `tint` mixes both of a cell's colours, and a frame's background is the terminal's own default, so the pass materialised it to our guess at that default and mixed towards the colour -- a dark rectangle behind the glyphs, left there until the pane changed state. The mask is enforced by the pass, not the shader: the other colour is snapshotted before materialising and put back after, so an unset colour goes back to unset, it works for a built-in and a plugin alike, and no shader grows an fg-only twin of itself. A `bell` pane state and a `since` variable, because "flash the border when a pane rings" needed both and neither existed. `since` is milliseconds since the pane entered its current state, stamped in the one function that decides the state -- a transition is the one thing a frame cannot tell you, so it is remembered where it cannot disagree with the state it dates. `bell` ranks above the ambient states and below `dead`: a pane that rang while you were elsewhere is news, and a pane that rang and then exited is a pane that exited. It ends when you look at the pane, because that is what answering a bell is. Animation needed a clock and did not have one: a shader reading `t` only advanced when something else caused a frame, so a pulse froze the moment you stopped typing. `app_next_deadline_ms` now reports `anim_ms` while a chain that reads the clock ran over the last frame -- derived from what was applied, so a pulse hung off `dead` costs a clock while a pane is dead and nothing for the rest of the session. A `deadline` verb makes that observable; the server's poll loop already lived on the number. contrib/chrome has seven presets and the perimeter trick they are built on: on a frame the coordinate you want is distance along the ring, which the language can work out, and `min(d, P - d)` is what makes an effect cross the corner it started from. Hand-written, unlike contrib/shaders, because the toy previews contents and a frame has no preview to give -- so tests/test_chrome_presets.py checks each one the only way left: load it into a real session, assert the frame moves and the contents do not. `equalize` (C-a =) puts every share back to even: each split's children weighted by how many visible panes are behind them, so one pane beside a stack of three is a quarter of the width and "even" means the same thing at every depth. Exactly equal cell counts are not something a tree can always express, so the action promises the even division it can. `rotate-layout` (C-a Space) turns the whole tab a quarter turn clockwise. Every split changes axis and a row split's children reverse, because the pane on top of a stack is the pane on the right once you turn it; get that asymmetry wrong and you have a mirror image, which looks almost right until four turns fail to come back. They do come back, exactly, which is why the inverse is three more turns rather than a second mirrored routine with its own chance of being wrong -- and why a key as easy as space is the right one rather than a hazard. A turn is refused when it would not fit: a share of the height is not always a share the width can afford, and a node that cannot give its children their floor takes the whole tab down to a list. So the turn is tried, the layout is asked what it made of it, and one that flattened the tab is put back with a word. Same rule `split_fits` applies to splitting. Keys: the palette moves from `shift+p` to `p`, and cycling tabs from `n`/`p` to tab/shift+tab. `p` is pressed far more often than "the tab before this one" and was the only shifted letter in the defaults; tab is what every other tabbed thing cycles with, and `\e[Z` decodes as shift+tab on a plain terminal, so this costs nothing on a client without the kitty protocol. `n` is free again. The actions are untouched: two lines of config put the old binds back. The shader ABI goes to 3: `shade_ctx_t` gained `state_ms` and struct shader gained `channels`, both the pass's business rather than a shader's. Plugins need a rebuild, which is what the version is for.
Diffstat (limited to 'tests')
-rw-r--r--tests/expr_eval.c12
-rw-r--r--tests/expr_test.c9
-rw-r--r--tests/harness.py9
-rw-r--r--tests/shader_test.c130
-rwxr-xr-xtests/test_chrome_presets.py109
-rwxr-xr-xtests/test_chrome_shaders.py417
-rw-r--r--tests/test_dump_layout.py2
-rwxr-xr-xtests/test_equalize.py151
-rw-r--r--tests/test_palette.py2
-rwxr-xr-xtests/test_rotate.py204
-rw-r--r--tests/test_shadertoy.py18
-rw-r--r--tests/test_tabs.py24
12 files changed, 1056 insertions, 31 deletions
diff --git a/tests/expr_eval.c b/tests/expr_eval.c
index eaaa52b..d76c2ca 100644
--- a/tests/expr_eval.c
+++ b/tests/expr_eval.c
@@ -4,7 +4,8 @@
*
* One request per line on stdin:
*
- * <x> <y> <cols> <rows> <curx> <cury> <cursor> <focused> <t>\t<expression>
+ * <x> <y> <cols> <rows> <curx> <cury> <cursor> <focused> <t> <since>
+ * \t<expression>
*
* One answer per line on stdout: the value, or `error: ...`.
*/
@@ -29,14 +30,15 @@ int main(void) {
*tab = 0;
expr_env_t env = {0};
- long long t = 0;
- if (sscanf(line, "%d %d %d %d %d %d %d %d %lld", &env.x, &env.y, &env.cols,
- &env.rows, &env.curx, &env.cury, &env.cursor, &env.focused,
- &t) != 9) {
+ long long t = 0, since = 0;
+ if (sscanf(line, "%d %d %d %d %d %d %d %d %lld %lld", &env.x, &env.y,
+ &env.cols, &env.rows, &env.curx, &env.cury, &env.cursor,
+ &env.focused, &t, &since) != 10) {
printf("error: bad environment\n");
continue;
}
env.t = t;
+ env.since = since;
char err[128] = {0};
expr_prog_t *p = expr_compile(tab + 1, err, sizeof err);
diff --git a/tests/expr_test.c b/tests/expr_test.c
index a202f06..fb191b8 100644
--- a/tests/expr_test.c
+++ b/tests/expr_test.c
@@ -23,7 +23,7 @@ static int at(const char *src, int x, int y) {
}
expr_env_t env = {.x = x, .y = y, .cols = 80, .rows = 24,
.curx = 10, .cury = 5, .cursor = 1, .focused = 1,
- .t = 1000};
+ .t = 1000, .since = 250};
int v = expr_eval(p, &env);
expr_free(p);
return v;
@@ -81,6 +81,12 @@ int main(void) {
eq("cursor", 1);
eq("focused", 1);
eq("t", 1000);
+ eq("since", 250);
+ /* What a flash is: full strength for a moment after the state began, then
+ * nothing. `t` cannot express this — it says what time it is, not how long
+ * ago something happened. */
+ eq("(since < 300) * 255", 255);
+ eq("(since < 200) * 255", 0);
printf("\n-- functions\n");
eq("min(3, 9)", 3);
@@ -119,6 +125,7 @@ int main(void) {
{"dist(x, y, curx, cury)", EXPR_DEP_POS | EXPR_DEP_CURSOR},
{"focused * 90", EXPR_DEP_FOCUS},
{"t % 1000", EXPR_DEP_TIME},
+ {"since", EXPR_DEP_TIME}, /* a clock, so never cached */
};
for (size_t i = 0; i < sizeof cases / sizeof *cases; i++) {
char err[128] = {0};
diff --git a/tests/harness.py b/tests/harness.py
index 80e97a2..d842086 100644
--- a/tests/harness.py
+++ b/tests/harness.py
@@ -195,6 +195,15 @@ class Session:
self._cmd("alive")
return self.proc.stdout.readline().strip() == "true"
+ def deadline(self):
+ """Milliseconds until the session wants a frame of its own, or -1.
+
+ A property of the frame last composed -- an animated shader asks for a
+ clock, a toast asks to expire -- so snapshot() first or you are asking
+ about nothing."""
+ self._cmd("deadline")
+ return int(self.proc.stdout.readline().strip())
+
def close(self):
try:
self._cmd("quit")
diff --git a/tests/shader_test.c b/tests/shader_test.c
index 959d0f9..a8f68d3 100644
--- a/tests/shader_test.c
+++ b/tests/shader_test.c
@@ -48,7 +48,7 @@ static void run1(screen_t *s, const char *kind, color_t color, uint8_t amount) {
shader_t sh;
if (!shader_make(&sh, kind, color, amount)) return;
shade_ctx_t base = base_ctx();
- shade_apply(s, &sh, 1, 0, 0, s->cols, s->rows, &base);
+ shade_apply(s, &sh, 1, (rect_t){0, 0, s->cols, s->rows}, NULL, &base);
}
static void run1p(screen_t *s, const char *kind, color_t color, uint8_t amount,
@@ -56,7 +56,7 @@ static void run1p(screen_t *s, const char *kind, color_t color, uint8_t amount,
shader_t sh;
if (!shader_make_p(&sh, kind, color, amount, param)) return;
shade_ctx_t base = ctx ? *ctx : base_ctx();
- shade_apply(s, &sh, 1, 0, 0, s->cols, s->rows, &base);
+ shade_apply(s, &sh, 1, (rect_t){0, 0, s->cols, s->rows}, NULL, &base);
}
/* Fills every cell mid-grey, so any change is a change the shader made. */
@@ -216,7 +216,7 @@ int main(void) {
screen_init(&s, 4, 2);
put(&s, 0, 0, (color_t){0}, (color_t){0});
shade_ctx_t base = base_ctx();
- shade_apply(&s, NULL, 0, 0, 0, 4, 2, &base);
+ shade_apply(&s, NULL, 0, (rect_t){0, 0, 4, 2}, NULL, &base);
ok("no shaders leaves defaults unset", !screen_at(&s, 0, 0)->fg.set,
shown(screen_at(&s, 0, 0)->fg));
screen_free(&s);
@@ -255,7 +255,7 @@ int main(void) {
shader_t sh;
shader_make(&sh, "dim", (color_t){0}, 255);
shade_ctx_t base = base_ctx();
- shade_apply(&s, &sh, 1, 2, 1, 4, 2, &base); /* an inset rect */
+ shade_apply(&s, &sh, 1, (rect_t){2, 1, 4, 2}, NULL, &base); /* an inset rect */
ok("a cell inside the rect is shaded",
ceq(screen_at(&s, 2, 1)->fg, 0, 0, 0), shown(screen_at(&s, 2, 1)->fg));
@@ -288,14 +288,14 @@ int main(void) {
screen_init(&s, 2, 1);
put(&s, 0, 0, rgb(0x00, 0x80, 0x00), rgb(0, 0, 0));
shader_t ab[2] = {gray, tint};
- shade_apply(&s, ab, 2, 0, 0, 2, 1, &base);
+ shade_apply(&s, ab, 2, (rect_t){0, 0, 2, 1}, NULL, &base);
color_t after_ab = screen_at(&s, 0, 0)->fg;
screen_free(&s);
screen_init(&s, 2, 1);
put(&s, 0, 0, rgb(0x00, 0x80, 0x00), rgb(0, 0, 0));
shader_t ba[2] = {tint, gray};
- shade_apply(&s, ba, 2, 0, 0, 2, 1, &base);
+ shade_apply(&s, ba, 2, (rect_t){0, 0, 2, 1}, NULL, &base);
color_t after_ba = screen_at(&s, 0, 0)->fg;
screen_free(&s);
@@ -310,7 +310,7 @@ int main(void) {
shader_t twice[2];
shader_make(&twice[0], "dim", (color_t){0}, 128);
shader_make(&twice[1], "dim", (color_t){0}, 128);
- shade_apply(&s, twice, 2, 0, 0, 2, 1, &base);
+ shade_apply(&s, twice, 2, (rect_t){0, 0, 2, 1}, NULL, &base);
ok("two half-dims are darker than one",
screen_at(&s, 0, 0)->fg.r < 0x70, shown(screen_at(&s, 0, 0)->fg));
screen_free(&s);
@@ -324,11 +324,11 @@ int main(void) {
for (uint16_t x = 0; x < 8; x++)
put(&s, x, y, rgb(0x11, 0x22, 0x33), rgb(0, 0, 0));
- shader_t sh = {"probe", probe_fn, {0}, 0, 0};
+ shader_t sh = {.kind = "probe", .fn = probe_fn};
shade_ctx_t base = base_ctx();
base.now_ms = 1234;
base.focused = true;
- shade_apply(&s, &sh, 1, 2, 1, 4, 2, &base);
+ shade_apply(&s, &sh, 1, (rect_t){2, 1, 4, 2}, NULL, &base);
ok("every cell in the rect is visited exactly once", probe.calls == 4 * 2,
"");
@@ -349,6 +349,110 @@ int main(void) {
screen_free(&s);
}
+ /* ---- the hole: a frame is a rect minus its contents ---- */
+ {
+ memset(&probe, 0, sizeof probe);
+ screen_init(&s, 6, 5);
+ fill(&s, rgb(0xff, 0xff, 0xff), rgb(0xff, 0xff, 0xff));
+
+ shader_t sh = {.kind = "probe", .fn = probe_fn};
+ shade_ctx_t base = base_ctx();
+ rect_t hole = {1, 1, 4, 3};
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 6, 5}, &hole, &base);
+
+ ok("a hole leaves the ring, and only the ring", probe.calls == 6 * 5 - 4 * 3,
+ "");
+ ok("a cell inside the hole is not visited at all",
+ ceq(screen_at(&s, 2, 2)->bg, 0xff, 0xff, 0xff),
+ shown(screen_at(&s, 2, 2)->bg));
+ ok("the ring is still measured against the whole rect",
+ probe.cols == 6 && probe.rows == 5, "");
+ ok("so the far corner keeps the rect's own coordinates",
+ ceq(screen_at(&s, 5, 4)->bg, 5, 4, 0), shown(screen_at(&s, 5, 4)->bg));
+ ok("and a side cell beside the hole is the ring's",
+ ceq(screen_at(&s, 0, 2)->bg, 0, 2, 0), shown(screen_at(&s, 0, 2)->bg));
+ screen_free(&s);
+ }
+
+ /* A hole that is empty, or that misses, is no hole: a pane whose rect is too
+ * small for a frame has no content rect to speak of, and asking for one
+ * anyway must not silently skip half the pass. */
+ {
+ memset(&probe, 0, sizeof probe);
+ screen_init(&s, 4, 2);
+ fill(&s, rgb(0xff, 0xff, 0xff), rgb(0xff, 0xff, 0xff));
+ shader_t sh = {.kind = "probe", .fn = probe_fn};
+ shade_ctx_t base = base_ctx();
+ rect_t empty = {1, 1, 0, 0};
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 4, 2}, &empty, &base);
+ ok("a zero-sized hole removes nothing", probe.calls == 4 * 2, "");
+
+ memset(&probe, 0, sizeof probe);
+ rect_t elsewhere = {40, 40, 4, 4};
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 4, 2}, &elsewhere, &base);
+ ok("nor does one that does not overlap", probe.calls == 4 * 2, "");
+
+ /* Clipped rather than wrapped: a hole hanging off the rect removes the
+ * part that is inside it and nothing else. */
+ memset(&probe, 0, sizeof probe);
+ rect_t over = {2, 0, 10, 10};
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 4, 2}, &over, &base);
+ ok("a hole larger than the rect is clipped to it", probe.calls == 2 * 2, "");
+ screen_free(&s);
+ }
+
+ /* ---- channels: which of a cell's two colours a pass keeps ---- */
+ {
+ shader_t sh;
+ shade_ctx_t base = base_ctx();
+ const color_t red = {true, 0xff, 0, 0};
+
+ screen_init(&s, 2, 1);
+ fill(&s, rgb(0x40, 0x40, 0x40), rgb(0x20, 0x20, 0x20));
+ shader_make(&sh, "tint", red, 255);
+ sh.channels = SHADE_FG;
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 2, 1}, NULL, &base);
+ ok("channel fg tints the foreground",
+ ceq(screen_at(&s, 0, 0)->fg, 0xff, 0, 0), shown(screen_at(&s, 0, 0)->fg));
+ ok("...and puts the background back",
+ ceq(screen_at(&s, 0, 0)->bg, 0x20, 0x20, 0x20),
+ shown(screen_at(&s, 0, 0)->bg));
+
+ fill(&s, rgb(0x40, 0x40, 0x40), rgb(0x20, 0x20, 0x20));
+ sh.channels = SHADE_BG;
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 2, 1}, NULL, &base);
+ ok("channel bg tints the background",
+ ceq(screen_at(&s, 0, 0)->bg, 0xff, 0, 0), shown(screen_at(&s, 0, 0)->bg));
+ ok("...and leaves the foreground",
+ ceq(screen_at(&s, 0, 0)->fg, 0x40, 0x40, 0x40),
+ shown(screen_at(&s, 0, 0)->fg));
+
+ /* The reason the mask exists. A cell the terminal draws in its own default
+ * has no background of ours; mixing that towards a colour turns a
+ * recoloured glyph into a painted rectangle, and it must come back *unset*
+ * rather than as our idea of what default means. */
+ screen_free(&s);
+ screen_init(&s, 2, 1);
+ put(&s, 0, 0, rgb(0x40, 0x40, 0x40), (color_t){0});
+ put(&s, 1, 0, rgb(0x40, 0x40, 0x40), (color_t){0});
+ sh.channels = SHADE_FG;
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 2, 1}, NULL, &base);
+ ok("an unset background stays unset", !screen_at(&s, 0, 0)->bg.set,
+ shown(screen_at(&s, 0, 0)->bg));
+
+ /* Zero is both, so every shader made before the mask existed behaves as it
+ * did: the field is the pass's, and a shader that says nothing gets the
+ * old answer. */
+ fill(&s, rgb(0x40, 0x40, 0x40), rgb(0x20, 0x20, 0x20));
+ sh.channels = 0;
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 2, 1}, NULL, &base);
+ ok("no mask means both",
+ ceq(screen_at(&s, 0, 0)->fg, 0xff, 0, 0) &&
+ ceq(screen_at(&s, 0, 0)->bg, 0xff, 0, 0),
+ shown(screen_at(&s, 0, 0)->bg));
+ screen_free(&s);
+ }
+
/* ---- positional: vignette ---- */
{
screen_init(&s, 41, 21);
@@ -488,10 +592,10 @@ int main(void) {
shader_t sh;
shader_make(&sh, "dim", (color_t){0}, 255);
shade_ctx_t base = base_ctx();
- shade_apply(&s, &sh, 1, 0, 0, 0, 0, &base); /* empty rect */
- shade_apply(&s, &sh, 0, 0, 0, 4, 2, &base); /* no shaders */
- shade_apply(NULL, &sh, 1, 0, 0, 4, 2, &base); /* no screen */
- shade_apply(&s, &sh, 1, 100, 100, 4, 2, &base); /* rect off-screen */
+ shade_apply(&s, &sh, 1, (rect_t){0, 0, 0, 0}, NULL, &base); /* empty rect */
+ shade_apply(&s, &sh, 0, (rect_t){0, 0, 4, 2}, NULL, &base); /* no shaders */
+ shade_apply(NULL, &sh, 1, (rect_t){0, 0, 4, 2}, NULL, &base); /* no screen */
+ shade_apply(&s, &sh, 1, (rect_t){100, 100, 4, 2}, NULL, &base); /* rect off-screen */
ok("a degenerate pass changes nothing and does not crash",
ceq(screen_at(&s, 0, 0)->fg, 0x80, 0x80, 0x80),
shown(screen_at(&s, 0, 0)->fg));
diff --git a/tests/test_chrome_presets.py b/tests/test_chrome_presets.py
new file mode 100755
index 0000000..7af0fac
--- /dev/null
+++ b/tests/test_chrome_presets.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""Every chrome preset in contrib/chrome/ works in sl0ppty.
+
+The content presets are cross-checked against the toy that previews them
+(test_shadertoy.py). A frame has no preview — the toy draws a pane's contents —
+so these are checked the only way left that means anything: load the file into a
+real session and look at the cells. An example that reads beautifully and does
+nothing when you paste it is worse than no example.
+
+Two things are asserted of each, and the second matters as much as the first:
+the frame moves, and the pane's own output does not. A chrome pass that leaked
+into the contents would be a shader bug wearing a preset's clothes.
+"""
+import glob
+import os
+import sys
+import tempfile
+import time
+
+from harness import Session, check, report
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+FILES = sorted(glob.glob(os.path.join(HERE, "..", "contrib", "chrome", "*.kdl")))
+
+# Prints a screenful of known-coloured text, then waits.
+SH = ["/bin/sh", "-c",
+ 'printf "\\033]2;p\\007"; '
+ 'printf "\\033[38;2;0;255;0mBLOCK\\033[0m\\n"; stty raw -echo; cat']
+
+# Announcements stack up from the bottom right and are drawn over everything,
+# including a pane's bottom rule -- and this test reloads eight times. A toast
+# that outlived its reload would be measured as the frame having changed, so
+# they are told to expire immediately.
+THEME = ('theme { default_fg "#ffffff" default_bg "#000000" }\n'
+ 'toast_ms 1\n')
+
+
+def frame_cells(snap, pane):
+ """Every cell of the frame's outer ring, clockwise from the top-left."""
+ x0, y0, w, h = pane["x"], pane["y"], pane["w"], pane["h"]
+ out = []
+ for x in range(w):
+ out.append(snap.style_at(x0 + x, y0))
+ for y in range(1, h):
+ out.append(snap.style_at(x0 + w - 1, y0 + y))
+ for x in range(w - 2, -1, -1):
+ out.append(snap.style_at(x0 + x, y0 + h - 1))
+ for y in range(h - 2, 0, -1):
+ out.append(snap.style_at(x0, y0 + y))
+ return [(r or {}).get("fg") for r in out]
+
+
+def content_cells(snap, pane):
+ return [(snap.style_at(pane["content_x"] + c, pane["content_y"] + r) or {}).get("fg")
+ for r in range(pane["content_h"]) for c in range(pane["content_w"])]
+
+
+def test_every_chrome_preset_moves_the_frame():
+ check("contrib ships chrome presets", len(FILES) >= 5, str(len(FILES)))
+
+ cfg = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ cfg.write(THEME)
+ cfg.close()
+
+ with Session(SH, cols=54, rows=12, config=cfg.name) as s:
+ s.until_text("BLOCK")
+ base_snap = s.snapshot()
+ base_frame = frame_cells(base_snap, s.pane())
+ base_content = content_cells(base_snap, s.pane())
+
+ for path in FILES:
+ name = os.path.basename(path)
+ with open(cfg.name, "w") as f:
+ f.write(THEME + open(path).read())
+ s.api("reload")
+ s.settle(40) # past toast_ms, so no announcement is on the frame
+
+ # Sampled over time, because most of these are animated and a
+ # single frame can legitimately catch one between pulses.
+ seen = set()
+ content_moved = False
+ for _ in range(5):
+ snap = s.snapshot()
+ pane = s.pane()
+ seen.update(
+ i for i, (a, b) in enumerate(zip(base_frame, frame_cells(snap, pane)))
+ if a != b)
+ if content_cells(snap, pane) != base_content:
+ content_moved = True
+ time.sleep(0.05)
+
+ check(f"{name} colours the frame", len(seen) > 0, "no cell changed")
+ check(f"{name} leaves the contents alone", not content_moved, "content changed")
+
+ # ...and taking it out again puts the frame back, so one preset cannot
+ # leave a mark on the next.
+ with open(cfg.name, "w") as f:
+ f.write(THEME)
+ s.api("reload")
+ s.settle(40)
+ check("removing the last one restores the frame",
+ frame_cells(s.snapshot(), s.pane()) == base_frame, "still shaded")
+
+ os.unlink(cfg.name)
+
+
+if __name__ == "__main__":
+ test_every_chrome_preset_moves_the_frame()
+ sys.exit(report())
diff --git a/tests/test_chrome_shaders.py b/tests/test_chrome_shaders.py
new file mode 100755
index 0000000..2f46f2c
--- /dev/null
+++ b/tests/test_chrome_shaders.py
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+"""`where="chrome"`: the same colour passes, run over a pane's frame.
+
+A shader was always a pass over a pane's *contents*, and the frame was left
+alone by construction — it is painted before the pass and lies outside the
+content rect. Chrome shaders are the other half of that: the frame is a rect
+too, so it is a pass too, and the same expressions that dim contents can make
+a border say something. Nothing new in the language, one new word in the
+config.
+
+What is worth checking is the seam. The two passes must not reach into each
+other's cells; the frame pass has to see the *whole frame's* coordinates or an
+effect could not travel round one; and an animated pass has to keep the session
+painting, or a pulse freezes the moment you stop typing.
+"""
+import os
+import sys
+import tempfile
+import time
+
+from harness import Session, check, report
+
+# A pane that paints a known, non-default colour into its own content.
+GREEN = "#00ff00"
+FRAME = "#ff5fd7" # the default focused frame colour
+DIM_FRAME = "#45454a" # ...and the colour of a frame you are not in
+SH = ["/bin/sh", "-c",
+ 'printf "\\033]2;p\\007"; printf "\\033[38;2;0;255;0mBLOCK\\033[0m\\n"; '
+ 'stty raw -echo; cat']
+
+THEME = 'theme { default_fg "#ffffff" default_bg "#000000" }\n'
+
+
+def cfg(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(THEME + text)
+ f.close()
+ return f.name
+
+
+def fg_at(snap, x, y):
+ return (snap.style_at(x, y) or {}).get("fg")
+
+
+def frame_fg(snap, pane, dx=5, dy=0):
+ return fg_at(snap, pane["x"] + dx, pane["y"] + dy)
+
+
+def content_fg(snap, pane):
+ return fg_at(snap, pane["content_x"], pane["content_y"])
+
+
+def test_a_chrome_pass_colours_the_frame_and_nothing_else():
+ conf = cfg('shaders {\n tint color="#ff0000" amount=255 where="chrome"\n}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("the frame takes the chrome colour",
+ frame_fg(snap, p) == "#ff0000", str(frame_fg(snap, p)))
+ check("all four sides of it, not just the top",
+ fg_at(snap, p["x"], p["y"] + 3) == "#ff0000" and
+ fg_at(snap, p["x"] + p["w"] - 1, p["y"] + 3) == "#ff0000" and
+ fg_at(snap, p["x"] + 5, p["y"] + p["h"] - 1) == "#ff0000",
+ snap.screen())
+ check("and the pane's own output is untouched",
+ content_fg(snap, p) == GREEN, str(content_fg(snap, p)))
+
+
+def test_a_content_pass_still_leaves_the_frame_alone():
+ """The invariant the frame has always had, now that something else in the
+ same block can legitimately reach it."""
+ conf = cfg('shaders {\n tint color="#ff0000" amount=255\n}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("the contents are tinted", content_fg(snap, p) == "#ff0000",
+ str(content_fg(snap, p)))
+ check("the frame is not", frame_fg(snap, p) == FRAME,
+ str(frame_fg(snap, p)))
+
+
+def test_the_two_passes_can_run_together():
+ conf = cfg('shaders {\n'
+ ' tint color="#0000ff" amount=255\n'
+ ' tint color="#ff0000" amount=255 where="chrome"\n'
+ '}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("each pass owns its own cells",
+ content_fg(snap, p) == "#0000ff" and frame_fg(snap, p) == "#ff0000",
+ f"{content_fg(snap, p)} / {frame_fg(snap, p)}")
+
+
+def test_positions_are_the_whole_frames():
+ """`y == 0` is the frame's top row, not the top row of one of its sides:
+ an effect that travels round a border needs one coordinate space."""
+ conf = cfg('shaders {\n'
+ ' tint color="#00ff00" amount="(y == 0) * 255" where="chrome"\n'
+ '}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("the top row is the row the expression selected",
+ frame_fg(snap, p) == "#00ff00", str(frame_fg(snap, p)))
+ check("the sides and the bottom are not",
+ fg_at(snap, p["x"], p["y"] + 3) == FRAME and
+ fg_at(snap, p["x"] + 5, p["y"] + p["h"] - 1) == FRAME,
+ snap.screen())
+
+
+def test_a_state_can_carry_a_chrome_chain():
+ """The point of the feature: a border that says what a pane is doing."""
+ conf = cfg('states {\n'
+ ' unfocused { tint color="#ff0000" amount=255 where="chrome" }\n'
+ '}\n')
+ with Session(SH, cols=90, rows=14, config=conf) as s:
+ s.until_text("BLOCK")
+ s.key("\\\\")
+ s.settle()
+ snap = s.snapshot()
+ panes = s.panes()
+ focused = [p for p in panes if p["focused"]][0]
+ other = [p for p in panes if not p["focused"]][0]
+ check("the pane you are not in wears it",
+ frame_fg(snap, other) == "#ff0000", str(frame_fg(snap, other)))
+ check("the one you are in does not",
+ frame_fg(snap, focused) != "#ff0000", str(frame_fg(snap, focused)))
+
+ s.key("o") # focus the next pane: the states swap
+ s.settle()
+ snap = s.snapshot()
+ check("and it follows focus rather than sticking to a pane",
+ frame_fg(snap, focused) == "#ff0000" and
+ frame_fg(snap, other) != "#ff0000",
+ f"{frame_fg(snap, focused)} / {frame_fg(snap, other)}")
+
+
+def test_a_state_block_still_replaces_the_whole_state():
+ """A block holding only chrome passes is a state with no content pass, and
+ `unfocused` is the one where that is worth knowing: naming it takes the
+ dim_unfocused knob out of play, chrome chain or not. Documented in
+ config.kdl, and checked here so the documentation cannot drift from it."""
+ conf = cfg('states {\n'
+ ' unfocused { tint color="#ff0000" amount=255 where="chrome" }\n'
+ '}\n')
+ with Session(SH, cols=90, rows=14, config=conf) as s:
+ s.until_text("BLOCK")
+ s.key("\\\\")
+ s.settle()
+ snap = s.snapshot()
+ other = [p for p in s.panes() if not p["focused"]][0]
+ check("the unfocused pane's contents are left alone",
+ content_fg(snap, other) == GREEN, str(content_fg(snap, other)))
+
+
+def test_a_reload_puts_a_chrome_chain_on_and_takes_it_off_again():
+ """The way anyone will actually try this: save the config and look. Taking
+ it off again is the half worth testing — a chain that survived its own
+ removal would be a session you could not get back."""
+ path = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ path.write(THEME)
+ path.close()
+
+ def reload_with(s, text):
+ with open(path.name, "w") as f:
+ f.write(THEME + text)
+ s.api("reload")
+ s.settle(30)
+
+ with Session(SH, cols=60, rows=12, config=path.name) as s:
+ s.until_text("BLOCK")
+ check("the frame starts in its own colour",
+ frame_fg(s.snapshot(), s.pane()) == FRAME,
+ str(frame_fg(s.snapshot(), s.pane())))
+
+ reload_with(s, 'shaders {\n'
+ ' tint color="#ff0000" amount=255 where="chrome"\n'
+ '}\n')
+ check("saving a chrome pass colours the frame",
+ frame_fg(s.snapshot(), s.pane()) == "#ff0000",
+ str(frame_fg(s.snapshot(), s.pane())))
+
+ reload_with(s, "")
+ check("and taking it out of the file takes it off the frame",
+ frame_fg(s.snapshot(), s.pane()) == FRAME,
+ str(frame_fg(s.snapshot(), s.pane())))
+ os.unlink(path.name)
+
+
+def bg_at(snap, x, y):
+ """None when the cell has no background of ours — which is what a frame
+ cell normally is: the terminal draws it in whatever its default is."""
+ run = snap.style_at(x, y)
+ return run.get("bg") if run else None
+
+
+def test_a_pass_can_be_told_to_leave_the_background_alone():
+ """`tint` mixes both colours, and a frame's background is usually the
+ terminal's own default — so a border tint painted a dark rectangle behind
+ the glyphs and left it there. `channel="fg"` is the fix, and the background
+ has to come back *unset* rather than as our idea of what default means."""
+ both = cfg('shaders {\n'
+ ' tint color="#ff0000" amount=200 where="chrome"\n'
+ '}\n')
+ fg_only = cfg('shaders {\n'
+ ' tint color="#ff0000" amount=200 where="chrome" channel="fg"\n'
+ '}\n')
+ with Session(SH, cols=60, rows=12, config=both) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("without a channel the background is painted too",
+ bg_at(snap, p["x"] + 5, p["y"]) is not None,
+ str(bg_at(snap, p["x"] + 5, p["y"])))
+
+ with Session(SH, cols=60, rows=12, config=fg_only) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("channel fg still recolours the border glyphs",
+ frame_fg(snap, p) not in (FRAME, None), str(frame_fg(snap, p)))
+ check("and the frame keeps the terminal's own background",
+ bg_at(snap, p["x"] + 5, p["y"]) is None,
+ str(bg_at(snap, p["x"] + 5, p["y"])))
+ check("the contents are still untouched",
+ content_fg(snap, p) == GREEN, str(content_fg(snap, p)))
+
+
+def test_an_unknown_channel_is_refused_not_guessed():
+ conf = cfg('shaders {\n'
+ ' tint color="#ff0000" amount=255 where="chrome" channel="foreground"\n'
+ '}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("the session runs and nothing was shaded",
+ s.alive() and frame_fg(snap, p) == FRAME and
+ content_fg(snap, p) == GREEN,
+ f"{frame_fg(snap, p)} / {content_fg(snap, p)}")
+
+
+def ring(s, pane_id, back_to):
+ """Ring the bell in a pane while looking at another one, which is the only
+ case a bell is for: looking at a pane answers it."""
+ s.api("focus", id=pane_id)
+ s.settle(10)
+ s.raw(r"\x07")
+ s.settle(30)
+ s.api("focus", id=back_to)
+ s.settle(30)
+
+
+def test_a_bell_can_flash_the_border():
+ """The bell state plus `since`, which is the pair that makes a *flash*
+ expressible: `t` says what time it is, not how long ago the thing rang."""
+ # One node per line: a newline ends a KDL node, so a pass split over two
+ # lines is two broken nodes rather than one pass.
+ conf = cfg('anim_ms 20\n'
+ 'states {\n'
+ ' bell { tint where="chrome" color="#ff0000" amount="(since < 250) * 255" }\n'
+ '}\n')
+ with Session(SH, cols=90, rows=14, config=conf) as s:
+ s.until_text("BLOCK")
+ s.key("\\\\")
+ s.settle()
+ mine = [p for p in s.panes() if p["focused"]][0]
+ other = [p for p in s.panes() if not p["focused"]][0]
+
+ check("nothing flashes before anything rings",
+ frame_fg(s.snapshot(), other) == DIM_FRAME,
+ str(frame_fg(s.snapshot(), other)))
+
+ ring(s, other["id"], mine["id"])
+ check("the pane that rang flashes",
+ frame_fg(s.snapshot(), other) == "#ff0000",
+ str(frame_fg(s.snapshot(), other)))
+ check("and asks for the frames to animate it",
+ s.deadline() == 20, str(s.deadline()))
+ check("the pane you are in does not",
+ frame_fg(s.snapshot(), mine) == FRAME,
+ str(frame_fg(s.snapshot(), mine)))
+
+ # Real elapsed time: `since` is a clock, and the flash is defined as
+ # ending on its own.
+ time.sleep(0.3)
+ check("the flash ends by itself, without the bell being answered",
+ frame_fg(s.snapshot(), other) == DIM_FRAME,
+ str(frame_fg(s.snapshot(), other)))
+
+
+def test_the_bell_state_ends_when_you_look_at_the_pane():
+ """Answering a bell is looking at it, so the colour has to go then too —
+ a border still shouting about a bell you have read is worse than none."""
+ conf = cfg('states {\n'
+ ' bell { tint where="chrome" color="#ff0000" amount=255 }\n'
+ '}\n')
+ with Session(SH, cols=90, rows=14, config=conf) as s:
+ s.until_text("BLOCK")
+ s.key("\\\\")
+ s.settle()
+ mine = [p for p in s.panes() if p["focused"]][0]
+ other = [p for p in s.panes() if not p["focused"]][0]
+
+ ring(s, other["id"], mine["id"])
+ check("it holds while nobody has looked",
+ frame_fg(s.snapshot(), other) == "#ff0000",
+ str(frame_fg(s.snapshot(), other)))
+
+ s.api("focus", id=other["id"])
+ s.settle()
+ check("and stops the moment you do",
+ frame_fg(s.snapshot(), other) == FRAME,
+ str(frame_fg(s.snapshot(), other)))
+
+
+def test_a_rung_pane_is_rung_rather_than_merely_unfocused():
+ """States do not stack, and of the two things true of a pane that rang
+ while you were elsewhere, the ringing is the one you needed telling."""
+ conf = cfg('states {\n'
+ ' unfocused { dim where="chrome" amount=255 }\n'
+ ' bell { tint where="chrome" color="#ff0000" amount=255 }\n'
+ '}\n')
+ with Session(SH, cols=90, rows=14, config=conf) as s:
+ s.until_text("BLOCK")
+ s.key("\\\\")
+ s.settle()
+ mine = [p for p in s.panes() if p["focused"]][0]
+ other = [p for p in s.panes() if not p["focused"]][0]
+ check("an unfocused pane wears the unfocused chain",
+ frame_fg(s.snapshot(), other) == "#000000",
+ str(frame_fg(s.snapshot(), other)))
+
+ ring(s, other["id"], mine["id"])
+ check("and the bell chain replaces it once it rings",
+ frame_fg(s.snapshot(), other) == "#ff0000",
+ str(frame_fg(s.snapshot(), other)))
+
+
+def test_a_collapsed_pane_is_chrome_all_the_way_through():
+ """Once a tab flattens (D6) a pane *is* one row of frame, and that row is
+ the only thing saying the pane exists — so a chrome pass has to reach it."""
+ conf = cfg('shaders {\n tint color="#ff0000" amount=255 where="chrome"\n}\n')
+ with Session(SH, cols=60, rows=24, config=conf) as s:
+ s.until_text("BLOCK")
+ s.key("-")
+ s.key("-")
+ s.settle()
+ s.resize(60, 12) # too small for three panes: the tab becomes a list
+ s.settle()
+ rows = [p for p in s.panes() if p["h"] == 1]
+ check("the tab flattened", len(rows) >= 1, str(s.panes()))
+ snap = s.snapshot()
+ check("a collapsed row takes the chrome colour",
+ fg_at(snap, rows[0]["x"] + 2, rows[0]["y"]) == "#ff0000",
+ snap.screen())
+
+
+def test_an_unknown_where_is_refused_not_guessed():
+ """Running it over the contents because "chrom" was a typo would be a
+ surprise; the entry said where it wanted to be."""
+ conf = cfg('shaders {\n tint color="#ff0000" amount=255 where="chrom"\n}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ p, snap = s.pane(), s.snapshot()
+ check("the session still runs", s.alive())
+ check("and neither the frame nor the contents were shaded",
+ frame_fg(snap, p) == FRAME and content_fg(snap, p) == GREEN,
+ f"{frame_fg(snap, p)} / {content_fg(snap, p)}")
+
+
+def test_an_animated_chrome_pass_asks_for_a_frame_clock():
+ conf = cfg('anim_ms 40\n'
+ 'shaders {\n'
+ ' tint color="#00ff00" amount="((t / 50) % 2) * 255" where="chrome"\n'
+ '}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ s.snapshot()
+ check("the session wants repainting on the clock it was given",
+ s.deadline() == 40, str(s.deadline()))
+
+ # Real elapsed time: the thing under test is a clock, and the only
+ # honest way to ask whether a colour moves is to wait and look.
+ seen = set()
+ for _ in range(8):
+ time.sleep(0.03)
+ seen.add(frame_fg(s.snapshot(), s.pane()))
+ check("and the border actually moves", len(seen) > 1, str(seen))
+
+
+def test_a_still_shader_asks_for_nothing():
+ """The clock is opt-in by construction: it comes from the chain that ran,
+ so a session with no animated shader keeps its idle poll."""
+ conf = cfg('shaders {\n tint color="#ff0000" amount=255 where="chrome"\n}\n')
+ with Session(SH, cols=60, rows=12, config=conf) as s:
+ s.until_text("BLOCK")
+ s.snapshot()
+ check("nothing is due", s.deadline() == -1, str(s.deadline()))
+
+
+if __name__ == "__main__":
+ test_a_chrome_pass_colours_the_frame_and_nothing_else()
+ test_a_content_pass_still_leaves_the_frame_alone()
+ test_the_two_passes_can_run_together()
+ test_positions_are_the_whole_frames()
+ test_a_state_can_carry_a_chrome_chain()
+ test_a_state_block_still_replaces_the_whole_state()
+ test_a_reload_puts_a_chrome_chain_on_and_takes_it_off_again()
+ test_a_pass_can_be_told_to_leave_the_background_alone()
+ test_an_unknown_channel_is_refused_not_guessed()
+ test_a_bell_can_flash_the_border()
+ test_the_bell_state_ends_when_you_look_at_the_pane()
+ test_a_rung_pane_is_rung_rather_than_merely_unfocused()
+ test_a_collapsed_pane_is_chrome_all_the_way_through()
+ test_an_unknown_where_is_refused_not_guessed()
+ test_an_animated_chrome_pass_asks_for_a_frame_clock()
+ test_a_still_shader_asks_for_nothing()
+ sys.exit(report())
diff --git a/tests/test_dump_layout.py b/tests/test_dump_layout.py
index 07d972c..5c3a5f3 100644
--- a/tests/test_dump_layout.py
+++ b/tests/test_dump_layout.py
@@ -126,7 +126,7 @@ def test_the_active_tab_survives():
lay = write(LAYOUT)
with Session(SH, cols=90, rows=20, layout=lay) as s:
s.settle(60)
- s.key("n") # to the second tab
+ s.key(r"\t") # C-a tab: to the second tab
s.settle()
was = [t["index"] for t in s.tabs() if t["active"]]
dumped = s.api("dump-layout")["kdl"]
diff --git a/tests/test_equalize.py b/tests/test_equalize.py
new file mode 100755
index 0000000..675d468
--- /dev/null
+++ b/tests/test_equalize.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""`C-a =`: every visible pane gets an even share of the rows and columns.
+
+The interesting part is not the flat case -- three columns nudged apart and put
+back is arithmetic -- but what "even" means once the tree has depth. A split is
+divided between the *panes behind* each child, so one pane beside a stack of
+two is a third of the width and not half of it. Weighting per child would make
+"even" mean something different at every depth, which is the layout this action
+exists to get away from.
+"""
+import sys
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
+
+
+def by_pos(s):
+ """Panes on screen, left to right then top to bottom. A minimised pane gets
+ no rect at all — the strip draws it — so it is not one of these."""
+ return sorted((p for p in s.panes() if p["w"] > 1),
+ key=lambda p: (p["x"], p["y"]))
+
+
+def widths(s):
+ return [p["w"] for p in by_pos(s)]
+
+
+def test_a_nudged_row_of_columns_goes_back_to_even():
+ with Session(SH, cols=120, rows=24) as s:
+ s.settle()
+ s.key("\\\\")
+ s.key("\\\\")
+ s.settle()
+ even = widths(s)
+ check("three columns start out even", max(even) - min(even) <= 1,
+ str(even))
+
+ for _ in range(4):
+ s.key("L") # shift+l: move the boundary right
+ s.settle()
+ nudged = widths(s)
+ check("nudging the boundary makes them uneven",
+ max(nudged) - min(nudged) > 2, str(nudged))
+
+ s.key("=")
+ s.settle()
+ back = widths(s)
+ check("equalize puts them back to even",
+ max(back) - min(back) <= 1, str(back))
+ check("and it kept every pane", len(back) == 3, str(back))
+
+
+def test_it_divides_a_split_between_the_panes_behind_it():
+ """A pane beside a stack of two: a third of the width, not half of it."""
+ with Session(SH, cols=120, rows=30) as s:
+ s.settle()
+ s.key("\\\\") # left | right
+ s.key("-") # right becomes right-top / right-bottom
+ s.settle()
+ left, top, bottom = by_pos(s)
+ check("the split starts out halved per child",
+ abs(left["w"] - top["w"]) <= 1,
+ f"{left['w']} vs {top['w']}")
+
+ s.key("=")
+ s.settle()
+ left, top, bottom = by_pos(s)
+ check("the stack of two gets twice the lone pane's width",
+ abs(top["w"] - 2 * left["w"]) <= 2,
+ f"{left['w']} vs {top['w']}")
+ check("the two panes sharing that width still match",
+ top["w"] == bottom["w"], f"{top['w']} vs {bottom['w']}")
+ check("and they still split their column in half",
+ abs(top["h"] - bottom["h"]) <= 1,
+ f"{top['h']} vs {bottom['h']}")
+ check("the lone pane still spans the tab",
+ left["h"] > top["h"], f"{left['h']} vs {top['h']}")
+
+
+def test_a_minimised_pane_is_not_counted():
+ """It is out of the layout and costs a row in the strip, not a share: its
+ share would otherwise be handed to a pane nobody can see."""
+ with Session(SH, cols=120, rows=30) as s:
+ s.settle()
+ s.key("\\\\")
+ s.key("-")
+ s.settle()
+ top = by_pos(s)[1]
+ s.api("focus", id=top["id"])
+ s.key("m")
+ s.settle()
+ check("one pane is away in the strip", len(widths(s)) == 2,
+ str(widths(s)))
+
+ s.key("=")
+ s.settle()
+ seen = widths(s)
+ check("the two on screen share the tab evenly",
+ max(seen) - min(seen) <= 1, str(seen))
+
+
+def test_one_pane_has_nothing_to_even_out():
+ with Session(SH, cols=80, rows=24) as s:
+ s.settle()
+ before = s.pane()
+ s.key("=")
+ s.settle()
+ snap = s.snapshot()
+ check("it says so rather than doing nothing quietly",
+ "nothing to even out" in snap.screen(), repr(snap.screen()[-200:]))
+ after = s.pane()
+ check("and the pane is untouched",
+ (after["x"], after["y"], after["w"], after["h"]) ==
+ (before["x"], before["y"], before["w"], before["h"]),
+ str((before, after)))
+
+
+def test_it_is_in_the_palette_and_the_cheatsheet():
+ # Room for the whole sheet: it is sized from the screen, and a sheet
+ # clipped at the bottom would make this a test of the box.
+ with Session(SH, cols=110, rows=40) as s:
+ s.settle()
+ s.key("?")
+ s.settle()
+ sheet = s.snapshot().screen()
+ check("the cheatsheet lists it under size, with its key",
+ "= even out every split" in sheet, repr(sheet[:400]))
+ s.send("q") # any key closes the sheet
+
+ s.key("p")
+ s.send("equalize")
+ s.settle()
+ pal = s.snapshot()
+ check("the palette finds it by its config name",
+ "even out every split" in pal.screen(),
+ repr(pal.screen()[:400]))
+ check("with the key it would have skipped", "C-a =" in pal.screen(),
+ repr(pal.screen()[:400]))
+ check("and the row runs it",
+ any(h["action"].startswith("run:") for h in pal.hits),
+ str([h["action"] for h in pal.hits][:8]))
+
+
+if __name__ == "__main__":
+ test_a_nudged_row_of_columns_goes_back_to_even()
+ test_it_divides_a_split_between_the_panes_behind_it()
+ test_a_minimised_pane_is_not_counted()
+ test_one_pane_has_nothing_to_even_out()
+ test_it_is_in_the_palette_and_the_cheatsheet()
+ sys.exit(report())
diff --git a/tests/test_palette.py b/tests/test_palette.py
index 8549859..b2ddb0b 100644
--- a/tests/test_palette.py
+++ b/tests/test_palette.py
@@ -17,7 +17,7 @@ from harness import Session, check, report
SH = ["/bin/sh", "-c", 'printf "\\033]2;shell\\007"; stty raw -echo; cat']
-OPEN = r"\x01P" # the default bind: prefix, then shift+p
+OPEN = r"\x01p" # the default bind: prefix, then p
def cfg(text):
diff --git a/tests/test_rotate.py b/tests/test_rotate.py
new file mode 100755
index 0000000..c6b4a7f
--- /dev/null
+++ b/tests/test_rotate.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""`rotate-layout`: a quarter turn of the tab, clockwise.
+
+The whole feature is one asymmetry. Turn a *column* of panes clockwise and the
+one that was on top is now the one on the right, so a row split becomes a column
+split read backwards; turn a *row* of them and the leftmost becomes the top, so
+the order stands. Get that backwards and you have a mirror image, which looks
+almost right and is not a rotation — the tell is that four turns no longer come
+back to where you started, which is what most of this file checks.
+"""
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
+
+
+def rects(s):
+ """Every pane as (id, x, y, w, h), in reading order."""
+ return [(p["id"], p["x"], p["y"], p["w"], p["h"])
+ for p in sorted(s.panes(), key=lambda p: (p["y"], p["x"]))]
+
+
+def turn(s, n=1):
+ for _ in range(n):
+ s.key(r"\x20") # C-a space: the escape, so the driver's line keeps it
+ s.settle()
+
+
+def test_two_rows_become_two_columns_the_right_way_round():
+ with Session(SH, cols=80, rows=24) as s:
+ s.settle()
+ s.key("-") # rows: the first pane on top, the new one below
+ s.settle()
+ top, bottom = rects(s)
+ check("it starts as two rows", top[2] < bottom[2] and top[3] == bottom[3],
+ str(rects(s)))
+
+ turn(s)
+ left, right = sorted(rects(s), key=lambda r: r[1])
+ check("one turn makes two columns",
+ left[2] == right[2] and left[1] < right[1], str(rects(s)))
+ check("and the pane that was on top is now on the right",
+ right[0] == top[0], f"top was {top[0]}, right is {right[0]}")
+ check("the one that was underneath is on the left",
+ left[0] == bottom[0], f"bottom was {bottom[0]}, left is {left[0]}")
+
+ turn(s)
+ a, b = rects(s)
+ check("two turns are two rows again", a[3] == b[3] and a[2] < b[2],
+ str(rects(s)))
+ check("...with the panes the other way up than they started",
+ a[0] == bottom[0] and b[0] == top[0], str(rects(s)))
+
+
+def test_four_turns_are_the_identity():
+ """The property that makes this safe to press: it is a permutation of the
+ tree and nothing else, so pressing it enough times undoes it exactly."""
+ with Session(SH, cols=100, rows=30) as s:
+ s.settle()
+ s.key("\\\\") # a nest: one pane beside a stack of two
+ s.key("-")
+ s.settle()
+ start = rects(s)
+ check("three panes to turn", len(start) == 3, str(start))
+
+ seen = [start]
+ for i in range(1, 4):
+ turn(s)
+ seen.append(rects(s))
+ check(f"turn {i} changes the arrangement", seen[-1] != start,
+ str(seen[-1]))
+ turn(s)
+ check("the fourth turn comes back exactly", rects(s) == start,
+ f"{start} -> {rects(s)}")
+
+
+def test_a_share_of_one_axis_becomes_a_share_of_the_other():
+ with Session(SH, cols=100, rows=30) as s:
+ s.settle()
+ s.key("\\\\")
+ s.settle()
+ for _ in range(4):
+ s.key("L") # push the boundary right: the left pane grows
+ s.settle()
+ left, right = sorted(rects(s), key=lambda r: r[1])
+ check("the columns are uneven", left[3] > right[3] + 8,
+ f"{left[3]} vs {right[3]}")
+ ratio = left[3] / right[3]
+
+ turn(s)
+ top, bottom = sorted(rects(s), key=lambda r: r[2])
+ check("the wider pane is now the taller one",
+ top[0] == left[0] and top[4] > bottom[4],
+ f"{rects(s)} (left was {left[0]})")
+ check("and it kept roughly the share it had",
+ abs(top[4] / bottom[4] - ratio) < 0.35,
+ f"{ratio:.2f} across -> {top[4] / bottom[4]:.2f} down")
+
+
+def test_focus_stays_on_the_pane_you_were_in():
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle()
+ s.key("-")
+ s.settle()
+ was = s.focused()["id"]
+ turn(s, 3)
+ check("the pane you were in is the pane you are in",
+ s.focused()["id"] == was, f"{was} -> {s.focused()['id']}")
+
+
+def test_a_turn_that_would_not_fit_is_refused():
+ """A share of the height is not always a share the width can afford, and a
+ node that cannot give its children their floor takes the whole tab down to a
+ list (D6). An action a person just asked for must not be what does that."""
+ with Session(SH, cols=100, rows=30) as s:
+ s.settle()
+ s.key("-")
+ s.settle()
+ for _ in range(5):
+ s.key("K") # squeeze the top pane: fine as a row, too thin as a column
+ s.settle()
+ before = rects(s)
+
+ turn(s)
+ check("the layout is exactly as it was", rects(s) == before,
+ f"{before} -> {rects(s)}")
+ check("and it says why",
+ "no room to turn it" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[-200:]))
+
+ s.key("=") # even shares fit either way round
+ s.settle()
+ turn(s)
+ cols = sorted(rects(s), key=lambda r: r[1])
+ check("evening it out first lets the same turn through",
+ cols[0][2] == cols[1][2] and cols[0][1] < cols[1][1], str(rects(s)))
+
+
+def test_one_pane_has_nothing_to_turn():
+ with Session(SH, cols=80, rows=24) as s:
+ s.settle()
+ before = rects(s)
+ turn(s)
+ check("it says so", "nothing to turn" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[-200:]))
+ check("and the pane is untouched", rects(s) == before,
+ f"{before} -> {rects(s)}")
+
+
+def test_it_is_offered_by_name():
+ with Session(SH, cols=110, rows=40) as s:
+ s.settle()
+ s.key("?")
+ s.settle()
+ check("the cheatsheet lists it with its key",
+ "space turn the layout a quarter" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[:400]))
+ s.send("q")
+
+ s.key("p")
+ s.send("rotate")
+ s.settle()
+ snap = s.snapshot()
+ check("the palette finds it by its config name",
+ "turn the layout a quarter" in snap.screen(),
+ repr(snap.screen()[:400]))
+ check("and the row runs it",
+ any(h["action"].startswith("run:") for h in snap.hits),
+ str([h["action"] for h in snap.hits][:8]))
+
+
+def test_a_rotated_layout_survives_a_dump():
+ """The dump is the tree, so a turn has to come back as a turn."""
+ with Session(SH, cols=90, rows=24) as s:
+ s.settle()
+ s.key("-")
+ s.settle()
+ turn(s)
+ kdl = s.api("dump-layout")["kdl"]
+ check("it dumps as a column split", 'split="cols"' in kdl, kdl)
+ turned = rects(s)
+
+ lay = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ lay.write(kdl)
+ lay.close()
+ with Session(SH, cols=90, rows=24, layout=lay.name) as s2:
+ s2.settle(60)
+ check("and comes back in the same arrangement",
+ [r[1:] for r in rects(s2)] == [r[1:] for r in turned],
+ f"{turned} -> {rects(s2)}")
+
+
+if __name__ == "__main__":
+ test_two_rows_become_two_columns_the_right_way_round()
+ test_four_turns_are_the_identity()
+ test_a_share_of_one_axis_becomes_a_share_of_the_other()
+ test_focus_stays_on_the_pane_you_were_in()
+ test_a_turn_that_would_not_fit_is_refused()
+ test_one_pane_has_nothing_to_turn()
+ test_it_is_offered_by_name()
+ test_a_rotated_layout_survives_a_dump()
+ sys.exit(report())
diff --git a/tests/test_shadertoy.py b/tests/test_shadertoy.py
index 0b1dd49..6af076c 100644
--- a/tests/test_shadertoy.py
+++ b/tests/test_shadertoy.py
@@ -29,10 +29,10 @@ SCRIPT = open(HTML).read().split("<script>")[1].split("</script>")[0]
# Environments to evaluate at: corners, the middle, and a moved cursor.
ENVS = [
- dict(x=0, y=0, cols=80, rows=24, curx=0, cury=0, cursor=1, focused=1, t=0),
- dict(x=13, y=7, cols=80, rows=24, curx=40, cury=12, cursor=1, focused=1, t=1234),
- dict(x=79, y=23, cols=80, rows=24, curx=3, cury=21, cursor=0, focused=0, t=99999),
- dict(x=40, y=12, cols=100, rows=40, curx=99, cury=39, cursor=1, focused=1, t=7),
+ dict(x=0, y=0, cols=80, rows=24, curx=0, cury=0, cursor=1, focused=1, t=0, since=0),
+ dict(x=13, y=7, cols=80, rows=24, curx=40, cury=12, cursor=1, focused=1, t=1234, since=120),
+ dict(x=79, y=23, cols=80, rows=24, curx=3, cury=21, cursor=0, focused=0, t=99999, since=8000),
+ dict(x=40, y=12, cols=100, rows=40, curx=99, cury=39, cursor=1, focused=1, t=7, since=250),
]
def page_presets():
@@ -82,6 +82,9 @@ EXPRS = [
"focused ? x * 2 : y * 3",
"0 ? 1 / 0 : 7",
"t % 255",
+ "since",
+ "(since < 250) * 255", # a flash
+ "abs(since / 8 % 510 - 255)", # ...and a breathe
"(x + y) % 7 * 30",
"cols - x",
"rows * 2 - y",
@@ -91,7 +94,8 @@ EXPRS = [
def rand_exprs(n, seed=20260814):
"""A few generated ones too, so the checks are not only what I thought of."""
rng = random.Random(seed)
- atoms = ["x", "y", "cols", "rows", "curx", "cury", "t", "3", "7", "40", "255"]
+ atoms = ["x", "y", "cols", "rows", "curx", "cury", "t", "since", "3", "7",
+ "40", "255"]
out = []
for _ in range(n):
a, b, c = (rng.choice(atoms) for _ in range(3))
@@ -113,7 +117,7 @@ def c_values(pairs):
for env, expr in pairs:
vals = " ".join(str(env[k]) for k in
("x", "y", "cols", "rows", "curx", "cury", "cursor",
- "focused", "t"))
+ "focused", "t", "since"))
lines.append(f"{vals}\t{expr}")
out = subprocess.run([EVAL], input="\n".join(lines) + "\n",
capture_output=True, text=True)
@@ -190,7 +194,7 @@ def main():
# cell by cell -- sampling every other row made three `y % 2` effects look
# dead when they were fine.
grid = [dict(x=x, y=y, cols=64, rows=24, curx=30, cury=12, cursor=1,
- focused=1, t=t)
+ focused=1, t=t, since=t)
for t in (0, 500, 1500, 4000) for y in range(24) for x in range(64)]
flat = [(env, e) for _, _, e in presets for env in grid]
vals = c_values(flat)
diff --git a/tests/test_tabs.py b/tests/test_tabs.py
index 641853b..01bfce1 100644
--- a/tests/test_tabs.py
+++ b/tests/test_tabs.py
@@ -8,6 +8,8 @@ from harness import Session, check, report
SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
+NEXT, PREV = r"\t", r"\e[Z" # C-a tab / C-a shift+tab
+
def test_tabs():
with Session(SH, cols=60, rows=14) as s:
@@ -29,9 +31,9 @@ def test_tabs():
check("the tab strip is drawn", "1" in snap.line(1) and "2" in snap.line(1),
repr(snap.line(1)))
- s.key("p") # previous tab
+ s.key(PREV)
s.settle()
- check("C-a p goes back", s.tabs()[0]["active"])
+ check("C-a shift+tab goes back", s.tabs()[0]["active"])
check("the first tab kept its content", "in-tab-one" in s.snapshot().screen(),
repr(s.snapshot().screen()[:200]))
@@ -39,6 +41,22 @@ def test_tabs():
s.settle()
check("C-a 2 selects by number", s.tabs()[1]["active"])
+ # `n`/`p` used to cycle. `p` is the palette now -- pressed far more
+ # often than "the tab before this one" -- and `n` went with it rather
+ # than leaving half a pair behind.
+ active = [t["index"] for t in s.tabs() if t["active"]]
+ s.key("n")
+ s.settle()
+ check("C-a n no longer cycles tabs",
+ [t["index"] for t in s.tabs() if t["active"]] == active,
+ str(s.tabs()))
+ s.key("p")
+ s.settle()
+ check("nor does C-a p, which opens the palette",
+ [t["index"] for t in s.tabs() if t["active"]] == active,
+ str(s.tabs()))
+ s.send(r"\e") # put the palette away again
+
def test_background_tabs_keep_running():
with Session(SH, cols=60, rows=14) as s:
@@ -51,7 +69,7 @@ def test_background_tabs_keep_running():
s.settle()
s.raw("still-alive")
s.settle()
- s.key("n") # away again
+ s.key(NEXT) # away again
s.settle()
check("a background pane still accepts input",
any(p["tab"] == 1 for p in s.panes()), str(s.panes()))