diff options
| author | user <user@clank> | 2026-08-15 23:53:30 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 23:53:30 +0200 |
| commit | 4dc055692c56324a94d1087331da8862ea650d2b (patch) | |
| tree | 5ddec99f7cf6b144ede4138b6e952c84379725aa | |
| parent | sl0ppty: marks a font actually draws at cell size (diff) | |
| download | slosh-4dc055692c56324a94d1087331da8862ea650d2b.tar.gz slosh-4dc055692c56324a94d1087331da8862ea650d2b.tar.xz slosh-4dc055692c56324a94d1087331da8862ea650d2b.zip | |
sl0ppty: a README you can read, and docs/ for the rest
The README was 331 lines and had become a manual with a title. Everything
in it was true and most of it was the wrong thing to hand somebody who
has just arrived: five pages of shader expression semantics before they
have split a pane.
So the front page is 60 lines -- what it is, install, run, where the docs
are, and six bullets on what is actually different about it -- and the
detail moved into docs/, nine pages that each answer one question:
index.md what it is, install, run, where to go
keys.md the leader, every default, rebinding, direct binds
panes.md splitting, resizing, the mouse, dead panes, small terminals
config.md the file, live reload, include, themes, the knobs
shaders.md the passes, the built-ins, the expression language
chrome.md where="chrome", channel, since, the perimeter trick
layouts.md a session as a file, and writing one back out
scripting.md the control socket, OSC 5577, headless
design.md the two decisions, and a map of the source
docs/nav holds the order and the grouping and nothing else -- titles come
from each page's own heading, so a renamed page cannot end up with two
names. It is what the site generator will read.
Reference material that only existed in code comments is written down for
the first time: every action that can be bound (including the two with no
default key, which is what the palette is for), every control verb, the
built-in shaders with what each takes, the pane-state ranking, and the
layout file's shape.
Two things I got wrong on the first pass and fixed before committing: the
docs linked repo files through github.com URLs for a project whose remote
is a private git server, so those are inline paths now; and the README
advertised `make docs`, which does not exist yet. It will in the next
commit, and it can be advertised then.
| -rw-r--r-- | README.md | 346 | ||||
| -rw-r--r-- | docs/chrome.md | 94 | ||||
| -rw-r--r-- | docs/config.md | 105 | ||||
| -rw-r--r-- | docs/design.md | 50 | ||||
| -rw-r--r-- | docs/index.md | 67 | ||||
| -rw-r--r-- | docs/keys.md | 95 | ||||
| -rw-r--r-- | docs/layouts.md | 75 | ||||
| -rw-r--r-- | docs/nav | 24 | ||||
| -rw-r--r-- | docs/panes.md | 93 | ||||
| -rw-r--r-- | docs/scripting.md | 75 | ||||
| -rw-r--r-- | docs/shaders.md | 101 |
11 files changed, 817 insertions, 308 deletions
@@ -1,38 +1,28 @@ # sl0ppty -A terminal multiplexer — panes, tabs, sessions you can detach from — written -in C on top of [libghostty-vt](https://github.com/ghostty-org/ghostty). +A terminal multiplexer — panes, tabs, sessions you can detach from — written in +C on top of [libghostty-vt](https://github.com/ghostty-org/ghostty). -## Why another one - -The multiplexers we have are crusty, and the things that make them awkward are -not features somebody forgot to add — they follow from how the programs are -built. Layout state that is stored and edited rather than recomputed, so it -drifts out of sync with itself. Mouse handling written as a second -implementation of the drawing code, so the two disagree about where a button -is. Extension models bolted onto a core that never expected one. This project -started as a fork of one of them, carrying sixty-odd patches; almost none of -that work was *multiplexing*, it was arguing with the architecture. - -So: start over, and stand on the shoulders of the person who already solved -the genuinely hard part. Mitchell Hashimoto's terminal emulation core from -[ghostty](https://ghostty.org) is vendored as a library and does the VT -parsing, scrollback, selection, images and key encoding. What sits on top is -small, recomputes rather than remembers, and is scriptable to the same depth -it is usable. +``` +╭──────────── nvim ─────────── ▬ □ ✕ ╮ ╭──────── npm run dev ──────── ▬ □ ✕ ╮ +│ │ │ │ +│ │ ╰────────────────────────────────────╯ +│ │ ╭──────────── shell ───────── ▬ □ ✕ ╮ +│ │ │ │ +╰─────────────────────────────────────╯ ╰────────────────────────────────────╯ +``` ## Install -Needs [zig](https://ziglang.org) 0.16 and nothing else — no cmake, no -autotools, no libraries. The result is one static binary. +Needs [zig](https://ziglang.org) 0.16 and nothing else. The result is one static +binary. ```bash -make vendor # build the vendored terminal core (once, a couple of minutes) +make vendor # the vendored terminal core, once make # about a second -make test # optional: the full suite, about six seconds ``` -## Use +## Run ```bash sl0ppty # attach to session "main", creating it if needed @@ -40,292 +30,32 @@ sl0ppty -s work # a named session sl0ppty ls # what is running ``` -Sessions keep running when you detach, or when your terminal or ssh -connection goes away. Reattach with the same command. - -| key | | -|---|---| -| `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 Tab` `C-a shift+Tab` · `C-a 1..9` | new tab · cycle · select | -| `C-a f` | find a pane 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 | -| `C-a C-a` | send a literal `C-a` | - -**`C-a` is a default, not a decision.** If you want it back for start-of-line — -and plenty of people do — take one line of config and it is yours; everything -that names the prefix, the badge in the status bar and the cheatsheet -included, follows what you bound: - -```kdl -keys { prefix "ctrl+b" } // or ctrl+space, or alt+x, or ... -``` - -Or skip the leader entirely for the ones you use constantly: - -```kdl -keys { - direct { - bind "ctrl+alt+left" "focus-left" - bind "ctrl+alt+right" "focus-right" - } -} -``` - -Those fire the moment you press them — which also means the chord is gone from -every program in every pane, because it has to be. It is a footgun and it is -yours; the cheatsheet lists them apart, under "without the leader", so the -`C-a then:` caption above stays true. - -### The mouse works properly - -- **Click a pane's border to split toward it** — the side you click is the side - the new pane appears on. Hovering shows a dashed line where the split would - land. -- **Drag the gap between panes** to move the boundary; where two gaps cross, - drag the crossing to move both at once. -- **Drag a pane by its title** onto another to swap them. Everything it could - land on greys out; the pane in your hand does not. -- **Double-click a name to rename it, in place** — a pane's title, or a tab in - the strip. Enter keeps it, Escape abandons it, empty gives it back. -- **Drag a tab along the strip** to reorder it; the strip rearranges as you go - rather than dropping it somewhere at the end. -- Click a tab to switch, `+` to open one, a pane to focus it. -- Hovering anything says what it does, in a word at the bottom of the screen. - -### Panes that were given a command do not vanish when it ends - -Split off a shell, do something, type `exit`: it closes, like a terminal -should. But a pane that was *told to run something* — from a layout file or -the control API — keeps what it printed when that something exits, with a line -saying how it ended and two buttons: - -``` -╰ exited: status 3 ───────────────────────────────[re-run]─[close]─╯ -``` - -`[re-run]` runs the same command again in the same pane, keeping the previous -run above it in the scrollback. So a command that failed while you were -looking somewhere else still has its error message when you get back, and a -mistyped command in a fresh session no longer closes the session. - -### The rest of it - -- **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 - that have *no* key, which is the only way to reach those at all. -- **Find a pane instead of remembering where it is.** `C-a f` opens a picker - over the whole session — every tab, including panes a small window has - collapsed out of sight. Type to narrow it by pane title, tab name or - purpose; arrows or `C-n`/`C-p` or tab to move, enter to go, and a dot marks - where you already are. Tabs stop being navigation somewhere around six - projects, and this is what replaces them. -- **Select text to copy it.** Release the mouse and it is on your clipboard, - sent to your terminal over ssh as well as locally. Middle click pastes. -- **Images work.** Kitty graphics pass through to your terminal, which is the - thing other multiplexers drop — including the useful half of the protocol, - where a program uploads an image once and then places it every frame. Your - terminal's cell size is carried through to each pane, so programs can size - images and place them without saying how many cells to cover. PNG uploads - are decoded here and handed to your terminal as pixels, because the terminal - core ships no image decoder of its own. (Sixel is not supported: there is no - sixel decoder either, and unlike PNG nobody has asked.) -- **Small terminals degrade gracefully.** When panes no longer fit, the tab - becomes a list of one-line headers with the focused pane open below them — - and returns to exactly the layout you had when there is room again. -- **A pane that beeps says so**, in its title and in its tab, until you look. -- **Minimised panes** sit in a row along the bottom, still running. - -## Configuration - -`sl0ppty --dump-config` writes every setting with the value it currently has — -generated from the code, so it cannot drift from the defaults — and `C-a e` -opens your config in a pane, writing that starting file first if you have -none. [`config/config.kdl`](config/config.kdl) is the same list with the -reasoning attached, if you want to know *why* a setting exists. **Saving -it applies it to every running session immediately** — and a file with a -mistake in it is refused rather than half-applied. Anything smaller than that — -an unknown shader, a binding that does not parse, an include that is not there — -applies the rest of the file and tells you which line it could not honour. - -Geometry, key bindings and every colour are configurable. Six ready-made -themes are in [`contrib/themes`](contrib/themes), and a config can be made of -files rather than pasted together: - -```kdl -include "themes/amber.kdl" // switching theme is editing one line -include "keys/vim.kdl" "shaders/crt.kdl" -``` - -A relative path is relative to *the file doing the including*, not to wherever -you started the session; `~` is your home directory. What you include is the -base, so the file with the `include` line in it wins wherever they disagree — -which is what makes a theme something you can put two lines on top of. - -Panes are tinted by *state*. Out of the box: the panes you are **not in** are -gently pushed back (`dim_unfocused 60`, or `0` if you hate it), and so is any -pane that is **not live** — its program exited, it never started, or you are -looking at scrollback rather than the present. That second group is the one -you cannot discover by looking unless something says so: - -```kdl -dim_unfocused 60 // the one knob most people want - -states { // ...and the whole table underneath it - dead { grayscale amount=200; dim amount=90 } - suspended { grayscale amount=170; dim amount=60 } - scrolled { tint amount=22 color="#ff5fd7" } - unfocused { dim amount=60 } // writing this replaces dim_unfocused -} -``` - -The same mechanism draws a column ruler, a margin marker or a spotlight that -follows the cursor, inside programs that have never heard of such things. - -**A shader's strength can be an expression**, so an effect nobody built in -takes one line of config and no compiler: - -```kdl -shaders { - dim amount="(y % 2) * 40" // scanlines - dim amount="(x > cols - 10) * 120" // a right margin - tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7" -} -``` - -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 -phosphor CRT, sonar pings that follow your cursor — and `contrib/shader-tour` -cycles a live session through them. There are no loops, so a config cannot hang a session. The -expression produces the *strength* and never the colour, which keeps the -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 -in the config exactly like a built-in. Skeleton, Makefile and the rules a -shader has to keep: [`contrib/shader-plugin`](contrib/shader-plugin). It is -native code in the session's process, so install ones you trust — the same -standing as `shell` and a layout's `command=`, which can already run anything -as you. - -## Scripting - -Three things make sl0ppty easy to drive from other programs. - -**A pane can draw its own status bar and buttons**, by printing an escape -sequence — no plugin, no config: - -```bash -printf '\033]5577;1;status;building 3/7\033\\' -printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\' -# clicking [Approve] arrives on the program's stdin as: -# \033]5577;1;click;approve\033\\ -``` - -**A session can be a file**, so a project's window layout is checked in with -the project: - -```kdl -layout { - tab name="api" cwd="~/dev/api" { - pane command="nvim" - pane split="rows" { - pane command="npm run dev" suspended=true - pane - } - } -} -``` - -```bash -sl0ppty --layout session.kdl -``` - -`{"cmd":"dump-layout"}` writes one back out — tabs, splits, proportions, -directories, which pane you were in — so a session can be checked in, or put -back after a restart. [`contrib/sl0ppty-dev`](contrib/sl0ppty-dev) is that -loop, for when the thing you are rebuilding is sl0ppty itself. - -`suspended` panes are laid out but run nothing until you touch them, so twelve -checked-out projects are not twelve running dev servers. Panes and tabs can -also carry a `purpose=` label for tooling to find them by, which a program -inside a pane cannot overwrite. Full example: -[`config/layout.example.kdl`](config/layout.example.kdl). - -**Everything the keyboard can do, a script can do**, over the session's socket -— one JSON object per line: - -```bash -$ sl0ppty -s work cmd '{"cmd":"new-tab","name":"api"}' -{"ok":true,"id":2} -$ sl0ppty -s work cmd '{"cmd":"panes"}' -{"ok":true,"panes":[{"id":1,"title":"nvim","alive":true,...}]} -``` - -Panes are addressed by id, so a background tab is scriptable, and a detached -session answers exactly as an attached one does. +Sessions survive detaching, and your ssh connection dying. Then `C-a \` to +split, `C-a ?` for the keys, `C-a p` for every command by name. -## Under the hood +## Docs -Two decisions do most of the work, and [DESIGN.md](DESIGN.md) explains the -rest: +**[docs/](docs/index.md)** — [keys](docs/keys.md) · +[panes](docs/panes.md) · [configuration](docs/config.md) · +[shaders](docs/shaders.md) · [chrome](docs/chrome.md) · +[layouts](docs/layouts.md) · [scripting](docs/scripting.md) · +[how it works](docs/design.md) -**Everything drawn registers what it is** as it is drawn, and a click is a -lookup in that list. Drawing and clicking cannot disagree, because there is -only one of them. +## What is different about it -**It runs headless.** `sl0ppty --script` is the whole thing without a terminal: -commands in, the composited screen out as JSON. That is how it is tested — -drive these events, assert this screen — so the suite is 700-odd real -end-to-end checks that finish in six seconds. +- **The mouse works properly.** Click a border to split toward it, drag a gap to + move a boundary, drag a pane by its title to swap it. Everything drawn + registers what it is as it is drawn, so a click and a preview cannot disagree. +- **The layout is recomputed, never stored.** Small terminal? The tab becomes a + list of headers, and comes back to exactly the arrangement you had. +- **A pane told to run something keeps its output when that thing exits**, with + `[re-run]` and `[close]` in its frame. +- **Configuration is one KDL file** that reloads on save, can be built from + `include`d pieces, and refuses a broken file instead of half-applying it. +- **Colour passes over cells** — dim, tint, rulers, spotlights, and a border that + can flash when a pane rings, with strengths written as expressions. +- **Scriptable to the same depth it is usable**: one JSON object per line over + the session's socket, and a pane can draw its own status bar and buttons with + an escape sequence. -About 10k lines of C, no dependencies beyond libc and the vendored terminal -core. +Design notes and the twenty decisions behind it: [DESIGN.md](DESIGN.md). diff --git a/docs/chrome.md b/docs/chrome.md new file mode 100644 index 0000000..dcc6130 --- /dev/null +++ b/docs/chrome.md @@ -0,0 +1,94 @@ +# Chrome shaders + +A frame 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. + +```kdl +states { + // a bell nobody has answered: a quarter-second flash, then gone + bell { tint where="chrome" channel="fg" color="#ffcc00" amount="(since < 250) * 255" } +} +``` + +Nothing else changes: same shaders, same `amount`, same +[expressions](shaders.md#strength-as-an-expression), same chain order. One new +word. + +## Two rules + +**`channel="fg"`, almost always.** 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 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. + +**The contents are cut out of the pass.** A full-strength chrome tint cannot +reach your text, and a full-strength content tint cannot reach the border. They +are two rects and two passes. + +## Positions are the whole frame's + +`x`/`y` are counted from the frame's top-left corner, with `cols`/`rows` the size +of the pane's rect — not of one side. One pass over the whole ring, so an effect +can travel round a border instead of restarting at every corner. + +For anything that *travels*, though, the coordinate you want is distance along +the ring, which the language can work out — clockwise from the top-left: + +``` +(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 it: + +- `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. +- `(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 the formula is written out in full — +which is why a small effect is a long line. + +Seven of them are ready to paste in +`contrib/chrome`: +a shine running round the border, drifting sparkles, ripples leaving a corner in +both directions, a rotating arc, a static gradient, corner glints, and a sheen +across the title rule. + +## A border that says something + +Hung off a [state](config.md#pane-states), this is chrome doing the job chrome is +for, in 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)" } + // one that has not started yet is outlined, not filled + suspended { tint where="chrome" channel="fg" color="#8a8a95" amount=160 } + // and the panes you are not in recede, frame and contents together + unfocused { dim amount=60; dim where="chrome" channel="fg" amount=90 } +} +``` + +`since` is what makes a *flash* possible: it is how long the pane has been in +this state, where `t` only knows what time it is. The `bell` state ends when you +look at the pane, because that is what answering a bell is. + +A pane that a small terminal 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 — and +that is the state where a colour is worth the most. + +## Animation costs a clock + +Anything that reads `t` or `since` asks the session to keep painting, at +`anim_ms` (50ms, so 20fps) and only while such a pass is actually on screen. A +pulse hung off `dead` costs a frame clock while a pane is dead and nothing for +the rest of the session; the same pass in the global `shaders` block costs it for +the whole session. + +`anim_ms 0` turns the clock off, which leaves an animated shader advancing only +when something else causes a frame. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..4572404 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,105 @@ +# Configuration + +One file, in [KDL](https://kdl.dev): + +``` +$SL0PPTY_CONFIG, else $XDG_CONFIG_HOME/sl0ppty/config.kdl, else +~/.config/sl0ppty/config.kdl +``` + +`C-a e` opens it in a pane, writing a starting file first if you have none. + +**Saving it applies to every running session immediately.** A file that will not +parse is refused rather than half-applied, and the running config stays. Anything +smaller than that — an unknown shader, a binding that does not parse, an +[`include`](#include) that is not there — applies the rest of the file and says +which line it could not honour. + +Two settings are read later than the rest, because of *when* they are needed: +`shell` applies to the next pane you open, and a shader plugin that replaced one +already loaded needs a new session. + +## Two files worth knowing + +- **`sl0ppty --dump-config`** writes every setting with the value it currently + has. It is generated from the code, so it cannot drift from the defaults, and + it is a supported way to start a config: + `sl0ppty --dump-config > ~/.config/sl0ppty/config.kdl`. +- **`config/config.kdl`** + in the source tree is the same list with the *reasoning* attached — what each + setting is for, what it cost to get right, and which ones are opinions. When + the two disagree, the dump is right and that file is stale (a test says so). + +## include + +A config can be made of files: + +```kdl +include "themes/amber.kdl" +include "keys/vim.kdl" "shaders/crt.kdl" +``` + +which is how you switch a theme by editing one line rather than pasting one in. +Nothing about it is theme-specific: an include is another config file applied +here, so the same line composes keys, shader chains, states or geometry. + +- A relative path is relative to **the file doing the including**, never to the + directory you started the session from. `~` is your home directory, an absolute + path is itself, and an included file may include others. +- **What you include is the base**: the file with the `include` line in it wins + wherever they disagree, and a later include wins over an earlier one. That + holds wherever the line sits in the file. +- `keys` blocks add to what came before; a `shaders` or `states` block replaces + the one it inherited. +- Every included file is watched, so saving the theme reloads the session too. + +## Themes + +Every colour the compositor draws has its own name under `theme { }` — 48 of +them, because six names doing thirty jobs meant the split guide could not be +recoloured without also recolouring the focused frame. + +Six ready-made themes are in +`contrib/themes`: +`amber`, `mono`, `paper`, `phosphor`, `sl0p`, `slate`. Include one and put your +own two lines on top: + +```kdl +include "~/.config/sl0ppty/themes/phosphor.kdl" +theme { frame_focus "#00ff88" } // ...but that one colour is mine +``` + +`contrib/theme-tour` cycles a running session through all six. + +## The knobs, briefly + +| group | what is in it | +|---|---| +| geometry | `gap` `gap_aspect` `padding` `rounded` `title_align` `title_inset` `min_pane` `min_split` | +| chrome | `status_bar` `status_line` `status_pad` `hints` `version_banner` `pane_buttons` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark`) | +| behaviour | `focus_follows_mouse` `scroll_lines` `toast_ms` `hover_delay_ms` `double_click_ms` `anim_ms` `modal_scrim` `dim_unfocused` `keep_dead` `shell` `editor` `shader_dir` | +| colour | `theme { }` | +| effects | `shaders { }`, `states { }` — see [shaders](shaders.md) and [chrome](chrome.md) | +| keys | `keys { }` — see [keys](keys.md) | + +## Pane states + +A pane is tinted by what is *true* of it, which is the part you cannot discover +by looking: + +```kdl +dim_unfocused 60 // the one knob most people want + +states { // ...and the whole table underneath it + dead { grayscale amount=200; dim amount=90 } + suspended { grayscale amount=170; dim amount=60 } + scrolled { tint amount=22 color="#ff5fd7" } + unfocused { dim amount=60 } // writing this replaces dim_unfocused +} +``` + +The full ranking, most urgent first: `dragging`, `drop_hover`, `drop_target`, +`dead`, `suspended`, `bell`, `scrolled`, `unfocused`. Exactly one wins — the +first that matches — because two reasons to be grey compound into one muddy grey +that reads as neither. Naming a state replaces its default outright, including +with nothing, which is how you turn one off. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..5d08d86 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,50 @@ +# How it works + +Two decisions do most of the work. The rest — twenty numbered decisions, each +with what it cost to get wrong — is in +`DESIGN.md`. + +## One geometry + +**Everything drawn registers what it is, as it is drawn**, and a click is a +lookup in that list. Drawing and hit-testing cannot disagree, because there is +only one of them. The dashed guide that shows where a split would land is drawn +from the same rect the click will resolve against, in the same pass — so a +preview that lies is not a bug you can have. + +The same rule applies to state that could be derived: the layout is a pure +function of the tree and the terminal's size, recomputed every frame. Nothing +stores where a pane is. That is why a small terminal can collapse a tab to a list +of headers and come back to exactly the layout you had, and why a pane cannot be +left greyed out by the one code path that forgot to clear a flag. + +Sizes are **weights**, so an even split is equal weights and resizing is not a +special case of anything. + +## It runs headless + +`sl0ppty --script` is the whole thing without a terminal: commands in, the +composited screen out as JSON. The interactive client and the test suite drive +the same object through the same commands, so there is exactly one implementation +of what a key does. + +That is what makes the tests real: they type keys and assert on the screen a +client would have seen, rather than on the internals. 700-odd checks in about +eight seconds, plus unit tests for the parts that are pure functions (the KDL +parser, the shader pass, the expression compiler). + +## The shape of it + +``` +src/app.c the session: tree, layout, chrome, input routing, control API +src/pane.c one pty + one libghostty-vt terminal, composed into the screen +src/screen.c our cell buffer, and the minimal byte stream to update a terminal +src/server.c the session process: socket, poll loop, config watcher +src/client.c the attached terminal: bytes in, bytes out +src/config.c the config, the keymap, the cheatsheet's names +src/shader.c the colour passes; src/expr.c compiles their strengths +src/kdl.c a hand-rolled KDL subset, for configs and layouts +``` + +About 10k lines of C. The vendored terminal core does VT parsing, scrollback, +selection, images and key encoding; everything above is ours. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..69595a5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,67 @@ +# sl0ppty + +A terminal multiplexer — panes, tabs, sessions you can detach from — written in +C on top of [libghostty-vt](https://github.com/ghostty-org/ghostty). + +``` +╭──────────── nvim ─────────── ▬ □ ✕ ╮ ╭──────── npm run dev ──────── ▬ □ ✕ ╮ +│ │ │ │ +│ │ ╰────────────────────────────────────╯ +│ │ ╭──────────── shell ───────── ▬ □ ✕ ╮ +│ │ │ │ +╰─────────────────────────────────────╯ ╰────────────────────────────────────╯ +``` + +## Install + +Needs [zig](https://ziglang.org) 0.16 and nothing else — no cmake, no +autotools, no libraries. The result is one static binary. + +```bash +make vendor # build the vendored terminal core (once, a couple of minutes) +make # about a second +make test # optional: the full suite, about eight seconds +``` + +## Run + +```bash +sl0ppty # attach to session "main", creating it if needed +sl0ppty -s work # a named session +sl0ppty ls # what is running +``` + +Sessions keep running when you detach, or when your terminal or ssh connection +goes away. Reattach with the same command. + +Then: `C-a \` to split, `C-a ?` for the cheatsheet, `C-a p` for every command +by name. + +## Where to go + +- **[Keys](keys.md)** — the leader, the defaults, rebinding, the palette. +- **[Panes and tabs](panes.md)** — splitting, moving, zooming, the mouse. +- **[Configuration](config.md)** — one file, live reload, themes, `include`. +- **[Shaders](shaders.md)** — colour passes over a pane's contents. +- **[Chrome shaders](chrome.md)** — the same passes over a pane's frame. +- **[Layouts](layouts.md)** — a session as a file you check in. +- **[Scripting](scripting.md)** — the control socket, and what a pane can draw. +- **[How it works](design.md)** — the two decisions that shape the rest. + +## Why another one + +The multiplexers we have are crusty, and the things that make them awkward are +not features somebody forgot to add — they follow from how the programs are +built. Layout state that is stored and edited rather than recomputed, so it +drifts out of sync with itself. Mouse handling written as a second +implementation of the drawing code, so the two disagree about where a button +is. Extension models bolted onto a core that never expected one. + +So: start over, and stand on the shoulders of the person who already solved the +genuinely hard part. The terminal emulation core from +[ghostty](https://ghostty.org) is vendored as a library and does the VT +parsing, scrollback, selection, images and key encoding. What sits on top is +small, recomputes rather than remembers, and is scriptable to the same depth it +is usable. + +About 10k lines of C, no dependencies beyond libc and the vendored core. diff --git a/docs/keys.md b/docs/keys.md new file mode 100644 index 0000000..e421df2 --- /dev/null +++ b/docs/keys.md @@ -0,0 +1,95 @@ +# Keys + +Everything is the **leader** (`C-a` by default) and then a key. Pressing the +leader twice sends it to the program in the pane. + +`C-a ?` shows a cheatsheet built from the bindings your config actually has, so +it cannot disagree with your keyboard. `C-a p` opens the same list as a palette +you can type into. + +## Defaults + +| key | does | +|---|---| +| `\` `-` | split into columns / rows | +| `h` `j` `k` `l`, or arrows | move focus | +| `o` | the next pane | +| `H` `J` `K` `L`, or shift+arrows | move the boundary between panes | +| `=` | give every visible pane an even share | +| `Space` | turn the layout a quarter turn (four brings it back) | +| `z` | zoom this pane to fill the tab, and back | +| `m` | minimise it into the strip along the bottom | +| `x` | close this pane | +| `r` | run a finished pane's command again | +| `c` | new tab | +| `Tab` `shift+Tab` | next / previous tab | +| `1`…`9` | go to that tab | +| `f` | find a pane by name | +| `p` | the command palette | +| `PgUp` `PgDn` `Home` `End` | scrollback (the wheel works too) | +| `e` | edit the config, in a pane | +| `?` | this list | +| `d` | detach, leave it running | +| `q` | quit the session | +| `C-a` | send a literal `C-a` | + +## Rebinding + +`C-a` is a default, not a decision. If you want it back for start-of-line — and +plenty of people do — one line takes it: + +```kdl +keys { prefix "ctrl+b" } // or ctrl+space, or alt+x, or ... +``` + +Everything that names the prefix follows what you bound: the badge in the status +bar, the cheatsheet's `C-a then:` caption, the palette's key column. + +A `keys` block **adds to** the defaults rather than replacing them, so binding +one key leaves the rest alone. `"none"` takes a binding away: + +```kdl +keys { + bind "v" "split-cols" // as well as \ + bind "-" "none" // and no more splitting into rows +} +``` + +Modifiers are `ctrl+ alt+ shift+ super+`. Named keys are +`left right up down enter tab escape space backspace home end pageup pagedown +delete insert backslash minus slash comma period`; anything else is the +character you press, so `"?"` and `"S-slash"` are the same binding. + +## Without the leader + +Bindings in a `direct` block fire the moment you press them: + +```kdl +keys { + direct { + bind "ctrl+alt+left" "focus-left" + bind "ctrl+alt+right" "focus-right" + } +} +``` + +That chord is then gone from every program in every pane, because it has to be. +It is a footgun and it is yours; the cheatsheet lists direct bindings apart, +under "without the leader", so the `C-a then:` caption above stays true. + +## Every action + +Any of these can be bound, and any of them can be run from the palette without +being bound at all. + +| group | actions | +|---|---| +| panes | `split-cols` `split-rows` `close-pane` `rerun` `zoom` `minimize` `rotate-layout` | +| focus | `focus-left` `focus-right` `focus-up` `focus-down` `focus-next` `finder` | +| size | `resize-left` `resize-right` `resize-up` `resize-down` `equalize` | +| tabs | `new-tab` `next-tab` `prev-tab` `select-tab-1` … `select-tab-9` | +| scroll | `scroll-up` `scroll-down` `scroll-page-up` `scroll-page-down` `scroll-top` `scroll-bottom` | +| session | `palette` `help` `edit-config` `detach` `quit` `literal-prefix` | + +`scroll-up` and `scroll-down` have no default key — the wheel does that job — +which is exactly the case the palette exists for. diff --git a/docs/layouts.md b/docs/layouts.md new file mode 100644 index 0000000..36029ab --- /dev/null +++ b/docs/layouts.md @@ -0,0 +1,75 @@ +# Layouts + +A session can be a file, so a project's window layout is checked in with the +project. + +```kdl +layout { + tab name="api" cwd="~/dev/api" { + pane command="nvim" + pane split="rows" { + pane command="npm run dev" suspended=true + pane + } + } +} +``` + +```bash +sl0ppty --layout session.kdl +``` + +## The shape + +- `tab` — one per tab, in strip order. `name=`, `cwd=`, `purpose=`, + `active=true` for the one you land in. +- `pane` — a leaf, or a split when it has `pane` children. `split="cols"|"rows"` + says which way it divides; `weight=` is its share of the parent (even shares + are equal weights, so leaving it out means "even"). +- `command=` — what it runs. Without one, a pane runs your shell. +- `cwd=` — where it starts. Inherited from the tab when the pane does not say. +- `suspended=true` — laid out but running nothing until you touch it, so twelve + checked-out projects are not twelve running dev servers. The pane shows what it + *would* run. +- `focus=true` — the pane you start in, within its tab. +- `purpose=` — a label for tooling; see below. + +A full annotated example is +`config/layout.example.kdl`. + +## Writing one back out + +```bash +sl0ppty -s work cmd '{"cmd":"dump-layout"}' +``` + +writes the session as a layout file: tabs, splits, proportions, directories, +commands, which pane you were in. So a session can be checked in, or put back +after a restart. +`contrib/sl0ppty-dev` +is that loop, for when the thing you are rebuilding is sl0ppty itself. + +What a dump can honestly restore is the *shape*. What it cannot is the state +inside a program — a shell's history, a running editor — and it does not pretend +otherwise. A pane running the session's default shell is dumped as a pane with no +command, so restoring gives you a fresh one. + +## Applying one to a running session + +```bash +sl0ppty -s work cmd '{"cmd":"apply-layout","path":"session.kdl"}' +sl0ppty -s work cmd '{"cmd":"apply-layout","kdl":"layout { tab { pane } }","replace":true}' +``` + +Without `replace`, the tabs the file describes are added to what is already +there. + +## Purposes + +A pane or tab can carry a `purpose=` label — `agent:main`, `logs`, `db` — for +tooling to find it by. A purpose declared in a layout or over the control API is +*locked*: a program inside the pane cannot overwrite it, so identity comes from +the layout rather than from whatever the program decides to print. + +The [finder](panes.md#finding-a-pane) matches on purposes as well as titles, and +`{"cmd":"panes"}` reports them. diff --git a/docs/nav b/docs/nav new file mode 100644 index 0000000..ad025d8 --- /dev/null +++ b/docs/nav @@ -0,0 +1,24 @@ +# The order and grouping of the docs, and nothing else. +# +# A section is a line in column zero; a page is an indented filename. Titles +# come from each file's own `# heading`, so they cannot drift from the page. +# Read by contrib/gen-docs, which builds the static site. + +sl0ppty + index.md + +Using it + keys.md + panes.md + +Configuring it + config.md + shaders.md + chrome.md + +Driving it + layouts.md + scripting.md + +Inside + design.md diff --git a/docs/panes.md b/docs/panes.md new file mode 100644 index 0000000..388de89 --- /dev/null +++ b/docs/panes.md @@ -0,0 +1,93 @@ +# Panes and tabs + +A tab is a tree of splits. Panes keep running in every tab; only the one you are +looking at is drawn. + +## Arranging + +- **Split** with `C-a \` and `C-a -`. Splitting the same direction again gives + three equal shares rather than 1/2 + 1/4 + 1/4. +- **Resize** with `C-a H J K L`: the boundary moves the way you press, whichever + side of it you are on. Sizes are *weights*, so a resized layout survives the + window changing size. +- **`C-a =`** gives every visible pane an even share again — each split divided + between the panes behind it, so one pane beside a stack of three is a quarter + of the width rather than half of it. +- **`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. Four turns come back exactly. + A turn that would squeeze a pane under the floor is refused with a word. +- **`C-a z`** fills the tab with one pane and back; **`C-a m`** puts one away + into a strip along the bottom, still running. Click its row to bring it back. + +## The mouse + +Every mouse verb starts from the same list of rects the drawing filled in, so +nothing you click can disagree with what is on screen. + +- **Click a pane's border to split toward it** — the side you click is the side + the new pane appears on. Resting there first shows a dashed line where the + split would land. +- **Drag the gap between panes** to move that boundary. Where two gaps cross, + drag the crossing to move both at once. +- **Drag a pane by its title** onto another to swap them. Everything it could + land on greys out; the pane in your hand keeps its colour. +- **Double-click a name to rename it in place** — a pane's title or a tab in the + strip. Enter keeps it, Escape abandons it, empty gives it back to the program. +- **Drag a tab along the strip** to reorder it. The strip rearranges as you go + rather than dropping it at the end. +- Click a tab to switch, `+` to open one, a pane to focus it, and the marks in a + frame's corner to minimise (`▬`), zoom (`□`) or close (`✕`) it. +- Hovering anything says what it does, in a word, in the status line. + +## Finding a pane + +Tabs stop being navigation somewhere around six projects. `C-a f` opens a picker +over the whole session — every tab, including panes a small window has collapsed +out of sight. Type to narrow it by pane title, tab name or +[purpose](layouts.md#purposes); arrows, `C-n`/`C-p` or tab to move; enter to go. +A dot marks where you already are. + +## Panes that were given a command + +Split off a shell, do something, type `exit`: it closes, like a terminal should. +But a pane that was *told to run something* — from a [layout](layouts.md) or the +[control API](scripting.md) — keeps what it printed when that something exits, +with a line saying how it ended and two buttons: + +``` +╰ exited: status 3 ───────────────────────────────[re-run]─[close]─╯ +``` + +`[re-run]` (or `C-a r`) runs the same command again in the same pane, keeping +the previous run above it in the scrollback. So a command that failed while you +were looking elsewhere still has its error message when you get back, and a +mistyped command in a fresh session no longer closes the session. + +Which panes stay is one setting, `keep_dead`: `commands` (the default), `all`, +or `none`. + +## Small terminals + +When the panes no longer fit, the tab becomes a list of one-line headers with +the focused pane open below them — and returns to exactly the layout you had +when there is room again. Nothing is stored and restored: the arrangement is +recomputed from the tree every frame, and the list is what that function returns +when the room runs out. + +`min_pane` is where that starts happening; `min_split` is the smaller pane a +split is willing to *create*. + +## Text, images, bells + +- **Select text to copy it.** Release the mouse and it is on your clipboard, + sent to your terminal over ssh as well as locally. Middle click pastes. +- **Images work.** Kitty graphics pass through to your terminal, including the + half of the protocol other multiplexers drop, where a program uploads an image + once and places it every frame. Your terminal's cell size is carried through to + each pane, so a program can size an image without saying how many cells to + cover. PNG uploads are decoded here, because the terminal core ships no image + decoder. (Sixel is not supported.) +- **A pane that beeps says so** — in its title, in its tab, and until you look at + it. `bell_indicator false` makes a bell silent *and* invisible, which is a real + choice. A [chrome shader](chrome.md) can make it louder. diff --git a/docs/scripting.md b/docs/scripting.md new file mode 100644 index 0000000..0c27565 --- /dev/null +++ b/docs/scripting.md @@ -0,0 +1,75 @@ +# Scripting + +Three things make sl0ppty easy to drive from other programs: a socket that does +everything the keyboard does, an escape sequence a pane can use to draw its own +chrome, and a headless mode that is the whole program without a terminal. + +## The control socket + +One JSON object per line, over the session's socket. A detached session answers +exactly as an attached one does. + +```bash +$ sl0ppty -s work cmd '{"cmd":"new-tab","name":"api"}' +{"ok":true,"id":2} +$ sl0ppty -s work cmd '{"cmd":"panes"}' +{"ok":true,"panes":[{"id":1,"title":"nvim","alive":true,...}]} +``` + +Panes and tabs are addressed by **id**, so a background tab is scriptable. + +| verb | | +|---|---| +| `panes` `tabs` | what exists, with ids, rects, titles, purposes, state | +| `snapshot` | the composited screen, as JSON or `format:"text"` | +| `deadline` | when the session wants its next frame, in ms, or -1 | +| `send` | bytes as if typed, decoded like input (`"data":"\\x01\\\\"`) | +| `raw` | bytes straight into the focused pane's pty | +| `resize` | `cols` `rows`, and optionally `cell_w` `cell_h` | +| `split` | `dir:"cols"\|"rows"`, `id` for which pane to split | +| `focus` | `id` | +| `close` `rerun` | `id`, or 0 for the focused pane | +| `new-tab` `select-tab` `close-tab` `move-tab` `set-name` | tabs, by `id` or `index` | +| `set-purpose` | `target:"pane"\|"tab"`, `id`, `purpose` | +| `dump-layout` `apply-layout` | see [layouts](layouts.md) | +| `notify` | put a line in the session's status area | +| `graphics` | the kitty placements on screen, or the bytes sent for them | +| `clipboard` | what the session has copied | +| `reload` | re-read the config; answers `{"ok":true,"warning":...}` if it had a complaint | +| `edit-config` | open the config in a pane | +| `alive` | is it running, and how many panes and tabs | +| `quit` | end the session | + +## A pane can draw its own chrome + +By printing an escape sequence — no plugin, no config: + +```bash +printf '\033]5577;1;status;building 3/7\033\\' +printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\' +# clicking [Approve] arrives on the program's stdin as: +# \033]5577;1;click;approve\033\\ +``` + +The status text appears in the pane's frame; the buttons are real targets in it. +A program that wants to be asked something can ask *in place* rather than +printing a prompt and hoping. + +`purpose` is the other verb: `printf '\033]5577;1;purpose;logs\033\\'`. A purpose +declared by a layout or the control API wins and cannot be overwritten this way. + +## Headless + +`sl0ppty --script` is the whole program without a terminal: commands on stdin, +answers on stdout. It is how the test suite works — drive these events, assert +this screen — which is also why the suite is 700-odd real end-to-end checks that +finish in eight seconds. + +```bash +$ printf '%s\n' '{"cmd":"split","dir":"cols"}' '{"cmd":"snapshot","format":"text"}' \ + | sl0ppty --script --cols 80 --rows 24 -- /bin/sh +``` + +The bare-verb form (`snapshot text`, `send \x01\\`, `resize 100 30`) is a +human-friendly alias for the same code, so a script and a test cannot drift from +what the API does. diff --git a/docs/shaders.md b/docs/shaders.md new file mode 100644 index 0000000..7e32d09 --- /dev/null +++ b/docs/shaders.md @@ -0,0 +1,101 @@ +# Shaders + +A colour pass over a pane's cells: a pure function from (cell, position, +strength) to that cell's colours. It runs after the pane's contents are composed +and before the chrome that goes over them, which is why "contents, not chrome" +falls out of the paint order rather than needing a rule. For the frame, see +[chrome shaders](chrome.md). + +Cells only — foreground, background, attributes. Text and width are never +touched, because rewriting text would desync selection and copy. + +```kdl +shaders { // every pane gets these, in the order written + vignette amount=70 + ruler amount=60 at=80 color="#ff5fd7" +} +``` + +A chain is a sequence: `grayscale` then `tint` is not `tint` then `grayscale`. +Listing a shader twice is how you get two of it. An unknown name is refused with +a word and skipped, never guessed at. + +## The built-ins + +| shader | takes | does | +|---|---|---| +| `dim` | `amount` | darken towards black | +| `grayscale` | `amount` | drain colour, weighted for the eye | +| `tint` | `amount` `color=` | pull towards a colour | +| `vignette` | `amount` | darken towards the pane's edges | +| `gradient` | `amount` `direction=0..3` | fade towards the background | +| `zebra` | `amount` `band=1` | darken alternate bands; `band=1` is a scanline | +| `ruler` | `amount` `at=80` `color=` | mark a column, background only | +| `margin` | `amount` `at=100` | everything past a column recedes | +| `spotlight` | `amount` `radius=12` | brightness falls away from the cursor | + +`amount` is 0..255 and defaults to 128. Two props belong to the *pass* rather +than the shader and work on any of them: `where="content"|"chrome"` and +`channel="both"|"fg"|"bg"`. + +## Strength as an expression + +`amount` can be an expression instead of a number, evaluated for every cell. +That is how you get an effect nobody built in, without building anything: + +```kdl +shaders { + dim amount="(y % 2) * 40" // scanlines + dim amount="(x > cols - 10) * 120" // a right margin + tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7" +} +``` + +| | | +|---|---| +| variables | `x` `y` — the cell, inside the rect the pass runs over | +| | `cols` `rows` — the size of that rect | +| | `curx` `cury` `cursor` — the cursor, and whether this pane has it | +| | `focused` — 0 or 1 | +| | `t` — milliseconds, for animation | +| | `since` — milliseconds the pane has been in its current state | +| operators | `+ - * / %` (integer; division by zero is 0, not a crash) | +| | `< > <= >= == != && \|\| !` give 0 or 1, so `(x < 10) * 200` is a rule | +| | `a ? b : c` — both sides are evaluated, then one is chosen | +| functions | `min(a,b)` `max(a,b)` `abs(a)` `clamp(v,lo,hi)` | +| | `dist(x1,y1,x2,y2)` — counts a row double, because a cell is about twice as tall as it is wide | + +There are no loops and no recursion, on purpose: a config cannot spin and there +is nothing to sandbox. An expression that does not compile drops that one shader +with a warning — never the config, and never a half-strength version of what you +asked for. + +The expression produces the **strength**, never the colour. That keeps the mixing +in C, gives an expression no way to produce an invalid cell, and makes the result +cacheable: a program that does not read the clock is evaluated once per cell into +a map and reused, so a shader you wrote in your config costs about what a +compiled one does. Reading `t` or `since` costs a per-cell evaluation every +frame, which is the honest price of animation. + +## Ready-made + +Thirty-two presets are in +`contrib/shaders` — +a cursor line, a crosshair, a torch, a phosphor CRT, sonar pings that follow +your cursor. Each is a file you can paste into your config or `include`. + +`contrib/shader-tour` cycles a running session through them, and +`contrib/shadertoy.html` previews the language in a browser (a test cross-checks +that preview against the real compiler, so it cannot lie to you). + +## Your own, compiled + +A shader is a C function from one cell to that cell's colours. Any `*.so` in +`~/.config/sl0ppty/shaders/` that exports one is loaded at startup and named in +the config exactly like a built-in. Skeleton, Makefile and the rules a shader +has to keep: +`contrib/shader-plugin`. + +It is native code in the session's process, so install ones you trust — the same +standing as `shell` and a layout's `command=`, which can already run anything as +you. |
