aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--DESIGN.md34
-rw-r--r--README.md44
-rw-r--r--config/config.kdl125
-rw-r--r--contrib/chrome/README.md85
-rw-r--r--contrib/chrome/corner-glints.kdl18
-rw-r--r--contrib/chrome/gradient.kdl20
-rw-r--r--contrib/chrome/ripple.kdl19
-rw-r--r--contrib/chrome/shimmer.kdl19
-rw-r--r--contrib/chrome/shine.kdl19
-rw-r--r--contrib/chrome/spinner.kdl19
-rw-r--r--contrib/chrome/title-sheen.kdl18
-rw-r--r--contrib/shadertoy.html12
-rw-r--r--src/app.c314
-rw-r--r--src/app.h22
-rw-r--r--src/cmd.c13
-rw-r--r--src/config.c122
-rw-r--r--src/config.h26
-rw-r--r--src/expr.c12
-rw-r--r--src/expr.h17
-rw-r--r--src/shader.c49
-rw-r--r--src/shader.h15
-rw-r--r--src/shader_abi.h29
-rw-r--r--src/sl0ppty.h7
-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
35 files changed, 2043 insertions, 102 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 6752d53..bf5c88e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -155,13 +155,14 @@ unreadable.
| **D10** | **One attached client** in the MVP. The protocol allows N (read-only observers are what sl0p.foo actually wants); the multi-user *rendering* path is not built, because it is where the fork's phantom-client and `MY FOCUS AND:` bugs live. |
| **D11** | **No terminfo/ncurses.** We are opinionated about the outer terminal: emit a known-good modern subset, probe with DA/XTGETTCAP where we must. |
| **D12** | **Vendor lib-vt source**, pinned by commit in `vendor/libghostty-vt.vendor.json`, with a patches file if we ever need one. We needed one: `vendor/patches/0001` stops a screen clear from freeing transmitted kitty images, which broke every program that uploads once and places per frame (see D17). Patches are applied to the committed tree and listed in the vendor json, so a build has them and a re-vendor knows what to re-apply. zig is a build-time dependency only, and doubles as our C compiler and static/musl cross-linker. |
-| **D13** | **Shaders are a colour pass over pane *contents*, never our chrome.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so "contents, not chrome" is the paint order, not a rule anyone has to remember. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. **Which states ship a default is a line, not a mood**: a pane that is *not live* gets one — dead, suspended, scrolled, where the cells are showing something other than a running program's present and nothing else would tell you. `unfocused` is a taste rather than a fact and gets one anyway, through a knob of its own (`dim_unfocused`) rather than through the table: the objection to shipping a taste was never the taste, it was that turning it off should not require knowing the states block exists, and a single number fixes that. Writing the chain by hand still wins over the knob, including writing an empty one. `dragging` keeps no default, because the pane in your hand keeping its colour is what makes it lift off the page. The corollary is that a collapsed header, which the pass never reaches because a header is chrome, has to carry those same three facts in words. Fixed rather than configured, because it is a ranking by urgency and not a preference. They do not stack, because two reasons to be grey compound into one muddy grey that reads as neither. `dead` is one of them because a pane whose program exits is now kept until it is dismissed (D14); it was excluded while there was no frame it could ever draw. All of it is **derived every frame, never attached and remembered**: focus and drags move through too many paths to keep an attachment in sync with, and a pane left grey by the one path that forgot to clear it is a bug that only appears in front of someone. Same rule as the rect and the collapsed flag. |
+| **D13** | **Shaders are a colour pass over cells, and *which* cells is the pass's, not the shader's.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so a content pass covers the contents and nothing else by paint order, not by a rule anyone has to remember. Frames are shaded by a pass of their own over a rect of their own (D19); no shader knows the difference, which is the point. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. **Which states ship a default is a line, not a mood**: a pane that is *not live* gets one — dead, suspended, scrolled, where the cells are showing something other than a running program's present and nothing else would tell you. `unfocused` is a taste rather than a fact and gets one anyway, through a knob of its own (`dim_unfocused`) rather than through the table: the objection to shipping a taste was never the taste, it was that turning it off should not require knowing the states block exists, and a single number fixes that. Writing the chain by hand still wins over the knob, including writing an empty one. `dragging` keeps no default, because the pane in your hand keeping its colour is what makes it lift off the page. The corollary is that a collapsed header, which the pass never reaches because a header is chrome, has to carry those same three facts in words. Fixed rather than configured, because it is a ranking by urgency and not a preference. They do not stack, because two reasons to be grey compound into one muddy grey that reads as neither. `dead` is one of them because a pane whose program exits is now kept until it is dismissed (D14); it was excluded while there was no frame it could ever draw. All of it is **derived every frame, never attached and remembered**: focus and drags move through too many paths to keep an attachment in sync with, and a pane left grey by the one path that forgot to clear it is a bug that only appears in front of someone. Same rule as the rect and the collapsed flag. |
| **D14** | **A pane that was given a command outlives it.** When such a program exits, the pane stays: it keeps everything it printed, writes one line into its own backlog saying how it ended (`[process exited: status 3]`, `[process exited: signal 9]`), says the same thing in its frame and in the status line, and offers the only two verbs still meaningful — `[re-run]` and `[close]`. Re-running spawns the same argv in the same cwd, in the *same pane*: same id, same place in the tree, same terminal, so the run that ended stays above the new one in the scrollback and nothing that referred to that pane has to be told anything. The old behaviour reaped the pane before the next paint, which meant the last thing a failing command printed vanished together with the status that would have explained it, and a mistyped command in a fresh session closed the session. Which panes this applies to is the *command*, not the provenance: a pane told to run something is part of the session's shape and its death is news, while a shell you split off is finished when you type `exit` and a corpse to dismiss there is fussiness. A shell is a shell whether a layout made it or a keystroke did, which also means a session restored from `dump-layout` behaves like the one it was dumped from. `keep_dead` moves the line: `all` keeps shells too, `none` keeps nothing. Two consequences are load-bearing rather than incidental: the pty fd is **closed** the moment EOF is seen, because an EOF fd left in the poll set is readable forever and would spin a session at 100% CPU behind a pane that looks idle; and the exit status is collected with a **bounded** wait, never a blocking one, because a program that closes its terminal and keeps running must not be able to hang the session — losing that race costs the words "status 0" and nothing else. |
| **D15** | **Shaders may be loaded as native shared libraries**, from a directory beside the config. This is the one hole in D9 and it is argued for rather than drifted into (and D16 has since removed most of the *need* for it). A shader is called per cell, per pass, per frame — tens of thousands of times at up to 120Hz — so the subprocess model D9 prescribes cannot reach that path at all, and the measured alternative, interpreting a bytecode per cell, is 56ns against 8ns for the compiled function. What is loaded is also the narrowest possible thing: a pure function from one cell and its position to that cell's colours, unable to touch text, width, neighbours, panes or the session, because `shader_abi.h` is the only header it sees and nothing else is declared in it. On trust: the config file can already say `shell` and a layout can already say `command=`, so a file in the config directory can already run anything as you — a `.so` in the same directory is not a new boundary. It is refused unless it declares our ABI version *and* matching struct sizes, so a stale plugin fails at load instead of garbling colours. Nothing is ever unloaded: a `shade_fn` may be sitting in a live config or on a pane, so `dlclose()` would be a jump into an unmapped page — a reload therefore picks up newly *added* plugins only, and replacing one needs a new session. |
| **D16** | **A shader's `amount` may be an expression**, compiled to bytecode at config load. The scope is the argument: every built-in here is already `colour_op(amount)` with the amount a pure function of position, so the part worth handing to a config is the *number*, not the colour maths. That keeps mixing in C, gives an expression no way to produce an invalid cell (it produces one byte), and makes the result cacheable — a program that does not read the clock is evaluated once per cell into a map and reused until something it reads changes, so a config-written positional shader measures 5.2ns/cell against a compiled vignette's 6.1ns. Reading the clock costs 35ns/cell, per frame, which is the honest price of animation and is opt-in. The language is **total**: no loops, no recursion, no jumps — even `?:` evaluates both sides and selects, because nothing in it has an effect. So evaluation is O(program length), a config cannot spin, and there is nothing to sandbox; division by zero is 0 rather than a trap, because an expression that is wrong should look wrong rather than take the session with it. What a program reads is **derived by the compiler**, not declared, and that dependency set *is* the cache key — a stale map is unrepresentable rather than merely unlikely, which is the same rule the layout follows. An expression that does not compile drops that one shader with a warning: not the config, and not a half-strength version of what was asked for. |
| **D17** | **Images need pixels, and we were not carrying any.** Kitty graphics worked in the test suite and not in real life, because three separate things all quietly assumed a cell has no size. `pty_spawn`/`pty_resize` left `ws_xpixel`/`ws_ypixel` zero, so a program asking the tty how big a cell is got nothing and guessed. `ghostty_terminal_resize` was called with `0, 0` for the cell size, so lib-vt could not work out how many cells an image covers when the placement did not say — and a placement that covers zero cells is dropped, which is a picture that silently does not appear. And the vendored lib freed image *data* on `ED 2`, so a full-screen program lost everything it had uploaded. The fix runs the whole way through: the client reads its terminal's pixel size (`TIOCGWINSZ`, re-read on every resize because a window can move to another monitor), `MSG_HELLO`/`MSG_RESIZE` carry it, the app hands it to every pane in every tab, and each pane gives it to both its pty and its terminal. Where nobody knows, the default is **8x16 rather than 0**: a plausible cell means an image appears at roughly the right size, and zero means it does not appear at all. Tested end to end, including that a program can read real pixel dimensions out of its own pty. **Re-emission has to preserve what the program meant, not what it currently covers.** Two things fall out of that. A placement carries a sub-cell offset (`X=`/`Y=`, where the image starts *inside* its first cell); dropping it is invisible in a still picture and makes anything moving jump a whole cell at a time. And `c=`/`r=` mean *scale into this many cells*, so they are passed on only when the program asked for them — sending the count a natural-size image happens to cover makes it change size as it slides across a cell boundary, because that count goes up by one. Cropping follows the same split: a natural image is cropped by the screen pixels the pane has left (measured from the offset, not the cell edge), a scaled one proportionally, because a lost cell is worth `source/cells` pixels there. **And a program may prefer to *ask*.** `TIOCGWINSZ`'s pixel fields are the first way to find the cell size and XTWINOPS (`CSI 14/16/18 t`) is the second, which is what a program reaches for when the pty fields come back zeroed — as they do on the far side of an ssh hop. We answered none of it, so the query timed out and the program fell back to a guess; 10x20 against a real 9x22 cell is every image stretched by a fifth, and it looks like a bug in the program. `GHOSTTY_TERMINAL_OPT_SIZE` answers all three from the pane's own geometry (lib-vt encodes the reply). Mode 2048, the in-band report on resize, already worked: it is answered from the size handed to `ghostty_terminal_resize`. |
| **D18** | **We decode PNG ourselves, with the stb_image we already vendor.** lib-vt ships no image codec: built as a library its `sys.decode_png` hook is null, and a null hook rejects every `f=100` transmission outright — no image stored, no placement, nothing to re-emit. A program that uploads a PNG (which is what a program with a picture on disk does) drew nothing at all, silently, while raw RGB worked. Found because ida-tui's startup logo never appeared. PNG is the only format this can ever be about: the protocol has exactly three (`f=24`, `f=32`, `f=100`) and the first two are raw pixels lib-vt already handles, and `decode_png` is the only codec hook it has — so there is no scope to creep. Decoding only, never encoding; the client is handed RGBA. The decoder is `vendor/libghostty-vt/src/stb/stb_image.h`, which is **already in the tree and already pinned** by the vendor commit — upstream compiles that same header with the same `STBI_ONLY_PNG` for the same reason (`src/stb/stb.c` says so), it is simply not part of the `-Demit-lib-vt` build. So this is not a new dependency, it is a file we already ship finally being compiled, and `src/png.c` is a hook and a copy around it. Hand-rolling a subset decoder was considered and rejected: the bulk of a PNG decoder is a DEFLATE inflate, and "just the colour type ida-tui uses" breaks on the first palette PNG, which is what every png optimiser emits. That is a lot of new bounds-checked parsing, fed by whatever a pane decides to send, to replace a file already in the repo. Two consequences to know: we re-emit **decoded** pixels, so a 819KB logo goes to the client as 3.3MB of base64 (once per image, again after a reattach clears `sent`) — passing the original PNG through would be cheaper but lib-vt hands back only the decoded form; and lib-vt caps image storage at **10MB** in library builds, so images are bigger than they look. |
| **D19** | **The finder and the command palette are one picker with a subject.** They are the same object — a modal, a query, a filtered list, one selection, the same keys and the same mouse rules — differing only in what fills the rows and what Enter does with one. Building the second as its own machine would have meant two of everything, and the pair would have drifted the way the pane title editor and a tab label editor would have if they had not been made one thing with a subject. So there is one `picker` state (`PICK_FINDER`/`PICK_PALETTE`), one draw, one key handler, and a `pick_row_t` of three columns that both fill: a dim left (a tab, a group), the thing itself, and a dim right (a purpose, the chord). Choosing goes through the row's own action string, so the keyboard and the mouse cannot take different paths to the same row. **The palette lists actions that are not bound to anything**, which the cheatsheet by design cannot — it is built from the binds, so an action with no key does not appear, and that is exactly the action you have no other way to reach. Where a binding does exist the palette shows it, so using the palette teaches the key that would have skipped it; where several exist it shows them all, because a config's binds add to the defaults rather than replacing them and naming only the first would hide the one the user chose. Two actions are left out: `literal-prefix`, which means "type this key" rather than "do this thing" and would send the prefix to a pane hidden behind the list, and `select-tab-N`, which is positional — a row saying "go to that tab" cannot say which. Running is by `action_t`, not by synthesising the keystroke that would have caused it: a palette that had to invent input to do its job would be lying to the rest of the program about what happened. |
+| **D20** | **Chrome is a rect too, so it is a pass too.** `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 (D16), same chain order, one new word. It is one pass over the frame's *whole* rect with the contents punched out as a hole, not 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 is what keeps the two honest — a skipped cell is never visited, so a full-strength chrome tint cannot reach your text and a full-strength content tint cannot reach the border. The chains are stored separately rather than as a flag on each shader, because a pass *is* a chain and a rect, and because `shader_abi.h` has no business knowing where a session chose to run an effect. This 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. **Animation needs a clock**, so `app_next_deadline_ms` reports one while a chain that reads `t` ran over the last frame — derived from what was applied, like everything else about a frame, so a pulse hung off `dead` costs `anim_ms` while a pane is dead and nothing for the rest of the session. Without it a border froze the moment you stopped typing, which is the bug that makes an animated shader look broken rather than absent. A **`bell` pane state** and a **`since`** variable came with it, because "flash the border when a pane rings" needed both and neither existed: a bell had no state to hang a chain off, and `t` says what time it is rather than how long ago something happened, so a one-shot effect was not expressible at all. `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, but 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 (the frame clock stops with it). Shipped off by default — `bell_indicator`'s quiet mark stays the default, and an animated default would spend a frame clock on every session. **`channel="fg"|"bg"|"both"`** came from the first border flash actually being looked at: `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 by the shader: the cell's other colour is snapshotted before materialising and put back after, so it works for a built-in and a plugin alike, an unset colour goes back to *unset* rather than to our guess, and no shader grows an fg-only variant of itself. It is one byte tested once per pass, not per cell, and 0 means both so every shader built before it behaves as it did. |
### D6 — responsive layout as a pure function
@@ -265,7 +266,8 @@ Frame pacing: pty reads are coalesced and painted on a timerfd at a cap
- **M7** — layout files, suspended panes, `apply-layout` ✅ (what `sl0ppi up`
is ported onto)
- **M8** — pane sizes as weights: keyboard resize, drag the gap to move a
- boundary, drag a title to swap two panes ✅
+ boundary, drag a title to swap two panes, `equalize` to put every share back
+ to even, `rotate-layout` to turn the whole tab a quarter turn ✅
### The border is the button
@@ -309,6 +311,34 @@ resizing is not a special case of anything: the layout pass stays the same
pure function of the tree and the rect, and a resized layout survives a
collapse/expand cycle for free.
+**Rotating is a permutation of the tree** (`rotate-layout`, `C-a Space`): every split
+changes axis, and a row split's children reverse — turn a column of panes
+clockwise and the one that was on top is the one on the right, while a row's
+leftmost becomes its top. That asymmetry is the whole feature; getting it wrong
+gives a mirror image, which looks almost right until you notice four turns do
+not come back. They do come back, exactly, which is what makes it safe to press
+and why the inverse is three more turns rather than a second mirrored routine
+with its own chance of being wrong. 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
+(D6) — 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, for the same reason: an action a person just asked for should not
+be the thing that breaks the arrangement.
+
+**Equalising is one pass over the weights** (`equalize`, `C-a =`), because the
+weights are the whole state: no layout of its own, nothing remembered, and the
+next frame is the answer. Each split's children are weighted by *how many
+visible panes are behind each of them*, not given one weight each — one pane
+beside a stack of two is a third of the width, not half of it, or "even" would
+mean something different at every depth. Exactly equal cell counts are not
+something a tree can always express (a nested split pays for its own gaps out
+of its share, and a share that is not a whole number of cells lands where the
+layout's remainder loop puts it), so the action promises the even *division*
+the tree can express. Minimised panes are not counted: they are out of the
+layout and cost a row in the strip rather than a share, so counting one would
+hand its share to a pane nobody can see.
+
Every mouse verb starts from the hit list, so none of them can disagree with
what is on screen:
diff --git a/README.md b/README.md
index c530ece..5ed3ab2 100644
--- a/README.md
+++ b/README.md
@@ -48,10 +48,12 @@ connection goes away. Reattach with the same command.
| `C-a \` / `C-a -` | split into columns / rows |
| `C-a h j k l` or arrows | move focus |
| `C-a H J K L` or shift+arrows | move the boundary between panes |
+| `C-a =` | give every visible pane an even share again |
+| `C-a Space` | turn the whole layout a quarter turn (four brings it back) |
| `C-a z` / `C-a m` | zoom a pane to fill the tab / minimise it |
-| `C-a c` · `C-a n` `C-a p` · `C-a 1..9` | new tab · cycle · select |
+| `C-a c` · `C-a Tab` `C-a shift+Tab` · `C-a 1..9` | new tab · cycle · select |
| `C-a f` | find a pane by name |
-| `C-a shift+p` | the command palette: every action by name |
+| `C-a p` | the command palette: every action by name |
| `C-a PgUp` `C-a PgDn` `C-a Home` `C-a End` | scrollback (the wheel works too) |
| `C-a x` / `C-a r` | close a pane / re-run a finished one |
| `C-a d` / `C-a q` | detach / quit |
@@ -116,7 +118,7 @@ mistyped command in a fresh session no longer closes the session.
### The rest of it
-- **Run a command without knowing its key.** `C-a shift+p` opens a palette of
+- **Run a command without knowing its key.** `C-a p` opens a palette of
every action there is, by phrase ("split into columns") or by the name a
config file uses (`split-cols`). It shows the chord beside each one, so
using it teaches the key that would have skipped it — and it lists actions
@@ -187,7 +189,7 @@ shaders {
}
```
-Integer arithmetic over `x y cols rows curx cury focused t`, with `min max abs
+Integer arithmetic over `x y cols rows curx cury focused t since`, with `min max abs
clamp dist` and comparisons that give 0 or 1 — so `(x < 10) * 200` is how you
write a rule. Thirty-two ready-made ones are in
[`contrib/shaders`](contrib/shaders) — a cursor line, a crosshair, a torch, a
@@ -198,6 +200,40 @@ mixing in C and means the whole program can be worked out once into a per-cell
map and reused: a shader you wrote in your config costs about what a compiled
one does.
+**The frame is a rect too**, so the same passes can run over a pane's chrome
+instead of its contents — `where="chrome"` — and a border becomes somewhere to
+say what a pane is doing, for no room and no words:
+
+```kdl
+states {
+ // a dead pane's frame breathes red until you deal with it
+ dead { tint where="chrome" channel="fg" color="#ff0033" amount="abs((t / 8) % 510 - 255)" }
+ // a bell nobody has answered: a quarter-second flash, then gone
+ bell { tint where="chrome" channel="fg" color="#ffcc00" amount="(since < 250) * 255" }
+ // and the panes you are not in recede, frame and contents together
+ unfocused { dim amount=60; dim where="chrome" channel="fg" amount=90 }
+}
+```
+
+`x`/`y` there are the *whole frame's*, so an effect travels round a border
+rather than restarting at every corner, and the contents are cut out of the
+pass — a full-strength chrome tint cannot reach your text. `channel="fg"` keeps
+it a border rather than a slab: a frame's *background* is the terminal's own
+default, and mixing that towards a colour paints a rectangle behind the glyphs
+instead of colouring them. A pane that a small
+window has collapsed to a single row is chrome all the way through, which
+makes this the only kind of pass that can still colour it. Anything that reads
+`t` also asks the session to keep painting (`anim_ms`, 20fps by default, and
+only while such a shader is on screen), because a pulse that froze the moment
+you stopped typing would just look broken.
+
+On a frame, the coordinate you usually want is not `x`/`y` but **distance along
+the perimeter**, which the language can work out — and that is what makes a
+shine travel round a border, a ripple leave a corner in both directions, or an
+arc rotate. Seven of those are in [`contrib/chrome`](contrib/chrome): a shine, a
+shimmer, ripples, a spinner, a static gradient, corner glints, and a sheen
+across the title rule.
+
**You can add your own** without rebuilding sl0ppty: a shader is a C function
from one cell to that cell's colours, and any `*.so` in
`~/.config/sl0ppty/shaders/` that exports one is loaded at startup and named
diff --git a/config/config.kdl b/config/config.kdl
index d3a7dc3..11d671a 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -65,6 +65,11 @@ title_inset 2
// showed on screen would be useless in a background tab. Looking at the pane
// is what puts it out. Off makes a bell silent *and* invisible, which is a
// real choice rather than an oversight.
+//
+// The mark is the quiet default. A louder one is a shader rather than a knob:
+// `bell` is a pane state, so `states { bell { ... } }` colours or flashes a
+// rung pane's frame for as long as the bell is unanswered. See the chrome
+// passes further down.
// ── the frame's own buttons ─────────────────────────────────────────────────
//
// Minimise, zoom and close, in the top-right of every pane's frame: the mark
@@ -271,6 +276,7 @@ modal_scrim 120
// drop_target the others, all of them somewhere it could go
// dead its program exited; waiting to be re-run or closed
// suspended laid out, never started (its label is its contents)
+// bell a BEL arrived and nobody has looked since; ends when you do
// scrolled looking at scrollback rather than at the present
// unfocused the weakest thing that can be true of a pane
//
@@ -311,6 +317,7 @@ states {
suspended { grayscale amount=170; dim amount=60 }
scrolled { tint amount=22 color="#ff5fd7" } // = theme's scroll_bg
// dragging { }
+ // bell { } // see the chrome passes below for a border flash
// unfocused { dim amount=60 } // = dim_unfocused above; yours replaces it
}
@@ -337,6 +344,13 @@ states {
// margin amount at=100 everything from that column recedes
// spotlight amount radius=12 brightness falls away from the cursor
//
+// Two props are the *pass's* rather than the shader's, and work on any of them:
+//
+// where="content"|"chrome" the contents, or the frame (see further down)
+// channel="both"|"fg"|"bg" which of the cell's two colours it may keep;
+// `fg` recolours glyphs and leaves backgrounds
+// alone, which is what a border wants
+//
// `amount` can also be an *expression* instead of a number, evaluated for
// every cell. That is how you get an effect nobody built in, without building
// anything:
@@ -382,6 +396,90 @@ states {
// spotlight amount=90 radius=14
// }
+// ── chrome passes ───────────────────────────────────────────────────────────
+//
+// `where="chrome"` on any entry above runs that pass over a pane's *frame*
+// instead of its contents: the border, the padding inside it, the title and
+// the buttons. Everything else is identical -- same shaders, same `amount`,
+// same expressions -- because a frame is just another rect, so it is just
+// another pass. `where="content"` is the default and never has to be written.
+//
+// The two never touch each other's cells: the contents are cut out of the
+// frame pass, so a full-strength chrome tint leaves your text alone and a
+// full-strength content tint leaves the border alone.
+//
+// `channel="fg"` (or `"bg"`, or `"both"`, the default) says which of a cell's
+// two colours a pass may keep. On a frame you almost always want `fg`: a
+// border's *background* is the terminal's own default, and mixing that towards
+// a colour paints a rectangle behind the glyphs instead of colouring them --
+// and leaves it there until the pane changes state. `fg` also keeps the padding
+// inside the border out of it, since a blank cell has no foreground to colour,
+// so the effect lands on exactly the rules, the title and the buttons.
+//
+// `x`/`y` are the *whole frame's*, counted from its top-left corner, with
+// `cols`/`rows` the size of the pane's rect -- not of one side. That is what
+// lets an effect travel round a border rather than restart at every corner:
+//
+// shaders {
+// // the top rule only, where the title is
+// tint where="chrome" channel="fg" color="#ff5fd7" amount="(y==0)*255"
+// // a corner glow. One node per line: a newline ends a node, so a
+// // pass split over two lines is two broken nodes, not one pass.
+// tint where="chrome" channel="fg" color="#00ffd7" amount="255-dist(x,y,0,0)*20"
+// }
+//
+// For anything that *travels*, though, `x`/`y` are rarely what you want: it
+// wants distance *along the ring*, which the language can work
+// out -- clockwise from the top-left corner, in cells:
+//
+// (y == 0 ? x : x == cols - 1 ? cols - 1 + y
+// : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y)
+//
+// with the perimeter itself `2 * cols + 2 * rows - 4`. Two things fall out of
+// that: `min(d, P - d)` is distance the short way round, so an effect crosses
+// the corner it started from instead of stopping at a seam; and
+// `(p - head + P) % P` is "how far ahead of the head", which makes an arc
+// rotate rather than a blob slide. There is no way to name a subexpression, so
+// it has to be written out in full -- which is why a small effect is a long
+// line. contrib/chrome has seven of them ready to paste (a shine running round
+// the border, sparkles, ripples from a corner, a rotating arc, a static
+// gradient, corner glints, a sheen across the title rule).
+//
+// Hung off a state, this is a border that says what a pane is doing -- which
+// is what chrome is for, and it costs no room and no text:
+//
+// states {
+// // a dead pane's frame breathes red until you deal with it: a
+// // triangle wave, up and back down over about four seconds
+// dead { tint where="chrome" channel="fg" color="#ff0033" amount="abs(t/8%510-255)" }
+// // a bell nobody has answered yet: `since` is how long the pane has
+// // been in this state, so this is a real *flash* -- full strength for
+// // a quarter second and then gone, without the bell being answered.
+// // `t` could not say that: it is the time, not how long ago.
+// bell { tint where="chrome" channel="fg" color="#ffcc00" amount="(since<250)*255" }
+// // one that has not started yet is outlined, not filled
+// suspended { tint where="chrome" channel="fg" color="#8a8a95" amount=160 }
+// // the panes you are not in recede, frame and contents together.
+// // Both passes are written out because naming a state replaces the
+// // *whole* state: a block holding only chrome passes is a state with
+// // no content pass, which for `unfocused` means dim_unfocused above
+// // stops applying to it.
+// unfocused { dim amount=60; dim where="chrome" amount=90 }
+// }
+//
+// A pane a small window has collapsed to a single row (see min_pane) is chrome
+// all the way through, so a chrome pass is the only kind that can still colour
+// it -- which is the state where a colour is worth the most.
+//
+// Empty by default, like the content passes: yours to ask for.
+
+// How often to repaint while a shader is animated, in milliseconds -- which
+// means one whose `amount` reads `t`. Nothing else needs a frame clock: every
+// other repaint has an event behind it, and this one is only asked for while
+// such a shader is actually on screen. 0 turns it off, which leaves an
+// animated shader advancing only when something else causes a frame.
+anim_ms 50
+
// How long an announcement ("copied 13 chars", a pane's OSC 9 notification,
// "config reloaded") stays in the bottom-right corner.
toast_ms 2500
@@ -497,7 +595,8 @@ theme {
// pageup/pagedown/delete/insert/backslash/minus/slash/comma/period.
//
// Actions: split-cols split-rows close-pane focus-left focus-right focus-up
-// focus-down focus-next resize-left resize-right resize-up resize-down
+// focus-down focus-next resize-left resize-right resize-up resize-down equalize
+// rotate-layout
// scroll-up scroll-down scroll-page-up scroll-page-down scroll-top
// scroll-bottom zoom minimize rerun new-tab next-tab prev-tab select-tab-1..9 finder
// palette help detach quit
@@ -549,9 +648,27 @@ keys {
bind "shift+k" "resize-up"
bind "shift+j" "resize-down"
+ // Undo every drag and nudge at once: each split divided between the panes
+ // behind it, so every visible pane gets an even share of the rows and
+ // columns it competes for.
+ bind "=" "equalize"
+
+ // A quarter turn of the whole tab, 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. Four presses come back exactly, so
+ // there is nothing to undo -- which is what earns it the space bar, the
+ // easiest key there is and the one tmux spends on cycling layouts. Refused
+ // with a word when the turn would leave a pane under the floor: a share of
+ // the height is not always a share the width can afford, and `=` first
+ // usually fixes that.
+ bind "space" "rotate-layout"
+
bind "c" "new-tab"
- bind "n" "next-tab"
- bind "p" "prev-tab"
+ // Tab cycles tabs, the way every other tabbed thing does. `n` and `p`
+ // used to, and are free again: `p` is worth more as the palette, which is
+ // pressed rather more often than "the tab before this one".
+ bind "tab" "next-tab"
+ bind "shift+tab" "prev-tab"
bind "1" "select-tab-1"
bind "2" "select-tab-2"
bind "3" "select-tab-3"
@@ -560,7 +677,7 @@ keys {
// Every action by name, including the ones nothing is bound to -- which
// is the whole point of it, since an unbound action has no other way in.
- bind "shift+p" "palette"
+ bind "p" "palette"
// The cheatsheet, built from whatever is bound below rather than from a
// list of what the defaults were -- rebind something and it says so.
diff --git a/contrib/chrome/README.md b/contrib/chrome/README.md
new file mode 100644
index 0000000..12f275f
--- /dev/null
+++ b/contrib/chrome/README.md
@@ -0,0 +1,85 @@
+# chrome presets
+
+Colour passes over a pane's **frame** rather than its contents — `where="chrome"`
+— each in its own file, ready to paste into `~/.config/sl0ppty/config.kdl`.
+
+```sh
+cat contrib/chrome/shine.kdl >> ~/.config/sl0ppty/config.kdl # then save
+```
+
+Hand-written, unlike [`contrib/shaders`](../shaders), which is generated from
+`contrib/shadertoy.html`: the toy previews a pane's contents, and a frame is a
+different rect with no preview to give. So these are checked the only way that
+means anything — `tests/test_chrome_presets.py` loads every file here into a
+real session and asserts that the frame moves and the contents do not.
+
+| file | what it does |
+|---|---|
+| `shine.kdl` | a highlight running clockwise round the border, wrapping at the corner |
+| `shimmer.kdl` | sparkles drifting along it |
+| `ripple.kdl` | two pulses leaving the top-left corner and meeting at the far side |
+| `spinner.kdl` | a fixed arc rotating — work in progress, for a state |
+| `gradient.kdl` | not animated: lit from the top-left, so the frame reads as a surface |
+| `corner-glints.kdl` | the four corners breathing together |
+| `title-sheen.kdl` | the top rule only, where the title sits |
+
+## the perimeter trick
+
+On a frame, `x`/`y` are rarely what you want. An effect that travels round a
+border wants **distance along the ring**, and the language can work it out —
+clockwise from the top-left corner, in cells:
+
+```
+(y == 0 ? x
+ : x == cols-1 ? cols - 1 + y
+ : y == rows-1 ? 2 * cols + rows - 3 - x
+ : 2 * cols + 2 * rows - 4 - y)
+```
+
+The perimeter itself is `2 * cols + 2 * rows - 4`. Two things fall out of it:
+
+- **wrapping.** `min(d, P - d)` is distance the short way round, so an effect
+ crosses the corner it started from instead of stopping at a seam.
+- **direction.** `(p - head + P) % P` is "how far ahead of the head", which is
+ what makes an arc rotate rather than a blob slide.
+
+There is no way to name a subexpression, so the formula is written out in full
+in each file. That is the ugly part of these, and the reason the files are long
+when the effects are small.
+
+## two rules worth knowing
+
+**`channel="fg"`, always, on a frame.** A border's *background* is the
+terminal's own default; mixing that towards a colour paints a rectangle behind
+the glyphs instead of colouring them, and leaves it there. `fg` also keeps the
+padding inside the border out of it, since a blank cell has no foreground to
+colour — so the effect lands on exactly the rules, the title and the buttons.
+
+**Anything that reads `t` asks the session to keep painting** (`anim_ms`, 20fps
+by default) for as long as the pass is running. On a state that is the point: a
+pulse hung off `bell` or `dead` costs a frame clock while a pane is ringing or
+finished, and nothing for the rest of the session. Hung off every pane, as these
+files do for previewing, it costs that clock for the whole session — which is
+fine for a laptop and worth knowing about on a battery.
+
+`gradient.kdl` reads no clock at all: it is computed once into a per-cell map
+and reused, so it costs nothing per frame.
+
+## states
+
+Any of these is a state chain away from meaning something rather than just
+looking like something:
+
+```kdl
+states {
+ // the arc turns while a pane has not been started yet
+ suspended { tint where="chrome" channel="fg" color="#ffcc00" amount="…spinner…" }
+ // and a bell nobody has answered flashes, then settles
+ bell { tint where="chrome" channel="fg" color="#ffcc00" amount="(since < 250) * 255" }
+}
+```
+
+`since` is how long the pane has been in its current state, which is what makes
+a one-shot effect possible: `t` says what time it is, not how long ago something
+happened. The full list of states, and what each one means, is in
+`config/config.kdl`.
diff --git a/contrib/chrome/corner-glints.kdl b/contrib/chrome/corner-glints.kdl
new file mode 100644
index 0000000..799d58d
--- /dev/null
+++ b/contrib/chrome/corner-glints.kdl
@@ -0,0 +1,18 @@
+// corner glints — chrome
+//
+// Hand-written, unlike contrib/shaders: those are generated from
+// contrib/shadertoy.html, and the toy previews a pane's *contents*. A frame is
+// a different rect and the toy has no frame to show.
+//
+// Drop this into ~/.config/sl0ppty/config.kdl (a running session picks it up
+// when you save), or point $SL0PPTY_CONFIG at this file. To hang it off a pane
+// state instead of every pane, put the same pass inside one:
+//
+// states { bell { ... } }
+//
+// The four corners breathe together, brightest at the corner itself. `dist`
+// counts a row double, so the glint is round rather than an ellipse.
+
+shaders {
+ tint where="chrome" channel="fg" color="#ffffff" amount="clamp(220 - min(min(dist(x, y, 0, 0), dist(x, y, cols - 1, 0)), min(dist(x, y, 0, rows - 1), dist(x, y, cols - 1, rows - 1))) * 55, 0, 220) * (55 + abs(t / 3 % 90 - 45)) / 100"
+}
diff --git a/contrib/chrome/gradient.kdl b/contrib/chrome/gradient.kdl
new file mode 100644
index 0000000..16040b5
--- /dev/null
+++ b/contrib/chrome/gradient.kdl
@@ -0,0 +1,20 @@
+// gradient — chrome
+//
+// Hand-written, unlike contrib/shaders: those are generated from
+// contrib/shadertoy.html, and the toy previews a pane's *contents*. A frame is
+// a different rect and the toy has no frame to show.
+//
+// Drop this into ~/.config/sl0ppty/config.kdl (a running session picks it up
+// when you save), or point $SL0PPTY_CONFIG at this file. To hang it off a pane
+// state instead of every pane, put the same pass inside one:
+//
+// states { bell { ... } }
+//
+// Not animated: the border is simply brightest at the top-left corner and falls
+// away round both sides, so the frame reads as lit from somewhere rather than
+// drawn in one flat colour. Costs nothing per frame -- it does not read the
+// clock, so the whole pass is computed once into a map and reused.
+
+shaders {
+ tint where="chrome" channel="fg" color="#ffffff" amount="235 - min((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y), (2 * cols + 2 * rows - 4) - (y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y)) * 255 / (cols + rows - 2)"
+}
diff --git a/contrib/chrome/ripple.kdl b/contrib/chrome/ripple.kdl
new file mode 100644
index 0000000..527ca1e
--- /dev/null
+++ b/contrib/chrome/ripple.kdl
@@ -0,0 +1,19 @@
+// ripple — chrome
+//
+// Hand-written, unlike contrib/shaders: those are generated from
+// contrib/shadertoy.html, and the toy previews a pane's *contents*. A frame is
+// a different rect and the toy has no frame to show.
+//
+// Drop this into ~/.config/sl0ppty/config.kdl (a running session picks it up
+// when you save), or point $SL0PPTY_CONFIG at this file. To hang it off a pane
+// state instead of every pane, put the same pass inside one:
+//
+// states { bell { ... } }
+//
+// Two pulses leaving the top-left corner in opposite directions and meeting at
+// the far side. The radius sweeps 0..half the perimeter, which is as far as a
+// ring goes before the two halves meet.
+
+shaders {
+ tint where="chrome" channel="fg" color="#7aa2f7" amount="(abs(min((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y), (2 * cols + 2 * rows - 4) - (y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y)) - (t / 18 % (cols + rows - 2))) < 3) * 220"
+}
diff --git a/contrib/chrome/shimmer.kdl b/contrib/chrome/shimmer.kdl
new file mode 100644
index 0000000..1e9e972
--- /dev/null
+++ b/contrib/chrome/shimmer.kdl
@@ -0,0 +1,19 @@
+// shimmer — chrome
+//
+// Hand-written, unlike contrib/shaders: those are generated from
+// contrib/shadertoy.html, and the toy previews a pane's *contents*. A frame is
+// a different rect and the toy has no frame to show.
+//
+// Drop this into ~/.config/sl0ppty/config.kdl (a running session picks it up
+// when you save), or point $SL0PPTY_CONFIG at this file. To hang it off a pane
+// state instead of every pane, put the same pass inside one:
+//
+// states { bell { ... } }
+//
+// Sparkles drifting along the border. The `% 19 < 2` is a cheap pseudo-pattern
+// rather than a random number: the language has no rand, and a stride that does
+// not divide the perimeter never repeats where you can see it.
+
+shaders {
+ tint where="chrome" channel="fg" color="#ffffff" amount="(((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y) * 7 + t / 40) % 19 < 2) * 210"
+}
diff --git a/contrib/chrome/shine.kdl b/contrib/chrome/shine.kdl
new file mode 100644
index 0000000..335fb3d
--- /dev/null
+++ b/contrib/chrome/shine.kdl
@@ -0,0 +1,19 @@
+// shine — chrome
+//
+// Hand-written, unlike contrib/shaders: those are generated from
+// contrib/shadertoy.html, and the toy previews a pane's *contents*. A frame is
+// a different rect and the toy has no frame to show.
+//
+// Drop this into ~/.config/sl0ppty/config.kdl (a running session picks it up
+// when you save), or point $SL0PPTY_CONFIG at this file. To hang it off a pane
+// state instead of every pane, put the same pass inside one:
+//
+// states { bell { ... } }
+//
+// A highlight running clockwise round the border, wrapping at the corner it
+// started from. `min(d, P - d)` is what makes it wrap: distance the short way
+// round a ring, rather than along a line that stops.
+
+shaders {
+ tint where="chrome" channel="fg" color="#ffffff" amount="clamp(235 - min(abs((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y) - (t / 14 % (2 * cols + 2 * rows - 4))), (2 * cols + 2 * rows - 4) - abs((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y) - (t / 14 % (2 * cols + 2 * rows - 4)))) * 30, 0, 235)"
+}
diff --git a/contrib/chrome/spinner.kdl b/contrib/chrome/spinner.kdl
new file mode 100644
index 0000000..8f5d187
--- /dev/null
+++ b/contrib/chrome/spinner.kdl
@@ -0,0 +1,19 @@
+// spinner — chrome
+//
+// Hand-written, unlike contrib/shaders: those are generated from
+// contrib/shadertoy.html, and the toy previews a pane's *contents*. A frame is
+// a different rect and the toy has no frame to show.
+//
+// Drop this into ~/.config/sl0ppty/config.kdl (a running session picks it up
+// when you save), or point $SL0PPTY_CONFIG at this file. To hang it off a pane
+// state instead of every pane, put the same pass inside one:
+//
+// states { bell { ... } }
+//
+// A fixed arc rotating: fourteen cells of border, going round. Reads as work in
+// progress, so it belongs on a state -- `suspended` for a pane that has not
+// started, or `bell` for one that wants you.
+
+shaders {
+ tint where="chrome" channel="fg" color="#ffcc00" amount="(((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y) - (t / 14 % (2 * cols + 2 * rows - 4)) + (2 * cols + 2 * rows - 4)) % (2 * cols + 2 * rows - 4) < 14) * 200"
+}
diff --git a/contrib/chrome/title-sheen.kdl b/contrib/chrome/title-sheen.kdl
new file mode 100644
index 0000000..5eabd0f
--- /dev/null
+++ b/contrib/chrome/title-sheen.kdl
@@ -0,0 +1,18 @@
+// title sheen — chrome
+//
+// Hand-written, unlike contrib/shaders: those are generated from
+// contrib/shadertoy.html, and the toy previews a pane's *contents*. A frame is
+// a different rect and the toy has no frame to show.
+//
+// Drop this into ~/.config/sl0ppty/config.kdl (a running session picks it up
+// when you save), or point $SL0PPTY_CONFIG at this file. To hang it off a pane
+// state instead of every pane, put the same pass inside one:
+//
+// states { bell { ... } }
+//
+// The top rule only, where the title sits: a sheen crossing it left to right.
+// `y == 0` picks the rule out; the rest of the frame is left alone.
+
+shaders {
+ tint where="chrome" channel="fg" color="#ffffff" amount="(y == 0) * clamp(235 - abs(x - (t / 12 % cols)) * 40, 0, 235)"
+}
diff --git a/contrib/shadertoy.html b/contrib/shadertoy.html
index d796d12..f17a984 100644
--- a/contrib/shadertoy.html
+++ b/contrib/shadertoy.html
@@ -101,6 +101,8 @@
<code>cols rows</code> the size of the pane<br>
<code>curx cury</code> the cursor (follows your mouse), <code>cursor</code> 0/1<br>
<code>focused</code> 0/1 &nbsp; <code>t</code> milliseconds<br>
+ <code>since</code> ms in the pane's current state (<code>bell</code>,
+ <code>dead</code>…); here it restarts every 3s so a flash is visible<br>
<code>+ - * / %</code> integer only; division by zero is 0<br>
<code>&lt; &gt; &lt;= &gt;= == != &amp;&amp; || !</code> give 0 or 1, so
<code>(x &lt; 10) * 200</code> is a rule<br>
@@ -138,6 +140,7 @@ const VARS = {
cursor: { dep: DEP.CURSOR },
focused: { dep: DEP.FOCUS },
t: { dep: DEP.TIME },
+ since: { dep: DEP.TIME },
};
const FNS = { min: 2, max: 2, abs: 1, clamp: 3, dist: 4 };
@@ -537,8 +540,15 @@ function draw(now) {
ctx.font = `${CH - 5}px ui-monospace, monospace`;
ctx.textBaseline = "top";
+ /* `since` is how long the pane has been in its current state. This preview
+ has no pane and no states, so it is simulated: the clock restarts every
+ three seconds, as if something rang again — which is the only way a
+ one-shot effect (a bell flash) is visible in a preview at all. Said out
+ loud in the legend, because a preview that quietly means something else
+ than the session does is worse than no preview. */
const env = { cols: COLS, rows: ROWS, curx: cursor.x, cury: cursor.y,
- cursor: 1, focused: 1, t: Math.floor(now), x: 0, y: 0 };
+ cursor: 1, focused: 1, t: Math.floor(now),
+ since: Math.floor(now) % 3000, x: 0, y: 0 };
for (let y = 0; y < ROWS; y++) {
const [colour, text] = SAMPLE[y] || ["", ""];
diff --git a/src/app.c b/src/app.c
index 846d005..48ec42d 100644
--- a/src/app.c
+++ b/src/app.c
@@ -12,6 +12,7 @@
#include <stdlib.h>
#include <string.h>
+#include "expr.h"
#include "json.h"
#include "version.h"
#include "graphics.h"
@@ -141,6 +142,16 @@ struct node {
char purpose[64];
bool purpose_locked; /* declared by a layout: in-band cannot override */
+ /* When this pane last became what it is, for `since` (D20). A *transition*
+ * cannot be derived from the frame in front of you — the frame only says
+ * what is true now — so this is remembered, and it is remembered in the one
+ * place that decides the state, which is what keeps the timestamp and the
+ * state it belongs to from disagreeing. `last_state` is the state it was in
+ * when `state_since` was stamped; PSTATE_COUNT is "in none of them", which
+ * is a state to have been in for a while like any other. */
+ pane_state_t last_state;
+ int64_t state_since;
+
/* split */
split_dir_t dir;
node_t **kids;
@@ -296,11 +307,20 @@ struct app {
* socket under that name; the app only knows it because it is worth saying
* out loud when several are running. */
char session[64];
+ /* The last layout pass made this tab a list rather than a layout (D6). Same
+ * rule as `animating`: derived every pass, never remembered across one. */
+ bool flattened;
/* the screen we last composed into: its hit list is what a click resolves
* against, so routing can never consult geometry the user never saw */
corner_t corners[16];
size_t ncorners;
const screen_t *painted;
+ /* Something on the frame we last composed is animated: a shader whose amount
+ * reads the clock ran over it. Derived every frame from the chains actually
+ * applied, like the rect and the collapsed flag and for the same reason —
+ * remembering "this session animates" would keep a clock running for a pulse
+ * that was hung off a state no pane is in any more. */
+ bool animating;
};
static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; }
@@ -386,10 +406,13 @@ static void toasts_expire(app_t *a) {
a->ntoasts = keep;
}
-/* When something needs repainting on its own: a toast expiring, or a hover
- * guide arming. Without the second one the guide would appear on the next
- * event rather than when the dwell is up, which for a resting pointer means
- * "never". */
+/* When something needs repainting on its own: a toast expiring, a hover guide
+ * arming, or an animated shader — the three things on screen that change
+ * without an event behind them. Without the second one the guide would appear
+ * on the next event rather than when the dwell is up, which for a resting
+ * pointer means "never"; without the third a pulse would only advance when
+ * something else happened to cause a frame, which for an idle pane means a
+ * border frozen mid-pulse. */
int app_next_deadline_ms(app_t *a) {
int64_t soonest = -1;
for (size_t i = 0; i < a->ntoasts; i++)
@@ -409,6 +432,16 @@ int app_next_deadline_ms(app_t *a) {
}
}
+ /* An animated shader ran over the last frame, so the next thing that changes
+ * is the clock. Bounded by a config knob rather than painting flat out: this
+ * is a terminal, and 20fps of one pane's border is not worth a core. The
+ * flag comes from the frame we composed, so the cost lands only while
+ * something animated is actually on screen. */
+ if (a->animating && CFG.anim_ms) {
+ int64_t due = now_ms_() + CFG.anim_ms;
+ if (soonest < 0 || due < soonest) soonest = due;
+ }
+
if (soonest < 0) return -1;
int64_t in = soonest - now_ms_();
return in <= 0 ? 0 : (int)in;
@@ -1187,6 +1220,11 @@ static size_t tab_of(app_t *a, node_t *n);
static void layout(app_t *a) {
if (!a->ntabs) return;
+ /* Re-derived below, like everything else about a layout pass: whether this
+ * tab is a layout or a list (D6). Recorded because a tree edit needs to be
+ * able to ask what its edit did — a turn that flattens the tab is a turn
+ * nobody asked for. */
+ a->flattened = false;
uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap;
uint16_t top = (uint16_t)(gy + STRIP_ROWS);
rect_t r = {.x = gx,
@@ -1263,6 +1301,7 @@ static void layout(app_t *a) {
collapse_leaf(mins[i], (rect_t){r.x, r.y, 0, 0});
return;
}
+ a->flattened = true;
cur(a)->min_bar = (rect_t){0, 0, 0, 0}; /* a list has no separate bar */
if (r.h >= (uint16_t)(count_leaves(root) + 2)) {
/* One row per pane, plus a body worth opening into: a frame is two rows. */
@@ -2060,6 +2099,139 @@ static void resize_focus(app_t *a, int dx, int dy) {
layout(a);
}
+/* ---- equalising ---------------------------------------------------------
+ *
+ * Every visible pane an even share of the rows and columns it competes for.
+ *
+ * A split's children are weighted by *how many visible panes are behind each
+ * of them*, not given a weight each: one pane beside a column of three is a
+ * quarter of the width, not half of it. Weighting per child instead would make
+ * "even" mean something different at every depth — the pane on its own would
+ * get as much room as the three sharing the other side, which is the layout a
+ * person asks for this action to get *away* from.
+ *
+ * Exactly equal cell counts are not something a tree can always express: a
+ * nested split pays for its own gaps out of its share, and a share that is not
+ * a whole number of cells lands where the layout's remainder loop puts it. So
+ * this is the even division the tree *can* express, which is why the action is
+ * "give every pane an even share" and not "make every pane the same size".
+ *
+ * Minimised panes are not counted. They are out of the layout entirely and
+ * cost a row in the strip rather than a share of the tree, so counting one
+ * would hand its share to a pane nobody can see. Their own weight is still
+ * evened, because that is the share they come back to.
+ *
+ * Returns the number of visible panes under `n`. */
+static size_t equalize_node(node_t *n) {
+ if (n->kind == NODE_LEAF) {
+ n->weight = WEIGHT_UNIT;
+ return n->minimized ? 0 : 1;
+ }
+ size_t vis = 0;
+ for (size_t i = 0; i < n->nkids; i++) vis += equalize_node(n->kids[i]);
+ /* A subtree with nothing visible in it is not laid out at all, so its weight
+ * is never divided by — but it must not be zero either, or restoring the
+ * pane inside it would give it no share to come back to. */
+ n->weight = (int)(WEIGHT_UNIT * (vis ? vis : 1));
+ return vis;
+}
+
+/* The current tab, like zooming and minimising: this is about the arrangement
+ * you are looking at. False when there are no boundaries to move, so the
+ * caller can say so rather than leaving a keystroke looking broken. */
+bool app_equalize_splits(app_t *a) {
+ node_t *root = cur(a)->root;
+ if (!root || root->kind == NODE_LEAF) return false;
+ equalize_node(root);
+ layout(a);
+ return true;
+}
+
+/* ---- rotating -----------------------------------------------------------
+ *
+ * A quarter turn clockwise, of the whole tab.
+ *
+ * Every split changes axis, and one of the two directions also reverses its
+ * children — because that is what a quarter turn does to a stack. 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. Getting that asymmetry
+ * wrong gives you a mirror image, which looks almost right and is not a
+ * rotation: four turns would not come back.
+ *
+ * Four turns *do* come back, exactly, which is what makes this safe to press:
+ * it is a permutation of the tree and nothing else. Weights travel with the
+ * children they belong to, so a pane that had two thirds of the width has two
+ * thirds of the height afterwards.
+ *
+ * Depth first, so a subtree is turned before the split above it reorders it —
+ * the two operations are independent, but doing the children first keeps the
+ * reversal reading as one thing rather than as something that has to be
+ * reasoned about twice. */
+static void rotate_node(node_t *n) {
+ if (!n || n->kind == NODE_LEAF) return;
+ for (size_t i = 0; i < n->nkids; i++) rotate_node(n->kids[i]);
+
+ if (n->dir == SPLIT_ROWS) {
+ for (size_t i = 0, j = n->nkids ? n->nkids - 1 : 0; i < j; i++, j--) {
+ node_t *t = n->kids[i];
+ n->kids[i] = n->kids[j];
+ n->kids[j] = t;
+ }
+ n->dir = SPLIT_COLS;
+ } else {
+ n->dir = SPLIT_ROWS;
+ }
+}
+
+/* The current tab, like zooming and equalising. False when there is nothing to
+ * turn — one pane looks the same from every angle — and false when the turn
+ * would not fit, which is the interesting case.
+ *
+ * A pane's share of one axis is not a share the other axis can always afford:
+ * two rows split 19:6 are fine as rows and put the smaller one under the column
+ * floor as columns, and a node that cannot give its children their floor takes
+ * the *whole tab* down to a list (D6). So the turn is tried, the layout is asked
+ * what it made of it, and a turn that flattened the tab is put back — the same
+ * rule `split_fits` applies to splitting, and for the same reason: an action a
+ * person just asked for should not be the thing that breaks the arrangement.
+ *
+ * Put back by turning three more times rather than by a second, mirrored
+ * rotation. Four turns are the identity, so this *is* the inverse, and the
+ * asymmetry between the two directions — the part that is easy to get wrong —
+ * stays written down exactly once.
+ *
+ * A tab that was *already* a list is turned anyway: there is nothing to break,
+ * and refusing would mean a window too small to lay out could never be turned
+ * back into one that is. */
+bool app_rotate_layout(app_t *a) {
+ node_t *root = cur(a)->root;
+ if (!root || root->kind == NODE_LEAF) return false;
+
+ bool was_flat = a->flattened;
+ rotate_node(root);
+ layout(a);
+ if (a->flattened && !was_flat) {
+ rotate_node(root);
+ rotate_node(root);
+ rotate_node(root);
+ layout(a);
+ return false;
+ }
+ return true;
+}
+
+/* Turning because a person just asked for it, which is the only case that needs
+ * telling why nothing happened. The two refusals are different facts and get
+ * different words: one pane has no arrangement to turn, and a turn that would
+ * flatten the tab was put back. Same split of responsibility as
+ * `split_focus_ui`: the tree edit is quiet and the UI explains itself. */
+static void rotate_layout_ui(app_t *a) {
+ bool one_pane = cur(a)->root && cur(a)->root->kind == NODE_LEAF;
+ if (!app_rotate_layout(a))
+ app_toast(a, one_pane ? "nothing to turn" : "no room to turn it");
+}
+
/* Reordering is a swap of two leaves, in place: their positions, weights and
* parents trade, and everything else about them is untouched. */
static bool swap_panes(app_t *a, uint32_t id_a, uint32_t id_b) {
@@ -2503,6 +2675,12 @@ struct draw {
screen_t *s;
};
+/* Defined with the other shader passes below, because it is one. A collapsed
+ * row needs it here: the row is chrome, and it is the only thing a flattened
+ * tab draws of a pane. */
+static void shade_chrome(app_t *a, screen_t *s, node_t *n, rect_t r,
+ const rect_t *hole);
+
/* A collapsed subtree: one row, the title of the pane it stands for, and a hit
* that focuses it — which expands it on the next layout pass, because the
* expanded child is simply the one holding focus. */
@@ -2519,11 +2697,13 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) {
* gone or was never started.
*
* This row is the only thing a flattened tab draws of a pane, and the
- * shader pass deliberately never reaches it: shaders colour *contents*, and
- * a header is chrome (D13). So the states that get a colour everywhere else
- * have to be carried by the words here, in the same order the status line
- * ranks them. Without this, collapsing a tab hides exactly the facts the
- * colour exists to show. */
+ * *content* shader pass deliberately never reaches it: those colour
+ * contents, and a header is chrome (D13). So the states that get a colour
+ * everywhere else have to be carried by the words here, in the same order
+ * the status line ranks them. Without this, collapsing a tab hides exactly
+ * the facts the colour exists to show. A `where="chrome"` chain does reach
+ * this row — it is a frame — but that is a thing you may have asked for,
+ * not something the words can rely on. */
bool dead = !pane_alive(leaf->pane) && !pane_suspended(leaf->pane);
char words[64];
if (dead) {
@@ -2586,6 +2766,12 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) {
char action[48];
snprintf(action, sizeof action, "focus:%u", leaf->id);
hit_add(&s->hits, r.x, r.y, r.w, 1, action);
+
+ /* Last, so the pass sees the finished row: rule, bell, title and all. The
+ * state comes from the leaf this row stands for, not from the node that
+ * happens to own the rect — a collapsed subtree is drawn as its first pane
+ * and should be coloured as that pane. */
+ shade_chrome(a, s, leaf, r, NULL);
}
/* ---- shaders ------------------------------------------------------------ */
@@ -2626,7 +2812,7 @@ size_t app_shade_count(app_t *a, uint32_t pane_id) {
* should not be recoloured by anything, a mode the whole screen is in outranks
* a hint about one pane, and "not focused" is the weakest thing that can be
* true of a pane. */
-static pane_state_t pane_state(app_t *a, node_t *n) {
+static pane_state_t pane_state_of(app_t *a, node_t *n) {
/* Only once the pointer has actually moved: a press that turns out to be a
* click would otherwise flash the whole session on its way to nothing. */
if (a->drag.kind == DRAG_TITLE && a->drag.moved) {
@@ -2636,11 +2822,31 @@ static pane_state_t pane_state(app_t *a, node_t *n) {
}
if (!pane_alive(n->pane) && !pane_suspended(n->pane)) return PSTATE_DEAD;
if (pane_suspended(n->pane)) return PSTATE_SUSPENDED;
+ /* A rung pane outranks a scrolled or unfocused one, and *replaces* it rather
+ * than stacking with it: states do not stack (D13), and of the things true
+ * of a pane that rang while you were elsewhere, the ringing is the one you
+ * needed telling. The focused pane never reaches here with a bell — looking
+ * at it answers the bell before anything is drawn. */
+ if (pane_bell(n->pane)) return PSTATE_BELL;
if (pane_scrolled(n->pane)) return PSTATE_SCROLLED;
if (n != cur(a)->focus) return PSTATE_UNFOCUSED;
return PSTATE_COUNT;
}
+/* ...and when it last became that, which is the part a frame cannot tell you.
+ * Stamped here, in the one place that decides the state, so `since` cannot end
+ * up describing a state the pane is no longer in. Idempotent within a frame:
+ * the content pass and the chrome pass both ask, and the second sees no
+ * change. */
+static pane_state_t pane_state(app_t *a, node_t *n) {
+ pane_state_t st = pane_state_of(a, n);
+ if (st != n->last_state || !n->state_since) {
+ n->last_state = st;
+ n->state_since = now_ms_();
+ }
+ return st;
+}
+
static size_t policy_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) {
pane_state_t st = pane_state(a, n);
if (st >= PSTATE_COUNT) return 0;
@@ -2650,6 +2856,30 @@ static size_t policy_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) {
return k;
}
+static size_t chrome_policy_shaders(app_t *a, node_t *n, shader_t *out,
+ size_t cap) {
+ pane_state_t st = pane_state(a, n);
+ if (st >= PSTATE_COUNT) return 0;
+ size_t k = CFG.chrome_state_n[st];
+ if (k > cap) k = cap;
+ for (size_t i = 0; i < k; i++) out[i] = CFG.chrome_state_shaders[st][i];
+ return k;
+}
+
+/* Does anything in this chain read the clock? If so the session has to keep
+ * painting on its own, because the thing that will change next is the time.
+ * Asked of the chain that was actually applied rather than of the config: a
+ * pulse hung off `dead` costs a frame clock while a pane is dead and nothing
+ * for the rest of the session. */
+static void note_animation(app_t *a, const shader_t *chain, size_t n) {
+ for (size_t i = 0; i < n; i++)
+ if (chain[i].amount_expr &&
+ (expr_deps(chain[i].amount_expr) & EXPR_DEP_TIME)) {
+ a->animating = true;
+ return;
+ }
+}
+
/* A pane with nothing to apply costs nothing: no context is built and no cell
* is visited, so an unshaded session emits the same bytes it did before. */
static void shade_leaf(app_t *a, screen_t *s, node_t *n) {
@@ -2668,8 +2898,11 @@ static void shade_leaf(app_t *a, screen_t *s, node_t *n) {
if (!nc) return;
bool focused = n == cur(a)->focus;
+ /* After policy_shaders, which is what stamps the transition. */
+ int64_t now = now_ms_();
shade_ctx_t base = {
- .now_ms = now_ms_(),
+ .now_ms = now,
+ .state_ms = n->state_since ? now - n->state_since : 0,
.focused = focused,
.default_fg = CFG.default_fg,
.default_bg = CFG.default_bg,
@@ -2683,8 +2916,46 @@ static void shade_leaf(app_t *a, screen_t *s, node_t *n) {
base.cursor_x = (uint16_t)(s->cursor_x - n->content.x);
base.cursor_y = (uint16_t)(s->cursor_y - n->content.y);
}
- shade_apply(s, chain, nc, n->content.x, n->content.y, n->content.w,
- n->content.h, &base);
+ note_animation(a, chain, nc);
+ shade_apply(s, chain, nc, n->content, NULL, &base);
+}
+
+/* Chrome: a pass over `r`, skipping `hole` (the contents, when the thing being
+ * drawn has any). One pass over the whole frame rather than one per side, so
+ * that an effect can travel round it — four passes would each count from zero
+ * and a sweep would restart at every corner.
+ *
+ * The rect is passed in rather than taken from the node because the two things
+ * that wear chrome are not the same shape: an open pane is its rect minus its
+ * contents, and a collapsed pane is one row that is chrome all the way through
+ * — its `content` is the size the program inside still believes it has, which
+ * is somewhere else entirely, and punching that out of the row would remove
+ * cells the row does not even overlap. `n` is only asked which state it is in,
+ * so a collapsed row is coloured by the pane it stands for.
+ *
+ * No cursor. The cursor is in the contents by construction, so a frame pass
+ * carrying one would hand an effect a position outside its own rect; `cursor`
+ * reads 0 here, which is the truth about a frame. */
+static void shade_chrome(app_t *a, screen_t *s, node_t *n, rect_t r,
+ const rect_t *hole) {
+ shader_t chain[SHADE_CHAIN_MAX];
+ size_t nc = 0;
+
+ for (size_t i = 0; i < CFG.nchrome_shaders && nc < SHADE_CHAIN_MAX; i++)
+ chain[nc++] = CFG.chrome_shaders[i];
+ nc += chrome_policy_shaders(a, n, &chain[nc], SHADE_CHAIN_MAX - nc);
+ if (!nc) return;
+
+ int64_t now = now_ms_();
+ shade_ctx_t base = {
+ .now_ms = now,
+ .state_ms = n->state_since ? now - n->state_since : 0,
+ .focused = n == cur(a)->focus,
+ .default_fg = CFG.default_fg,
+ .default_bg = CFG.default_bg,
+ };
+ note_animation(a, chain, nc);
+ shade_apply(s, chain, nc, r, hole, &base);
}
/* Drawn after the panes, so the corner's hit is registered after the two gap
@@ -2761,8 +3032,15 @@ static void draw_cb(node_t *n, void *ud) {
}
/* Between the contents and the chrome that goes over them: the frame was
* painted before this and lies outside the content rect, and the split guide
- * is painted after, so it stays legible on top of a shaded pane. */
+ * is painted after, so it stays legible on top of a shaded pane.
+ *
+ * The chrome pass runs here too, and for the same reason: the frame it
+ * recolours is already painted, and the guide, the resize hints and the
+ * corners are painted after it and stay in their own colours. An affordance
+ * is not decoration and must not be dimmed along with the thing it is
+ * offered on. */
shade_leaf(d->a, d->s, n);
+ shade_chrome(d->a, d->s, n, n->rect, &n->content);
draw_split_guide(d->a, d->s, n);
}
@@ -3287,7 +3565,7 @@ static void draw_scrim(app_t *a, screen_t *s) {
.default_fg = CFG.default_fg,
.default_bg = CFG.default_bg,
};
- shade_apply(s, &dim, 1, 0, 0, s->cols, s->rows, &base);
+ shade_apply(s, &dim, 1, (rect_t){0, 0, s->cols, s->rows}, NULL, &base);
}
/* The frame every modal wears: an opaque box with a pane's corners, a title
@@ -3909,6 +4187,8 @@ void app_compose(app_t *a, screen_t *s) {
hit_reset(&s->hits);
s->cursor_visible = false;
a->painted = s;
+ /* Re-derived by the shader passes below, every frame, from what they ran. */
+ a->animating = false;
if (!a->ntabs || !cur(a)->root) return;
/* Looking at a pane is the acknowledgement, and it happens before anything
@@ -4385,6 +4665,10 @@ static bool run_action(app_t *a, action_t act) {
case ACT_RESIZE_RIGHT: resize_focus(a, 1, 0); return true;
case ACT_RESIZE_UP: resize_focus(a, 0, -1); return true;
case ACT_RESIZE_DOWN: resize_focus(a, 0, 1); return true;
+ case ACT_EQUALIZE:
+ if (!app_equalize_splits(a)) app_toast(a, "nothing to even out");
+ return true;
+ case ACT_ROTATE_LAYOUT: rotate_layout_ui(a); return true;
case ACT_SCROLL_UP: pane_scroll(cur(a)->focus->pane, -CFG.scroll_lines); return true;
case ACT_SCROLL_DOWN: pane_scroll(cur(a)->focus->pane, CFG.scroll_lines); return true;
case ACT_SCROLL_PAGE_UP:
diff --git a/src/app.h b/src/app.h
index 0455732..e9740ed 100644
--- a/src/app.h
+++ b/src/app.h
@@ -8,10 +8,6 @@
#include "shader.h"
#include "sl0ppty.h"
-typedef struct {
- uint16_t x, y, w, h;
-} rect_t;
-
typedef enum { SPLIT_COLS, SPLIT_ROWS } split_dir_t;
typedef struct node node_t;
@@ -30,8 +26,10 @@ char *app_graphics_json(app_t *a);
/* Transient announcements, drawn bottom-right and expiring on their own. */
void app_toast(app_t *a, const char *text);
size_t app_toast_count(app_t *a);
-/* Milliseconds until something needs repainting on its own (a toast expiring),
- * or -1 when nothing does. */
+/* Milliseconds until something needs repainting on its own — a toast expiring,
+ * a hover guide arming, or a shader whose amount reads the clock having run
+ * over the frame just composed — or -1 when nothing does. A property of that
+ * frame, so it is asked after composing one. */
int app_next_deadline_ms(app_t *a);
/* Text the session has copied, and the copy the client has not been told
@@ -107,6 +105,12 @@ bool app_minimize(app_t *a, uint32_t id);
bool app_toggle_zoom(app_t *a, uint32_t id);
bool app_pane_zoomed(app_t *a, uint32_t id);
+/* Give every visible pane in the current tab an even share of the rows and
+ * columns it competes for: each split's children weighted by how many visible
+ * panes are behind them, so "even" means the same thing at every depth. False
+ * when the tab is a single pane and there is nothing to divide. */
+bool app_equalize_splits(app_t *a);
+
/* Address panes by id (the control API), not "the focused one". */
bool app_focus_pane(app_t *a, uint32_t id);
bool app_split_pane(app_t *a, uint32_t id, bool rows);
@@ -114,6 +118,12 @@ bool app_close_pane(app_t *a, uint32_t id);
/* Run a dead (or not-yet-started) pane's command again, in the same pane and
* on top of the same scrollback. 0 means the focused one. */
bool app_rerun_pane(app_t *a, uint32_t id);
+
+/* Turn the current tab's layout a quarter turn clockwise: every split changes
+ * axis, and the children of a row split reverse, because the pane on top of a
+ * stack is the pane on the right once you turn it. Four turns are the identity.
+ * False when the tab is a single pane, which looks the same from every angle. */
+bool app_rotate_layout(app_t *a);
/* Open the config file in $EDITOR, in a pane of its own, so the edit/save/
* watch loop happens without leaving the session. The pane is ephemeral. */
bool app_edit_config(app_t *a);
diff --git a/src/cmd.c b/src/cmd.c
index e08b4f2..758586b 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -110,6 +110,14 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
}
return jok_raw("screen", screen_dump_json(s));
}
+ if (strcmp(cmd, "deadline") == 0) {
+ /* When this session wants its next frame, in milliseconds, or -1 for "only
+ * when something happens". A property of the frame last composed -- an
+ * animated shader asks for a clock, a toast asks to expire -- so a caller
+ * that has not composed one is asking about nothing. The server's poll loop
+ * lives on this answer; a scripted front end has the same question. */
+ return jok_int("ms", app_next_deadline_ms(a));
+ }
if (strcmp(cmd, "send") == 0) {
const char *data = jv_gets(req, "data", "");
input_feed(in, (const uint8_t *)data, strlen(data), feed_event, a);
@@ -334,6 +342,11 @@ char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
app_compose(a, s);
return strcmp(arg, "text") == 0 ? screen_dump(s) : screen_dump_json(s);
}
+ if (strcmp(verb, "deadline") == 0) {
+ char buf[24];
+ snprintf(buf, sizeof buf, "%d", app_next_deadline_ms(a));
+ return strdup(buf);
+ }
if (strcmp(verb, "panes") == 0) {
app_compose(a, s); /* layout is a function of the frame: compose first */
return app_panes_json(a);
diff --git a/src/config.c b/src/config.c
index cac6b72..3a27152 100644
--- a/src/config.c
+++ b/src/config.c
@@ -23,6 +23,8 @@ static const struct {
{"focus-down", ACT_FOCUS_DOWN}, {"focus-next", ACT_FOCUS_NEXT},
{"resize-left", ACT_RESIZE_LEFT}, {"resize-right", ACT_RESIZE_RIGHT},
{"resize-up", ACT_RESIZE_UP}, {"resize-down", ACT_RESIZE_DOWN},
+ {"equalize", ACT_EQUALIZE},
+ {"rotate-layout", ACT_ROTATE_LAYOUT},
{"scroll-up", ACT_SCROLL_UP}, {"scroll-down", ACT_SCROLL_DOWN},
{"scroll-page-up", ACT_SCROLL_PAGE_UP},
{"scroll-page-down", ACT_SCROLL_PAGE_DOWN},
@@ -115,22 +117,61 @@ static bool parse_color(const char *text, color_t *out) {
static const char *const PSTATE_NAMES[PSTATE_COUNT] = {
"dragging", "drop_hover", "drop_target", "dead", "suspended",
- "scrolled", "unfocused",
+ "bell", "scrolled", "unfocused",
};
const char *pane_state_name(pane_state_t s) {
return s < PSTATE_COUNT ? PSTATE_NAMES[s] : "";
}
-/* Reads the children of `node` as a shader chain into `out` (SHADE_MAX slots),
- * returning how many were understood. */
-static size_t parse_shader_list(config_t *c, const kdl_node_t *node,
- shader_t *out, char *err, size_t errcap) {
- size_t n = 0;
- for (size_t i = 0; i < node->nkids && n < SHADE_MAX; i++) {
+/* Reads the children of `node` as shader chains: the ones that run over a
+ * pane's contents and the ones that run over its frame, routed by each entry's
+ * `where` (default "content"). One parser, because a chrome pass is not a
+ * different kind of shader — it is the same chain run over a different rect,
+ * and a second parser would be a second place for `amount` to mean something
+ * slightly else. Counts are set, not added to: naming a block replaces it. */
+static void parse_shader_list(config_t *c, const kdl_node_t *node,
+ shader_t *content, size_t *ncontent,
+ shader_t *chrome, size_t *nchrome, char *err,
+ size_t errcap) {
+ *ncontent = 0;
+ *nchrome = 0;
+ for (size_t i = 0; i < node->nkids; i++) {
const kdl_node_t *k = node->kids[i];
if (!k || !k->name) continue;
+ /* Where this pass runs. Dropped rather than defaulted when the word is not
+ * one we know, for the same reason a bad `amount` is: the entry says what
+ * it wants and we cannot do it, and running it over the contents because
+ * "chrom" was a typo would be a surprise nobody asked for. */
+ const char *where = kdl_prop(k, "where", "content");
+ bool on_chrome = strcmp(where, "chrome") == 0;
+ if (!on_chrome && strcmp(where, "content") != 0) {
+ if (err && !err[0])
+ snprintf(err, errcap, "bad where for %s: %s (content or chrome)",
+ k->name, where);
+ continue;
+ }
+ shader_t *out = on_chrome ? chrome : content;
+ size_t *n = on_chrome ? nchrome : ncontent;
+ if (*n >= SHADE_MAX) continue;
+
+ /* Which of the cell's two colours it may touch. `fg` is what a *border*
+ * flash wants: a frame's background is usually the terminal's own default,
+ * and mixing that towards a colour turns a recoloured glyph into a painted
+ * rectangle. Refused the same way a bad `where` is, and for the same
+ * reason. */
+ const char *chan = kdl_prop(k, "channel", "both");
+ uint8_t channels = SHADE_BOTH;
+ if (strcmp(chan, "fg") == 0) channels = SHADE_FG;
+ else if (strcmp(chan, "bg") == 0) channels = SHADE_BG;
+ else if (strcmp(chan, "both") != 0) {
+ if (err && !err[0])
+ snprintf(err, errcap, "bad channel for %s: %s (fg, bg or both)",
+ k->name, chan);
+ continue;
+ }
+
/* `amount` is a number, or an expression that produces one per cell.
* Same key either way: `amount=90` and `amount="(y % 2) * 40"` are the
* same idea, one of them constant, and the compiler folds a constant
@@ -174,20 +215,20 @@ static size_t parse_shader_list(config_t *c, const kdl_node_t *node,
if (cs && !parse_color(cs, &col) && err && !err[0])
snprintf(err, errcap, "bad colour for shader %s: %s", k->name, cs);
- if (!shader_make_p(&out[n], k->name, col, (uint8_t)amount,
- (uint16_t)param)) {
+ shader_t *slot = &out[*n];
+ if (!shader_make_p(slot, k->name, col, (uint8_t)amount, (uint16_t)param)) {
if (err && !err[0]) snprintf(err, errcap, "unknown shader: %s", k->name);
expr_free(aexpr);
continue;
}
- out[n].amount_expr = aexpr;
+ slot->channels = channels;
+ slot->amount_expr = aexpr;
if (aexpr) {
c->exprs = realloc(c->exprs, (c->nexprs + 1) * sizeof *c->exprs);
c->exprs[c->nexprs++] = aexpr; /* the config owns every program */
}
- n++;
+ (*n)++;
}
- return n;
}
/* `direct` is part of the identity, not a property of it: `x` after the leader
@@ -326,6 +367,10 @@ void config_defaults(config_t *c) {
c->toast_ms = 2500;
c->hover_delay_ms = 250;
c->double_click_ms = 400;
+ /* 20fps. Fast enough that a pulse or a sweep reads as movement, slow enough
+ * that an idle session with an animated shader is not a busy loop -- and it
+ * only costs anything at all while such a shader is actually on screen. */
+ c->anim_ms = 50;
c->modal_scrim = 120;
c->status_bar = true;
c->status_line = true;
@@ -491,11 +536,22 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, MOD_SHIFT, ACT_RESIZE_RIGHT, false);
bind_add(c, GHOSTTY_KEY_ARROW_UP, MOD_SHIFT, ACT_RESIZE_UP, false);
bind_add(c, GHOSTTY_KEY_ARROW_DOWN, MOD_SHIFT, ACT_RESIZE_DOWN, false);
+ bind_add(c, GHOSTTY_KEY_EQUAL, 0, ACT_EQUALIZE, false);
+ /* The leader and the space bar: the biggest key on the keyboard, no modifier,
+ * and the one tmux already spends on cycling layouts — so the hand that knows
+ * that reaches for the right thing here. Four presses come back round, which
+ * is what makes a key this easy to hit the right choice rather than a hazard. */
+ bind_add(c, GHOSTTY_KEY_SPACE, 0, ACT_ROTATE_LAYOUT, false);
bind_add(c, GHOSTTY_KEY_C, 0, ACT_NEW_TAB, false);
- bind_add(c, GHOSTTY_KEY_N, 0, ACT_NEXT_TAB, false);
- bind_add(c, GHOSTTY_KEY_P, 0, ACT_PREV_TAB, false);
+ /* Cycling tabs is on tab/shift+tab, not on n/p: `p` is the palette, which is
+ * pressed far more often than "the tab before this one" and had the only
+ * shifted letter in the defaults. Tab is the key every other tabbed thing
+ * cycles with, and it decodes on a plain terminal (`\e[Z` is shift+tab), so
+ * this costs nothing on a client without the kitty protocol. */
+ bind_add(c, GHOSTTY_KEY_TAB, 0, ACT_NEXT_TAB, false);
+ bind_add(c, GHOSTTY_KEY_TAB, MOD_SHIFT, ACT_PREV_TAB, false);
bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER, false);
- bind_add(c, GHOSTTY_KEY_P, MOD_SHIFT, ACT_PALETTE, false);
+ bind_add(c, GHOSTTY_KEY_P, 0, ACT_PALETTE, false);
bind_add(c, GHOSTTY_KEY_PAGE_UP, 0, ACT_SCROLL_PAGE_UP, false);
bind_add(c, GHOSTTY_KEY_PAGE_DOWN, 0, ACT_SCROLL_PAGE_DOWN, false);
bind_add(c, GHOSTTY_KEY_HOME, 0, ACT_SCROLL_TOP, false);
@@ -546,6 +602,10 @@ static const struct {
{ACT_RESIZE_RIGHT, "size", "move the boundary right"},
{ACT_RESIZE_UP, "size", "move the boundary up"},
{ACT_RESIZE_DOWN, "size", "move the boundary down"},
+ /* Short enough to fit the palette's label column, which truncates at 26 —
+ * "give every pane an even share" read as "give every pane an even sh". */
+ {ACT_EQUALIZE, "size", "even out every split"},
+ {ACT_ROTATE_LAYOUT, "size", "turn the layout a quarter"},
{ACT_NEW_TAB, "tabs", "new tab"},
{ACT_NEXT_TAB, "tabs", "next tab"},
@@ -710,7 +770,7 @@ static void cb_color(cfgbuf_t *b, const char *name, color_t c) {
}
static void cb_chain(cfgbuf_t *b, const char *indent, const shader_t *sh,
- size_t n) {
+ size_t n, bool chrome) {
for (size_t i = 0; i < n; i++) {
if (!sh[i].kind) continue;
cb_add(b, "%s%s amount=%u", indent, sh[i].kind, sh[i].amount);
@@ -718,6 +778,9 @@ static void cb_chain(cfgbuf_t *b, const char *indent, const shader_t *sh,
cb_add(b, " color=\"#%02x%02x%02x\"", sh[i].color.r, sh[i].color.g,
sh[i].color.b);
if (sh[i].param) cb_add(b, " at=%u", sh[i].param);
+ if (chrome) cb_add(b, " where=\"chrome\"");
+ if (sh[i].channels == SHADE_FG) cb_add(b, " channel=\"fg\"");
+ else if (sh[i].channels == SHADE_BG) cb_add(b, " channel=\"bg\"");
cb_add(b, "\n");
}
}
@@ -776,6 +839,8 @@ char *config_render(const config_t *c) {
cb_add(&b, "toast_ms %u\n", c->toast_ms);
cb_add(&b, "hover_delay_ms %u\n", c->hover_delay_ms);
cb_add(&b, "double_click_ms %u\n", c->double_click_ms);
+ cb_add(&b, "anim_ms %u // frame clock while a shader animates\n",
+ c->anim_ms);
cb_add(&b, "modal_scrim %u // how far a modal pushes the rest back\n",
c->modal_scrim);
cb_add(&b, "dim_unfocused %u // ...and how far the panes you are not in\n",
@@ -796,17 +861,22 @@ char *config_render(const config_t *c) {
cb_add(&b, "}\n");
/* Written even when empty: an empty block says "this exists and you have
- * none", where nothing at all says "we forgot to tell you". */
- cb_add(&b, "\n// ---- colour passes over every pane's contents ----\n");
+ * none", where nothing at all says "we forgot to tell you". Chrome passes
+ * live in the same blocks, marked, because that is how they are written. */
+ cb_add(&b, "\n// ---- colour passes over every pane ----\n");
cb_add(&b, "// (contrib/shaders has thirty-odd to paste; contrib/shadertoy.html\n");
- cb_add(&b, "// previews them)\nshaders {\n");
- cb_chain(&b, " ", c->shaders, c->nshaders);
+ cb_add(&b, "// previews them. where=\"chrome\" runs a pass over the frame\n");
+ cb_add(&b, "// instead of the contents)\nshaders {\n");
+ cb_chain(&b, " ", c->shaders, c->nshaders, false);
+ cb_chain(&b, " ", c->chrome_shaders, c->nchrome_shaders, true);
cb_add(&b, "}\n");
cb_add(&b, "\n// ---- what a pane looks like in a given state ----\nstates {\n");
for (int st = 0; st < PSTATE_COUNT; st++) {
cb_add(&b, " %s {\n", pane_state_name((pane_state_t)st));
- cb_chain(&b, " ", c->state_shaders[st], c->state_n[st]);
+ cb_chain(&b, " ", c->state_shaders[st], c->state_n[st], false);
+ cb_chain(&b, " ", c->chrome_state_shaders[st],
+ c->chrome_state_n[st], true);
cb_add(&b, " }\n");
}
cb_add(&b, "}\n");
@@ -994,6 +1064,8 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
c->hover_delay_ms);
c->double_click_ms = (uint16_t)kdl_arg_int(kdl_child(root, "double_click_ms"),
0, c->double_click_ms);
+ c->anim_ms =
+ (uint16_t)kdl_arg_int(kdl_child(root, "anim_ms"), 0, c->anim_ms);
{
long v = kdl_arg_int(kdl_child(root, "modal_scrim"), 0, c->modal_scrim);
c->modal_scrim = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v);
@@ -1044,7 +1116,9 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
* Shared by `shaders { }` and by every state, so a state's chain is exactly
* as expressive as the global one and there is one parser to be wrong. */
const kdl_node_t *shaders = kdl_child(root, "shaders");
- if (shaders) c->nshaders = parse_shader_list(c, shaders, c->shaders, err, errcap);
+ if (shaders)
+ parse_shader_list(c, shaders, c->shaders, &c->nshaders, c->chrome_shaders,
+ &c->nchrome_shaders, err, errcap);
/* `states { drop_target { grayscale amount=200; dim amount=140 } }` — what a
* pane looks like while it is in a state. Naming a state at all replaces its
@@ -1064,7 +1138,9 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
continue;
}
if (st == PSTATE_UNFOCUSED) unfocused_declared = true;
- c->state_n[st] = parse_shader_list(c, k, c->state_shaders[st], err, errcap);
+ parse_shader_list(c, k, c->state_shaders[st], &c->state_n[st],
+ c->chrome_state_shaders[st], &c->chrome_state_n[st],
+ err, errcap);
}
}
/* After the states block, so a config that wrote its own chain keeps it and
diff --git a/src/config.h b/src/config.h
index 73ecc7e..7d9e50a 100644
--- a/src/config.h
+++ b/src/config.h
@@ -26,6 +26,12 @@ typedef enum {
PSTATE_DROP_TARGET, /* the others, all of them somewhere it could go */
PSTATE_DEAD, /* its program exited; waiting to be re-run or closed */
PSTATE_SUSPENDED, /* laid out, never started */
+ /* A BEL arrived and nobody has looked since. Above the ambient states
+ * because it is *news* — the whole point of a bell is to be noticed on a
+ * pane you were not watching — and below `dead`, because a pane that rang
+ * and then exited is a pane that exited. It ends when you look at the pane,
+ * which is what answering a bell is. */
+ PSTATE_BELL,
PSTATE_SCROLLED, /* looking at scrollback rather than the present */
PSTATE_UNFOCUSED,
PSTATE_COUNT,
@@ -42,6 +48,7 @@ typedef enum {
ACT_RERUN,
ACT_ZOOM,
ACT_MINIMIZE,
+ ACT_ROTATE_LAYOUT,
ACT_FOCUS_LEFT,
ACT_FOCUS_RIGHT,
ACT_FOCUS_UP,
@@ -62,6 +69,7 @@ typedef enum {
ACT_RESIZE_RIGHT,
ACT_RESIZE_UP,
ACT_RESIZE_DOWN,
+ ACT_EQUALIZE,
ACT_DETACH,
ACT_QUIT,
ACT_HELP,
@@ -184,6 +192,24 @@ typedef struct {
* already had to avoid by hand. */
shader_t state_shaders[PSTATE_COUNT][SHADE_MAX];
size_t state_n[PSTATE_COUNT];
+
+ /* The same two things again, over a pane's *frame* rather than its contents:
+ * any entry in `shaders` or in a state's block carrying `where="chrome"`
+ * lands here instead. Separate chains rather than a flag on each shader,
+ * because a pass is per rect: the contents and the frame are two rects and
+ * therefore two passes, and a chain is exactly the list one pass runs. It
+ * also keeps the flag out of the plugin ABI, which has no business knowing
+ * where a session chose to run an effect. */
+ shader_t chrome_shaders[SHADE_MAX];
+ size_t nchrome_shaders;
+ shader_t chrome_state_shaders[PSTATE_COUNT][SHADE_MAX];
+ size_t chrome_state_n[PSTATE_COUNT];
+ /* How often to repaint while something on screen is animated -- which means
+ * a shader whose amount reads the clock. Nothing else needs a frame clock:
+ * every other repaint has an event behind it. 0 turns animation off, in the
+ * sense that an animated shader then only advances when something else
+ * causes a frame. */
+ uint16_t anim_ms;
bool status_bar; /* the strip along the top: tabs, prefix, pane count */
bool status_line; /* the line along the bottom: what you are looking at */
/* How far the strip and the line are held off the left and right edges.
diff --git a/src/expr.c b/src/expr.c
index 3400e34..677b3b5 100644
--- a/src/expr.c
+++ b/src/expr.c
@@ -19,7 +19,8 @@ enum {
};
/* Variable slots, in the order the names table lists them. */
-enum { V_X, V_Y, V_COLS, V_ROWS, V_CURX, V_CURY, V_CURSOR, V_FOCUSED, V_T };
+enum { V_X, V_Y, V_COLS, V_ROWS, V_CURX, V_CURY, V_CURSOR, V_FOCUSED, V_T,
+ V_SINCE };
static const struct {
const char *name;
@@ -35,6 +36,9 @@ static const struct {
{"cursor", V_CURSOR, EXPR_DEP_CURSOR},
{"focused", V_FOCUSED, EXPR_DEP_FOCUS},
{"t", V_T, EXPR_DEP_TIME},
+ /* Also a clock, so it carries the same dependency: never cached, and the
+ * session keeps painting while something reads it. */
+ {"since", V_SINCE, EXPR_DEP_TIME},
};
/* cols/rows are a dependency even though the map's key always carries the
@@ -314,6 +318,12 @@ static int vm_run(const expr_prog_t *pr, const expr_env_t *env) {
[V_CURX] = env->curx, [V_CURY] = env->cury,
[V_CURSOR] = env->cursor, [V_FOCUSED] = env->focused,
[V_T] = (int32_t)(env->t & 0x7fffffff),
+ /* Clamped, not wrapped: a pane that has been unfocused for a month
+ * should read as "a long time", and a `since` that rolled over would
+ * make an effect restart for no reason anybody could see. */
+ [V_SINCE] = (int32_t)(env->since < 0 ? 0
+ : env->since > 0x7fffffff ? 0x7fffffff
+ : env->since),
};
for (size_t i = 0; i < pr->n; i++) {
diff --git a/src/expr.h b/src/expr.h
index 075df7d..6508ec7 100644
--- a/src/expr.h
+++ b/src/expr.h
@@ -20,15 +20,18 @@
* nothing in here has an effect. Evaluation is therefore O(program length),
* a bad expression cannot hang a session, and there is nothing to sandbox.
*
- * variables x y cols rows curx cury cursor focused t
+ * variables x y cols rows curx cury cursor focused t since
* operators + - * / % < > <= >= == != && || ! ?:
* functions min max abs clamp dist
*
- * `x`/`y` are cell positions inside the pane's content rect, `t` is a
- * millisecond clock for animation, `dist` corrects for a cell being about
- * twice as tall as it is wide. Division and modulo by zero are 0 rather than
- * a trap: an expression that is wrong should look wrong, not take the session
- * with it.
+ * `x`/`y` are cell positions inside the rect the pass runs over (a pane's
+ * contents, or its frame for a chrome pass), `t` is a millisecond clock for
+ * animation, `since` is how many milliseconds the pane has been in the state
+ * it is in — which is what a one-shot effect needs, because `t` says what time
+ * it is and not how long ago something happened. `dist` corrects for a cell
+ * being about twice as tall as it is wide. Division and modulo by zero are 0
+ * rather than a trap: an expression that is wrong should look wrong, not take
+ * the session with it.
*/
#ifndef SL0PPTY_EXPR_H
#define SL0PPTY_EXPR_H
@@ -71,6 +74,8 @@ typedef struct {
int curx, cury, cursor;
int focused;
int64_t t;
+ /* Milliseconds since the pane entered its current state, clamped at 0. */
+ int64_t since;
} expr_env_t;
/* Evaluate for one cell. Clamped to 0..255 by the caller, not here: the
diff --git a/src/shader.c b/src/shader.c
index b1bb1a1..ba2888a 100644
--- a/src/shader.c
+++ b/src/shader.c
@@ -186,7 +186,10 @@ bool shader_make_p(shader_t *out, const char *kind, color_t color,
if (!out || !kind) return false;
const shader_def_t *d = find_kind(kind);
if (!d) return false;
- *out = (shader_t){d->name, d->fn, NULL, color, amount, param};
+ /* channels left 0, which the pass reads as both: a shader made here has no
+ * opinion about that, and the config sets it when the config has one. */
+ *out = (shader_t){.kind = d->name, .fn = d->fn, .color = color,
+ .amount = amount, .param = param};
return true;
}
@@ -318,9 +321,9 @@ size_t shader_load_dir(const char *dir, char *err, size_t errcap) {
/* ---- the pass ----------------------------------------------------------- */
-void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
- uint16_t y0, uint16_t w, uint16_t h, const shade_ctx_t *base) {
- if (!s || !shaders || !n || !w || !h) return;
+void shade_apply(screen_t *s, const shader_t *shaders, size_t n, rect_t r,
+ const rect_t *hole, const shade_ctx_t *base) {
+ if (!s || !shaders || !n || !r.w || !r.h) return;
/* Clip once, here, so the inner loop can walk a row pointer instead of
* bounds-checking every cell through screen_at(). That call was a fifth of
@@ -328,11 +331,25 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
* already guarantees, once per cell per shader. The layout never asks for a
* rect that needs clipping; this is so that a caller that does gets a
* smaller rect rather than an out-of-bounds write. */
+ uint16_t x0 = r.x, y0 = r.y, w = r.w, h = r.h;
if (x0 >= s->cols || y0 >= s->rows) return;
if ((size_t)x0 + w > s->cols) w = (uint16_t)(s->cols - x0);
if ((size_t)y0 + h > s->rows) h = (uint16_t)(s->rows - y0);
if (!w || !h) return;
+ /* The hole in this rect's own coordinates, as a half-open span per axis.
+ * Computed once: the inner loop then asks two comparisons per cell, and an
+ * empty hole leaves an empty span that nothing is ever inside. */
+ uint16_t hx0 = 0, hx1 = 0, hy0 = 0, hy1 = 0;
+ if (hole && hole->w && hole->h && hole->x + hole->w > x0 &&
+ hole->y + hole->h > y0) {
+ hx0 = (uint16_t)(hole->x > x0 ? hole->x - x0 : 0);
+ hy0 = (uint16_t)(hole->y > y0 ? hole->y - y0 : 0);
+ long ex = (long)hole->x + hole->w - x0, ey = (long)hole->y + hole->h - y0;
+ hx1 = (uint16_t)(ex > w ? w : ex);
+ hy1 = (uint16_t)(ey > h ? h : ey);
+ }
+
shade_ctx_t ctx = base ? *base : (shade_ctx_t){0};
ctx.cols = w;
ctx.rows = h;
@@ -353,6 +370,11 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
const uint8_t *map = NULL;
expr_env_t env = {0};
bool per_cell = false;
+ /* Which colours this pass keeps. Per shader, not per cell: it is one test
+ * of a byte, and the inner loop is walked tens of thousands of times a
+ * frame. 0 means both, so a zeroed shader_t behaves as it always did. */
+ bool keep_fg = !sh->channels || (sh->channels & SHADE_FG);
+ bool keep_bg = !sh->channels || (sh->channels & SHADE_BG);
if (sh->amount_expr) {
env.cols = w;
@@ -362,6 +384,7 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
env.cursor = ctx.has_cursor;
env.focused = ctx.focused;
env.t = ctx.now_ms;
+ env.since = ctx.state_ms;
if (expr_deps(sh->amount_expr) == 0) {
local.amount = (uint8_t)clamp255(expr_constant(sh->amount_expr));
} else {
@@ -379,12 +402,27 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
for (uint16_t y = 0; y < h; y++) {
cell_t *row = &s->cur[(size_t)(y0 + y) * s->cols + x0];
ctx.y = y;
+ bool y_in_hole = y >= hy0 && y < hy1;
for (uint16_t x = 0; x < w; x++) {
+ /* Inside the hole: not this pass's cell. Skipped before anything is
+ * materialised on it, so a frame pass leaves the contents exactly as
+ * the content pass left them. */
+ if (y_in_hole && x >= hx0 && x < hx1) {
+ x = (uint16_t)(hx1 - 1); /* jump the span rather than walk it */
+ continue;
+ }
cell_t *c = &row[x];
/* width 0 is the tail half of a wide cell: never painted, so shading
* it would be computing a colour nothing can display. */
if (!c->width) continue;
+ /* What the cell was, for whichever colour this pass does not keep.
+ * Taken before materialising, so a colour the terminal was drawing in
+ * its own default goes back to *unset* rather than to our idea of it —
+ * the difference between recolouring a glyph and painting a rectangle
+ * behind it. */
+ color_t was_fg = c->fg, was_bg = c->bg;
+
/* Materialised per shader rather than once for the chain, so a shader
* added later cannot observe a half-resolved cell. Idempotent: once
* set, this does nothing. */
@@ -400,6 +438,9 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
local.amount = (uint8_t)clamp255(expr_eval(sh->amount_expr, &env));
}
local.fn(&local, &ctx, c);
+
+ if (!keep_fg) c->fg = was_fg;
+ if (!keep_bg) c->bg = was_bg;
}
}
}
diff --git a/src/shader.h b/src/shader.h
index 5e25b37..8cee437 100644
--- a/src/shader.h
+++ b/src/shader.h
@@ -68,9 +68,16 @@ const char *shader_kind(size_t i);
* anywhere: the trust decision is "put a file here". */
size_t shader_load_dir(const char *dir, char *err, size_t errcap);
-/* Run `n` shaders in order over the w×h rect at (x0,y0). `base` supplies
- * everything in the context except the per-cell position and the size. */
-void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
- uint16_t y0, uint16_t w, uint16_t h, const shade_ctx_t *base);
+/* Run `n` shaders in order over `r`, skipping the cells inside `hole` (NULL
+ * for none). `base` supplies everything in the context except the per-cell
+ * position and the size.
+ *
+ * The hole is what makes a pane's *frame* a rect: chrome is the pane's rect
+ * minus its contents, and an effect has to see the whole frame's coordinates
+ * or it could not travel round one — four separate passes over the four sides
+ * would each start counting from zero. Skipped cells are not visited at all,
+ * so a chrome pass cannot touch a cell the content pass owns. */
+void shade_apply(screen_t *s, const shader_t *shaders, size_t n, rect_t r,
+ const rect_t *hole, const shade_ctx_t *base);
#endif /* SL0PPTY_SHADER_H */
diff --git a/src/shader_abi.h b/src/shader_abi.h
index 60fbbc5..dbd840d 100644
--- a/src/shader_abi.h
+++ b/src/shader_abi.h
@@ -18,7 +18,24 @@
#include <stddef.h>
#include <stdint.h>
-#define SL0PPTY_SHADER_ABI 2
+/* 3: shade_ctx_t gained `state_ms`, so an effect can know how long the pane has
+ * been as it is and not only what time it is; struct shader gained
+ * `channels`, which is the pass's business rather than a shader's. */
+#define SL0PPTY_SHADER_ABI 3
+
+/* Which of a cell's two colours a pass is allowed to keep. Enforced by the
+ * pass, not by the shader: every shader writes whatever it writes, and the
+ * cell's other colour is put back afterwards — so this works for a built-in
+ * and for a loaded one, and no shader has to grow a variant of itself.
+ *
+ * Put back to what it *was*, which for a cell the terminal is drawing in its
+ * own default colour means back to unset. That is the difference between
+ * recolouring a border glyph and painting a dark rectangle behind it. */
+enum {
+ SHADE_FG = 1 << 0,
+ SHADE_BG = 1 << 1,
+ SHADE_BOTH = SHADE_FG | SHADE_BG, /* and 0 means this, so zeroed is normal */
+};
enum {
ATTR_BOLD = 1 << 0,
@@ -57,6 +74,12 @@ typedef struct {
uint16_t x, y; /* cell position within the content rect, 0-based */
uint16_t cols, rows; /* content size, so an effect can be positional */
int64_t now_ms; /* for anything animated */
+ /* How long this pane has been in the state it is in, in milliseconds — the
+ * bell that just rang, the pane that died a moment ago, the one you have not
+ * been in for a while. `now_ms` says what time it is; a one-shot effect needs
+ * to know how long ago something happened, and only the session can say. 0
+ * when there is nothing to date the state from. */
+ int64_t state_ms;
bool focused;
/* Where the cursor is, in the same rect-relative space as x/y. Only ever
@@ -97,6 +120,10 @@ struct shader {
* gradient 0 down, 1 up, 2 right, 3 left
*/
uint16_t param;
+ /* SHADE_FG / SHADE_BG / SHADE_BOTH, and 0 for both. Opaque to a shader, like
+ * `amount_expr` above: it is the pass that keeps a channel, so a shader
+ * cannot get it wrong and does not have to know. */
+ uint8_t channels;
};
/* ---- plugins ------------------------------------------------------------
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index cb916c5..319fcba 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -12,6 +12,13 @@
* the one place they are a *contract* with code compiled outside this tree. */
#include "shader_abi.h"
+/* A rectangle of cells. Here rather than in app.h because the layout is not
+ * the only thing that has one: a shader pass is given a rect to run over, and
+ * the frame pass is given the rect it must skip. */
+typedef struct {
+ uint16_t x, y, w, h;
+} rect_t;
+
/* ---- screen: our composited cell buffer + diff emitter ---------------- */
/* One geometry (DESIGN.md): every painted interactive element records its rect
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()))