<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/test_graphics.py, branch docs-new</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/tests/test_graphics.py?h=docs-new</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/test_graphics.py?h=docs-new'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-29T19:31:41Z</updated>
<entry>
<title>graphics: a frame the client never got must be said again</title>
<updated>2026-08-29T19:31:41Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-29T19:31:41Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=8a8b5e1c7acd269d4a639a40712412a84480ad18'/>
<id>urn:sha1:8a8b5e1c7acd269d4a639a40712412a84480ad18</id>
<content type='text'>
A pane image is re-emitted to the client as decoded RGBA, which for one
screenshot is ~4MB of base64 in a single indivisible message -- and
conn_send silently dropped anything that would overflow the 4MB outbox,
while push_graphics ignored the refusal and the model advanced anyway.
Everything stateful in that dropped frame was lost for good: the image
was marked sent (so its placements drew nothing, forever), and the
deletions rode out with it (so a placement the client had was never
revoked). Reported as screenshots in a recovered pi transcript parked
at a fixed spot on the screen, immune to scrolling and pane swaps --
those were placements whose deletion left with a dropped frame. The
1000x800 one could never be delivered at all: its transmission alone
was bigger than the cap.

So the frame is now transactional. Deletions queue and are repeated
every flush until gfx_commit() confirms the client heard them;
transmissions recorded in a frame are unwound by an undelivered commit
so they are sent again. A flush nobody commits counts as undelivered at
the next gfx_begin(), which is what the `graphics` control command is:
bytes rendered for a CLI, not the client. The server reports the
verdict and keeps the paint pending until it lands.

The outbox cap now bounds the backlog rather than backlog-plus-message,
so one big transmission on a drained queue always fits -- and that
surfaced a second way to lose: a queued image legitimately holds the
backlog over the cap until the client swallows it, and push_frame read
that as a dead client and closed the connection. The session
auto-detached every time a screenshot crossed the wire. A full outbox
is a client that is *behind*, not gone: push_frame now marks the screen
for a full repaint (the dropped diff means the client's cells are
unknown) and retries as POLLOUT drains the queue; a client that truly
left still shows up as a write error there.

The headless suite pins the owed-until-committed contract; the new
tests/live_graphics.py drives the real socket with a
bigger-than-the-cap image and a reader that stalls, and would have
caught both bugs: the flood must not detach, the transmission must
arrive whole, and a placement scrolled away must be deleted -- then
come back on scroll without a second copy of the pixels.
</content>
</entry>
<entry>
<title>slosh: M10c, the memory -- floats survive being written down</title>
<updated>2026-08-20T21:53:43Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-20T21:53:43Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=64db2051192a6a6f5a9333f503d14b1ccccd699d'/>
<id>urn:sha1:64db2051192a6a6f5a9333f503d14b1ccccd699d</id>
<content type='text'>
A dumped session quietly re-tiled its floats, which was the one data-loss
gap left in D22. dump-layout now records `floating=true x= y= w= h=` -- the
*wanted* rect, because intent is what survives a session and the next
screen's layout clamps it like any frame -- layout files declare the same
(linted: floating is a bool, x/y/w/h mean nothing without it), and a file
whose every pane floats self-heals through ensure_tiled: the top float
lands, the same rule closing the last tiled pane follows, no lint required.
save-workspace inherits all of it from the shared dumper.

`float` over the API takes x/y/w/h and then *places* rather than toggles --
a placement is a statement about where, not about whether -- floating the
pane first when it is tiled, re-placing when it already floats, with
omitted fields meaning "keep". The intent is stored exactly as asked: the
write-back rule is for hands, whose drags need the clamp to agree with the
eye; a script means what it typed.

And kitty graphics respect the stack. The cell compositor gets occlusion
free from paint order, but placements are sent after the diff and would
paint an image straight over a float covering it. Each pane's placements
are now clipped by the float rects above it -- every float for a tiled
pane, the higher-raised for a float -- cropping what one clean edge can
express (the pane-edge source-rectangle arithmetic aimed at another
clipper, including from the top and left, where the source origin moves and
the sub-cell offset is consumed) and suppressing what it cannot: a strip
across the middle or a corner overlap leaves a shape one placement cannot
say, and the image returns whole when the float moves. No z= needed --
clipping is the z-order, since nothing is ever allowed to overlap.

D22's "still owed" paragraph is paid and rewritten; M10 is fully checked.
</content>
</entry>
<entry>
<title>sl0ppty: ruff format the Python</title>
<updated>2026-08-17T12:36:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T12:36:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c0022776d0912042819e4ea330cf7491a8196b45'/>
<id>urn:sha1:c0022776d0912042819e4ea330cf7491a8196b45</id>
<content type='text'>
The bumpy one, again. Every `.py` outside `vendor/` plus the three `contrib/`
scripts that are Python with no extension: 60 files, 6,714 lines rewritten, no
behaviour changed. Nothing here is worth reading -- the next commit adds it to
`.git-blame-ignore-revs` so `git blame` walks past it.

It is a bigger reformat than the C was, 60% of the lines against 17%, and for a
reason worth writing down rather than tuning away: ruff and black break a call that
does not fit onto one argument per line, where this code aligned the continuation
under the opening paren. The test suite is thousands of `check("some prose", cond,
detail)` calls, so nearly every one of them changed shape and the suite grew 3,308
lines. Line length was the one lever that moved it -- 120 columns would have been a
40% reformat and no growth at all -- and it was left at 88, because picking an
unusual width to dodge a one-time diff is paying forever to avoid paying once.

Import sorting came with it. Two files had `import os, pty, uuid, select, struct,
sys, termios, time, fcntl, signal` on one line and now have ten.

Verified after, not before, because a formatter that alters behaviour would be
worse than none:

- `make retest`, 1801 checks green -- and this reformats the suite that does the
  checking, including `harness.py`, which every one of those files imports.
- `make test-live`, the tty tests the fast suite skips. They matter here more than
  usual: `live_input.py` and `live_m0.py` are two of the files whose comma-imports
  were split, and nothing in the fast suite would have noticed if that broke them.
- All 60 files still compile, `make fmt` twice in a row changes nothing the second
  time, and `ruff check --select I` has nothing left to say.
</content>
</entry>
<entry>
<title>sl0ppty: answer the terminal when it is asked how big it is</title>
<updated>2026-08-15T17:38:38Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T17:38:38Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=e275a776406343a3a0476d557a5d8c724ccf3593'/>
<id>urn:sha1:e275a776406343a3a0476d557a5d8c724ccf3593</id>
<content type='text'>
XTWINOPS (CSI 14/16/18 t) went unanswered. A program that draws images
needs the cell size to keep an aspect ratio; the pty's pixel fields are
the first way to get it (D17) and this query is the second, which is what
a program reaches for when those come back zeroed -- as they do on the
far side of an ssh hop. Unanswered, the query times out and the program
falls back to a guess: ida-tui guesses 10x20, and against a real 9x22
cell that is every image stretched by a fifth, which looks like a bug in
the program rather than in us.

Found alongside the png bug and from the same program, which asks
`CSI 16 t` in the same round trip as its graphics query -- so once the
png decoded, the picture would have appeared at the wrong shape.

lib-vt encodes and writes the reply once the callback says what the
numbers are; they come from the pane's own geometry, so a program is told
about the pane it is in rather than the window. Mode 2048 (the in-band
report on resize) already worked, because that one is answered from the
size passed to ghostty_terminal_resize.

Also notes in graphics.c what re-emission actually costs: we send decoded
pixels because that is the only form lib-vt keeps, so an 819KB png
reaches the client as 3.3MB of base64, once per image and again after a
reattach clears `sent`. Fine for a splash, and written down so the next
person to wonder does not have to measure it again.
</content>
</entry>
<entry>
<title>sl0ppty: decode the PNGs programs actually send</title>
<updated>2026-08-15T17:14:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T17:14:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2f0b3426d0f40d2cf53740cb597495af2c45bc2e'/>
<id>urn:sha1:2f0b3426d0f40d2cf53740cb597495af2c45bc2e</id>
<content type='text'>
Kitty graphics were green in the suite and a real program still drew
nothing. libghostty-vt ships no image codec: built as a library it sets
its `sys.decode_png` hook to null, and a null hook rejects every `f=100`
transmission outright -- no image stored, so no placement exists, so the
re-emission layer this project is mostly *about* is never reached. The
program gets no error and no picture.

Raw RGB worked throughout, which is why it looked done: every test here
sent raw pixels, because that is what is easy to write in a test. It is
the same lesson the last kitty graphics bug taught -- write the test the
way a program actually behaves -- and a program with a picture on disk
has a PNG. Found because ida-tui's startup logo never appeared, and its
detection probe answers `OK` first, so it fails silently rather than
falling back to its block art.

PNG is the whole of the gap and cannot grow: the protocol has exactly
three formats (`f=24`, `f=32`, `f=100`), the first two are raw pixels
lib-vt already handles, and `decode_png` is the only codec hook it has.
Decoding only, never encoding.

The decoder is the stb_image lib-vt already vendors and we already
commit -- upstream compiles that same header with the same
`STBI_ONLY_PNG` for this same purpose, it is just not in the
`-Demit-lib-vt` build. So nothing new is pulled in; a file we already
ship is finally compiled. src/png.c is the hook, a header pre-check and
a copy: dimensions are read before decoding because the buffer we return
is allocated through lib-vt's limited allocator but stb's own working
allocation is plain malloc and answers to nothing.

Tests cover a PNG placed at the size it asked for (RGB and RGBA sources,
both normalised to RGBA), the client being handed `f=32` rather than the
png it was given, and a malformed png placing nothing without taking the
pane with it. They fail on the previous binary.
</content>
</entry>
<entry>
<title>sl0ppty: the graphics stream gives the cursor back</title>
<updated>2026-08-14T21:32:44Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T21:32:44Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=7acf78ba9cb24b2fb93dca6163358d4cf69e73b2'/>
<id>urn:sha1:7acf78ba9cb24b2fb93dca6163358d4cf69e73b2</id>
<content type='text'>
Reported as "the cursor is not rendered in zsh" in a newly spawned pane. It
was not about zsh, or about that pane: it was about the pane next to it,
which was animating an image.

Placing an image means parking the cursor on its target cell first, and the
graphics bytes go out *after* the cell diff -- deliberately, so a repainted
cell cannot land on top of a placement. That is also after the frame has put
the real cursor where the focused pane wants it. So with any image on screen,
the last instruction the terminal received every frame was "go to wherever
that image is", and the cursor sat there: in another pane, moving about with
the picture. With a bouncing DVD logo next door it is quite a sight.

gfx_begin() now opens the stream with DECSC and gfx_flush() closes it with
DECRC, so the whole thing is cursor-neutral no matter what it contains. The
save has to be written at the start of the frame rather than at flush time,
because transmissions are emitted as images are recorded and are already in
the buffer by then. A frame with nothing to say drops both and still emits
not one byte, which the test asserts, because a save/restore pair on every
idle frame is exactly the sort of cost that creeps in unnoticed.

Two tests: that the stream is bracketed (and that it really does move the
cursor, or the assertion proves nothing), and that an idle frame is empty.

One C trap worth the comment it now has: "\x1b7" is a *single* hex escape,
0x1b7, not ESC followed by 7. The compiler catches that one because it is out
of range -- and would not catch "\x1b1", which compiles quietly into one
wrong byte. Escape then digit is always two strings.
</content>
</entry>
<entry>
<title>sl0ppty: re-emit what the program meant, not what it covers</title>
<updated>2026-08-14T21:25:46Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T21:25:46Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=aa01b72770d83091a53a43722ecc9a527bc18931'/>
<id>urn:sha1:aa01b72770d83091a53a43722ecc9a527bc18931</id>
<content type='text'>
Images rendered after the last commit, and were still wrong twice. Both
mistakes are invisible in a still picture, which is what every test was
looking at, and obvious the moment anything moves.

**The sub-cell offset was dropped.** A placement says where the image starts
*inside* its first cell (`X=`/`Y=`); that is what makes motion smooth rather
than a slideshow. We tracked the placement and re-emitted it without those,
so a bouncing logo moved in steps of a whole cell -- eight pixels across,
sixteen down.

**`c=`/`r=` were passed on unconditionally.** They mean *scale the image into
this many cells*, and the count a natural-size image happens to cover goes up
by one whenever it straddles one more boundary -- so the picture changed size
as it slid along. They are now sent only when the program asked for them,
which lib-vt reports as 0 when it did not.

Cropping splits the same way, and getting it wrong took a test failure to
notice: at natural size a lost cell costs the screen pixels the pane has left
(measured from the offset, not the cell edge, or an offset image hangs up to
a cell over the border); when the program asked to scale, a lost cell is
worth source/cells pixels and the crop is proportional. Cropping a 4-pixel
image scaled across 40 cells by screen pixels removes all of it.

gfx_place() took twenty positional arguments, half of them uint32_t pixel
quantities where a transposed pair compiles perfectly and lands the picture
slightly wrong -- the exact bug class this file exists for. It takes a struct
now.

`{"cmd":"graphics","format":"bytes"}` answers with the escape sequences the
client is actually sent. The model being right is not the same as the bytes
being right, and this whole episode was a lesson in asserting on the first
while the second was wrong. The new tests assert on the bytes.
</content>
</entry>
<entry>
<title>sl0ppty: carry the terminal's cell size, so images can have a size</title>
<updated>2026-08-14T21:13:56Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T21:13:56Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=4b380e9b4cbd24dca99bef4991541aa1aece847f'/>
<id>urn:sha1:4b380e9b4cbd24dca99bef4991541aa1aece847f</id>
<content type='text'>
"Kitty graphics pass through" was true of the tests and not of anything else.
Every test transmitted and placed in one command with explicit c=/r=; real
programs upload once with `a=t` and place per frame with `a=p`, saying
nothing about cells, and that path was broken from end to end.

A placement that does not say how many cells it covers is sized from the
image's pixels and the cell size. We never had a cell size:

- pty_spawn and pty_resize left ws_xpixel/ws_ypixel at zero, so a program
  asking its own tty how big a cell is got nothing and fell back to guessing;
- ghostty_terminal_resize was called with 0, 0 for the cell size, so lib-vt
  computed zero cells for such a placement -- and a placement covering zero
  cells is skipped, which on screen is an image that simply is not there.

So the size now runs the whole way through. The client reads it from
TIOCGWINSZ (re-read on every resize, because a window can move to a monitor
with different metrics), MSG_HELLO and MSG_RESIZE carry it -- a 4-byte
payload still means "I did not say", so an older client still attaches -- 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. That is the whole
lesson of this bug: a plausible cell size means an image appears at roughly
the right size, and a zero means it does not appear at all.

`{"cmd":"resize"}` takes optional cell_w/cell_h, so a script can be a client
that knows its own metrics, and so the tests can be one.

The tests now use the protocol the way programs use it, including a check
that a program can read real pixel dimensions out of its own pty -- the thing
that was silently zero. The remaining half of the fix is one layer down, in
vendor/patches/0001.
</content>
</entry>
<entry>
<title>sl0ptty: kitty graphics passthrough</title>
<updated>2026-08-13T23:01:04Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T23:01:04Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b6c175e58ee21e2532d2826411a5351d9a676c09'/>
<id>urn:sha1:b6c175e58ee21e2532d2826411a5351d9a676c09</id>
<content type='text'>
A program in a pane transmits an image and it appears. libghostty-vt parses
the protocol and tracks images and placements; what a multiplexer has to add
is re-emitting them to the client, which is exactly what tmux and zellij do
not do — and the reason tools fall back to sixel under them.

Per frame: walk the visible tab's panes, transmit each image once (a=t),
place every visible placement (a=p,C=1) after the cell diff so a repainted
cell cannot land on top of one, and delete placements that vanished. Image ids
are remapped into a high range, so two panes both using image 7 stay two
images and neither collides with what the client's terminal already had. A
reattaching client is told everything again, because it has seen nothing.

The cropping rule is the part worth remembering: a placement is N pixels drawn
across M cells, so the placement itself tells you the pixels per cell — the
client's font metrics never come into it. Clipping at a pane edge, or at the
top of the viewport when a tall image is partly scrolled off, moves the source
rectangle; asking for fewer columns or rows alone scales the image into them
instead of cropping it. The first version did exactly that, and dropped
partially-visible placements entirely rather than cropping them.

Tests cover the multiplexer-specific case first: two panes, both using image
id 7, must get different client ids. Then placement position in screen cells,
following the layout, cropping at edges and when taller than the pane, nothing
from collapsed panes, and removal when a pane closes or the image scrolls
away. A live check confirms the wire: transmitted once, re-placed per frame,
not re-transmitted.

416 checks green (389 headless + 27 live).
</content>
</entry>
</feed>
