diff options
| author | user <user@clank> | 2026-08-15 23:06:32 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 23:06:32 +0200 |
| commit | f6281fbec93d92bfc7e9164492e436d0236d9179 (patch) | |
| tree | c78b435dec7f62c948e2e8144f29f47ccab2fa7d /tests | |
| parent | sl0ppty: a command palette, on a key you choose (diff) | |
| download | slosh-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.c | 12 | ||||
| -rw-r--r-- | tests/expr_test.c | 9 | ||||
| -rw-r--r-- | tests/harness.py | 9 | ||||
| -rw-r--r-- | tests/shader_test.c | 130 | ||||
| -rwxr-xr-x | tests/test_chrome_presets.py | 109 | ||||
| -rwxr-xr-x | tests/test_chrome_shaders.py | 417 | ||||
| -rw-r--r-- | tests/test_dump_layout.py | 2 | ||||
| -rwxr-xr-x | tests/test_equalize.py | 151 | ||||
| -rw-r--r-- | tests/test_palette.py | 2 | ||||
| -rwxr-xr-x | tests/test_rotate.py | 204 | ||||
| -rw-r--r-- | tests/test_shadertoy.py | 18 | ||||
| -rw-r--r-- | tests/test_tabs.py | 24 |
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())) |
