aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-20 23:56:37 +0200
committeruser <user@clank>2026-08-20 23:56:37 +0200
commitc7a45c4ff8825ff564fa7852a08e9bbac2c2b7c8 (patch)
tree3002b2720e48d1b26775654f17ff7de7d88c27d1
parentslosh: M10c, the memory -- floats survive being written down (diff)
downloadslosh-c7a45c4ff8825ff564fa7852a08e9bbac2c2b7c8.tar.gz
slosh-c7a45c4ff8825ff564fa7852a08e9bbac2c2b7c8.tar.xz
slosh-c7a45c4ff8825ff564fa7852a08e9bbac2c2b7c8.zip
slosh: the scrolled edge fade earns its name
Four rows deep at 60 a row instead of three at 45: the edge row is nearly gone (#0e0e0e from white) and the melt reads as "there is more this way" rather than as a rendering artefact. The quarter-viewport cap, the (above > 0) gate and everything else about the derivation stand; a config that writes its own scrolled chain is untouched, as ever.
-rw-r--r--DESIGN.md2
-rw-r--r--config/config.kdl8
-rw-r--r--src/config.c14
-rw-r--r--tests/test_states.py2
4 files changed, 15 insertions, 11 deletions
diff --git a/DESIGN.md b/DESIGN.md
index e925aed..b6e60ba 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -158,7 +158,7 @@ unreadable.
| **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 two lines into its own backlog saying what it ran and how it ended (`[ran: npm run dev]`, `[process exited: status 3]`) -- the command because `[re-run]` is one button, and a pane that does not say what it would run makes pressing it a guess, most of all in a tab a project's layout built where the command was never typed into that pane to be scrolled back to; a pane running the session's shell has none to name and gets the exit line alone, 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. **`above`/`below` joined the environment for the scrolled edge fades**: lines of scrollback hidden past the viewport's top and bottom edges, read from the same scrollbar the ▲ indicator uses, with a dependency bit of their own (`EXPR_DEP_SCROLL`) so a cached map is keyed on the scroll position and reused while it holds still — they change per scroll event, not per frame, so they cost no clock. The compiled-in `scrolled` default uses them to melt the viewport's top and bottom rows where content continues, `min(3, rows / 4)` deep so a short viewport is not all fade; `(above > 0)` is a binary gate, so the edge you cannot scroll past renders solid, which is the message. They ride in `shade_ctx_t`, so `SLOSH_SHADER_ABI` went 3→4. |
+| **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. **`above`/`below` joined the environment for the scrolled edge fades**: lines of scrollback hidden past the viewport's top and bottom edges, read from the same scrollbar the ▲ indicator uses, with a dependency bit of their own (`EXPR_DEP_SCROLL`) so a cached map is keyed on the scroll position and reused while it holds still — they change per scroll event, not per frame, so they cost no clock. The compiled-in `scrolled` default uses them to melt the viewport's top and bottom rows where content continues, `min(4, rows / 4)` deep (60 a row -- it shipped shallower and softer, and read as a rendering artefact rather than as "there is more this way") so a short viewport is not all fade; `(above > 0)` is a binary gate, so the edge you cannot scroll past renders solid, which is the message. They ride in `shade_ctx_t`, so `SLOSH_SHADER_ABI` went 3→4. |
| **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. |
diff --git a/config/config.kdl b/config/config.kdl
index a39df7e..ef699ff 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -506,7 +506,9 @@ modal_scrim 120
// continues -- and each fade exists only while there is content past that
// edge, so hitting the top of the buffer turns the top edge solid, and
// returning to the present ends the state and everything is opaque again. Up
-// to three rows a side, never more than a quarter of the viewport each. All
+// to four rows a side, never more than a quarter of the viewport each --
+// the edge row nearly gone, so the melt is a statement rather than a
+// suspicion. All
// of it is commented below because naming a state replaces its default
// outright -- a declared copy would pin the wash to one theme's colour and
// quietly stop following the next.
@@ -517,8 +519,8 @@ states {
suspended { grayscale amount=170; dim amount=60 }
// scrolled { // derived: a wash of theme's scroll_bg, and edge fades where more content is
// tint amount=22 color="#7aa2f7"
- // dim amount="(above > 0) * max(0, (min(3, rows / 4) - y) * 45)"
- // dim amount="(below > 0) * max(0, (y - rows + min(3, rows / 4) + 1) * 45)"
+ // dim amount="(above > 0) * max(0, (min(4, rows / 4) - y) * 60)"
+ // dim amount="(below > 0) * max(0, (y - rows + min(4, rows / 4) + 1) * 60)"
// }
// dragging { }
// floating { }
diff --git a/src/config.c b/src/config.c
index f1361ef..ccbbc3a 100644
--- a/src/config.c
+++ b/src/config.c
@@ -739,13 +739,15 @@ static bool derived_pass(config_t *c, shader_t *sh, const char *kind,
* each edge, so `(above > 0)` snaps the top fade off at the top of the
* buffer: an edge you cannot scroll past renders solid, which is the whole
* message. (Reaching the bottom ends the state itself, same statement.) Up to
- * three rows a side and never more than a quarter of the viewport each,
- * because two three-row fades on a six-row viewport is every row faded and
- * nothing said. They ride after the wash so they dim washed cells rather
- * than racing it. */
-#define SCROLL_FADE_TOP "(above > 0) * max(0, (min(3, rows / 4) - y) * 45)"
+ * four rows a side and never more than a quarter of the viewport each,
+ * because two deep fades on a short viewport is every row faded and nothing
+ * said. They ride after the wash so they dim washed cells rather than racing
+ * it. 60 a row, so the edge row is nearly gone (240) and the melt is a
+ * statement rather than a suspicion -- it shipped at three rows of 45 and
+ * read as a rendering artefact more than as "there is more this way". */
+#define SCROLL_FADE_TOP "(above > 0) * max(0, (min(4, rows / 4) - y) * 60)"
#define SCROLL_FADE_BOTTOM \
- "(below > 0) * max(0, (y - rows + min(3, rows / 4) + 1) * 45)"
+ "(below > 0) * max(0, (y - rows + min(4, rows / 4) + 1) * 60)"
static void apply_scrolled(config_t *c, bool declared) {
if (declared) return;
diff --git a/tests/test_states.py b/tests/test_states.py
index a0f47f2..3d5340c 100644
--- a/tests/test_states.py
+++ b/tests/test_states.py
@@ -254,7 +254,7 @@ def test_the_scrolled_wash_follows_the_theme():
pane = s.pane()
s.send(r"\x01\x1b[5~") # C-a PgUp: into the scrollback
s.settle(20)
- # Mid-viewport, clear of the derived edge fades (three rows a
+ # Mid-viewport, clear of the derived edge fades (four rows a
# side): this test is about the wash, and the wash alone.
got = fg(s.snapshot(), pane, row=pane["content_h"] // 2)
return got