aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 23:13:56 +0200
committeruser <user@clank>2026-08-14 23:13:56 +0200
commit4b380e9b4cbd24dca99bef4991541aa1aece847f (patch)
treefcd41dfdf23f17027523336dbe448ee683e72c16
parentvendor: keep kitty images across a screen clear (diff)
downloadslosh-4b380e9b4cbd24dca99bef4991541aa1aece847f.tar.gz
slosh-4b380e9b4cbd24dca99bef4991541aa1aece847f.tar.xz
slosh-4b380e9b4cbd24dca99bef4991541aa1aece847f.zip
sl0ppty: carry the terminal's cell size, so images can have a size
"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.
-rw-r--r--DESIGN.md3
-rw-r--r--HANDOVER.md34
-rw-r--r--README.md6
-rw-r--r--src/app.c34
-rw-r--r--src/app.h6
-rw-r--r--src/client.c32
-rw-r--r--src/cmd.c6
-rw-r--r--src/pane.c31
-rw-r--r--src/proto.h9
-rw-r--r--src/pty.c19
-rw-r--r--src/server.c8
-rw-r--r--src/sl0ppty.h12
-rw-r--r--tests/test_graphics.py86
13 files changed, 262 insertions, 24 deletions
diff --git a/DESIGN.md b/DESIGN.md
index fd83b26..4ff2480 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -154,11 +154,12 @@ unreadable.
| **D9** | **No wasm plugins.** Status bar and pane finder are built in. A "plugin" is a subprocess speaking the control protocol — with exactly one exception, shaders, for the reason in D15. |
| **D10** | **One attached client** in the MVP. The protocol allows N (read-only observers are what sl0p.foo actually wants); the multi-user *rendering* path is not built, because it is where the fork's phantom-client and `MY FOCUS AND:` bugs live. |
| **D11** | **No terminfo/ncurses.** We are opinionated about the outer terminal: emit a known-good modern subset, probe with DA/XTGETTCAP where we must. |
-| **D12** | **Vendor lib-vt source**, pinned by commit in `vendor/libghostty-vt.vendor.json`, with a patches file if we ever need one. zig is a build-time dependency only, and doubles as our C compiler and static/musl cross-linker. |
+| **D12** | **Vendor lib-vt source**, pinned by commit in `vendor/libghostty-vt.vendor.json`, with a patches file if we ever need one. We needed one: `vendor/patches/0001` stops a screen clear from freeing transmitted kitty images, which broke every program that uploads once and places per frame (see D17). Patches are applied to the committed tree and listed in the vendor json, so a build has them and a re-vendor knows what to re-apply. zig is a build-time dependency only, and doubles as our C compiler and static/musl cross-linker. |
| **D13** | **Shaders are a colour pass over pane *contents*, never our chrome.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so "contents, not chrome" is the paint order, not a rule anyone has to remember. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. 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 outlives its program.** When the program in a pane exits, the pane stays: it keeps everything it printed, writes one line into its own backlog saying how it ended (`[process exited: status 3]`, `[process exited: signal 9]`), says the same thing in its frame and in the status line, and offers the only two verbs still meaningful — `[re-run]` and `[close]`. Re-running spawns the same argv in the same cwd, in the *same pane*: same id, same place in the tree, same terminal, so the run that ended stays above the new one in the scrollback and nothing that referred to that pane has to be told anything. The old behaviour reaped the pane before the next paint, which meant the last thing a failing command printed vanished together with the status that would have explained it, and a mistyped command in a fresh session closed the session. `keep_dead false` restores it. Two consequences are load-bearing rather than incidental: the pty fd is **closed** the moment EOF is seen, because an EOF fd left in the poll set is readable forever and would spin a session at 100% CPU behind a pane that looks idle; and the exit status is collected with a **bounded** wait, never a blocking one, because a program that closes its terminal and keeps running must not be able to hang the session — losing that race costs the words "status 0" and nothing else. |
| **D15** | **Shaders may be loaded as native shared libraries**, from a directory beside the config. This is the one hole in D9 and it is argued for rather than drifted into (and D16 has since removed most of the *need* for it). A shader is called per cell, per pass, per frame — tens of thousands of times at up to 120Hz — so the subprocess model D9 prescribes cannot reach that path at all, and the measured alternative, interpreting a bytecode per cell, is 56ns against 8ns for the compiled function. What is loaded is also the narrowest possible thing: a pure function from one cell and its position to that cell's colours, unable to touch text, width, neighbours, panes or the session, because `shader_abi.h` is the only header it sees and nothing else is declared in it. On trust: the config file can already say `shell` and a layout can already say `command=`, so a file in the config directory can already run anything as you — a `.so` in the same directory is not a new boundary. It is refused unless it declares our ABI version *and* matching struct sizes, so a stale plugin fails at load instead of garbling colours. Nothing is ever unloaded: a `shade_fn` may be sitting in a live config or on a pane, so `dlclose()` would be a jump into an unmapped page — a reload therefore picks up newly *added* plugins only, and replacing one needs a new session. |
| **D16** | **A shader's `amount` may be an expression**, compiled to bytecode at config load. The scope is the argument: every built-in here is already `colour_op(amount)` with the amount a pure function of position, so the part worth handing to a config is the *number*, not the colour maths. That keeps mixing in C, gives an expression no way to produce an invalid cell (it produces one byte), and makes the result cacheable — a program that does not read the clock is evaluated once per cell into a map and reused until something it reads changes, so a config-written positional shader measures 5.2ns/cell against a compiled vignette's 6.1ns. Reading the clock costs 35ns/cell, per frame, which is the honest price of animation and is opt-in. The language is **total**: no loops, no recursion, no jumps — even `?:` evaluates both sides and selects, because nothing in it has an effect. So evaluation is O(program length), a config cannot spin, and there is nothing to sandbox; division by zero is 0 rather than a trap, because an expression that is wrong should look wrong rather than take the session with it. What a program reads is **derived by the compiler**, not declared, and that dependency set *is* the cache key — a stale map is unrepresentable rather than merely unlikely, which is the same rule the layout follows. An expression that does not compile drops that one shader with a warning: not the config, and not a half-strength version of what was asked for. |
+| **D17** | **Images need pixels, and we were not carrying any.** Kitty graphics worked in the test suite and not in real life, because three separate things all quietly assumed a cell has no size. `pty_spawn`/`pty_resize` left `ws_xpixel`/`ws_ypixel` zero, so a program asking the tty how big a cell is got nothing and guessed. `ghostty_terminal_resize` was called with `0, 0` for the cell size, so lib-vt could not work out how many cells an image covers when the placement did not say — and a placement that covers zero cells is dropped, which is a picture that silently does not appear. And the vendored lib freed image *data* on `ED 2`, so a full-screen program lost everything it had uploaded. The fix runs the whole way through: the client reads its terminal's pixel size (`TIOCGWINSZ`, re-read on every resize because a window can move to another monitor), `MSG_HELLO`/`MSG_RESIZE` carry it, the app hands it to every pane in every tab, and each pane gives it to both its pty and its terminal. Where nobody knows, the default is **8x16 rather than 0**: a plausible cell means an image appears at roughly the right size, and zero means it does not appear at all. Tested end to end, including that a program can read real pixel dimensions out of its own pty. |
### D6 — responsive layout as a pure function
diff --git a/HANDOVER.md b/HANDOVER.md
index ca0531b..6b4ae7d 100644
--- a/HANDOVER.md
+++ b/HANDOVER.md
@@ -163,6 +163,35 @@ program actually reads -- the same reason the layout recomputes rather than
remembers. If you add a variable to the language, add its dependency flag in
the same table, and the cache stays correct for free.
+## Kitty graphics: it looked done and was not
+
+The test suite was green and the feature did not work, because every test
+transmitted *and* placed in one command (`a=T`) with explicit `c=`/`r=`. Real
+programs use the other half of the protocol -- upload once with `a=t`, place
+per frame with `a=p`, no cell counts -- and every part of that was broken:
+
+- `pty_spawn`/`pty_resize` never set `ws_xpixel`/`ws_ypixel`, so a program
+ asking its tty how big a cell is got zeroes and guessed;
+- `ghostty_terminal_resize` was called with `0, 0` for the cell size, so
+ lib-vt could not size a placement that did not say how many cells it wanted
+ -- it computed zero cells, and we skipped it;
+- the vendored lib freed image *data* on `ED 2`, so a full-screen program lost
+ everything it had uploaded (`vendor/patches/0001`).
+
+Three layers, one symptom: nothing on screen. Worth knowing for next time --
+**the shape of the test decided what worked**. If you add a protocol here,
+write the test the way a program actually uses it, not the way that is easiest
+to write.
+
+The debugging that found it is worth repeating rather than reinventing:
+capture the real program's byte stream off a pty, replay it into a headless
+session (it reproduces exactly), then bisect the stream. `.scratch/capfile.py`
+and friends do this and are gitignored; the whole loop is about a minute.
+
+The 8x16 default matters. Where nobody knows the cell size -- headless, or a
+terminal that will not say -- a plausible cell means images appear at roughly
+the right size, and a zero means they do not appear at all.
+
## Things left on the table
- **A `reload` keybinding.** The config watcher made it less pressing.
@@ -183,6 +212,11 @@ the same table, and the cache stays correct for free.
pane. Hot-swapping would need every shader reference to be indirected
through the registry, which is a real change and not obviously worth it.
- **Per-row shader dispatch** — see the numbers above.
+- **The cell size is not queried, only received.** If a client's terminal
+ reports no pixel size in `TIOCGWINSZ`, we keep the 8x16 default rather than
+ asking it with `CSI 14 t`/`CSI 16 t`. Doing that properly means the client
+ parsing a reply, and the client deliberately does not decode anything (D7).
+ The honest fix is a config key.
- **Expressions cannot make a colour**, only a strength (D16). `tint` takes a
fixed `color=`; a gradient *between* two colours needs either three
expressions (one per channel) or a plugin. Worth doing only if somebody
diff --git a/README.md b/README.md
index b365f9c..f0e3b8e 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,11 @@ mistyped command in a fresh session no longer closes the session.
- **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. (Sixel does not.)
+ 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. (Sixel is not
+ supported: the terminal core has no sixel decoder.)
- **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.
diff --git a/src/app.c b/src/app.c
index 3ea5a55..d1a8fcd 100644
--- a/src/app.c
+++ b/src/app.c
@@ -180,6 +180,12 @@ struct app {
uint32_t next_tab_id;
uint32_t next_id;
uint16_t cols, rows;
+ /* The client's cell size in pixels, as reported when it attached. Panes are
+ * told so that a program can size an image, and so lib-vt can work out how
+ * many cells an image covers when the program did not say. Defaulted rather
+ * than zeroed: 0 means "no image can be placed", which is not a good thing
+ * for a headless session or a terminal that will not say. */
+ uint16_t cell_w, cell_h;
bool prefix;
bool quit;
bool detach;
@@ -675,6 +681,7 @@ static node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd,
pane_set_osc_handler(p, on_pane_osc, a);
pane_set_clipboard_handler(p, on_pane_clipboard, a);
pane_set_notify_handler(p, on_pane_notify, a);
+ pane_set_cell_px(p, a->cell_w, a->cell_h);
return n;
}
@@ -689,6 +696,7 @@ static node_t *leaf_new(app_t *a) {
pane_set_osc_handler(p, on_pane_osc, a);
pane_set_clipboard_handler(p, on_pane_clipboard, a);
pane_set_notify_handler(p, on_pane_notify, a);
+ pane_set_cell_px(p, a->cell_w, a->cell_h);
return n;
}
@@ -735,6 +743,11 @@ app_t *app_new(const char *const argv[], uint16_t cols, uint16_t rows) {
a->argv = argv;
a->cols = cols;
a->rows = rows;
+ /* A plausible cell until a client says otherwise, because zero is not a
+ * neutral default here: it is the value that makes every naturally-sized
+ * image cover no cells and quietly not appear. */
+ a->cell_w = 8;
+ a->cell_h = 16;
a->gfx = gfx_new();
tab_add(a, "");
a->cur = 0;
@@ -1205,6 +1218,27 @@ void app_resize(app_t *a, uint16_t cols, uint16_t rows) {
layout(a);
}
+static void set_cell_px_cb(node_t *n, void *ud) {
+ const uint16_t *px = ud;
+ pane_set_cell_px(n->pane, px[0], px[1]);
+}
+
+void app_set_cell_px(app_t *a, uint16_t w, uint16_t h) {
+ if (!w || !h || (w == a->cell_w && h == a->cell_h)) return;
+ a->cell_w = w;
+ a->cell_h = h;
+ uint16_t px[2] = {w, h};
+ /* Every pane, not just the visible ones: a background tab's program may be
+ * drawing images too, and it should not have to be looked at to find out
+ * how big a cell is. */
+ walk_all(a, set_cell_px_cb, px);
+}
+
+void app_cell_px(const app_t *a, uint16_t *w, uint16_t *h) {
+ if (w) *w = a->cell_w;
+ if (h) *h = a->cell_h;
+}
+
/* ---- tree edits --------------------------------------------------------- */
static void replace_child(node_t *parent, node_t *old, node_t *new_) {
diff --git a/src/app.h b/src/app.h
index e118691..f220eb6 100644
--- a/src/app.h
+++ b/src/app.h
@@ -42,6 +42,12 @@ char *app_take_clipboard(app_t *a);
/* Re-read the config file. Keeps the working one if the new file is broken. */
bool app_reload_config(char *err, size_t errcap);
void app_resize(app_t *a, uint16_t cols, uint16_t rows);
+/* The attached client's cell size in pixels, passed on to every pane's pty and
+ * terminal. Images need it: a placement that does not say how many cells it
+ * covers is sized from the image's pixels and this. Ignored if either is 0,
+ * so a client that cannot find out leaves the default standing. */
+void app_set_cell_px(app_t *a, uint16_t w, uint16_t h);
+void app_cell_px(const app_t *a, uint16_t *w, uint16_t *h);
/* Pump every pane that has data. Returns true if anything changed. */
bool app_pump_fd(app_t *a, int fd);
diff --git a/src/client.c b/src/client.c
index a3ad73d..2c22af9 100644
--- a/src/client.c
+++ b/src/client.c
@@ -41,20 +41,32 @@ static void restore(void) {
(void)r;
}
-static void term_size(uint16_t *cols, uint16_t *rows) {
+/* The terminal's size, in cells and — when it will say — in pixels per cell.
+ * Most terminals fill in the pixel fields; the ones that do not report zero,
+ * which is passed on as "I do not know" rather than as a size. */
+static void term_size(uint16_t *cols, uint16_t *rows, uint16_t *cell_w,
+ uint16_t *cell_h) {
struct winsize ws;
+ *cell_w = *cell_h = 0;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col && ws.ws_row) {
*cols = ws.ws_col;
*rows = ws.ws_row;
+ if (ws.ws_xpixel && ws.ws_ypixel) {
+ *cell_w = (uint16_t)(ws.ws_xpixel / ws.ws_col);
+ *cell_h = (uint16_t)(ws.ws_ypixel / ws.ws_row);
+ }
} else {
*cols = 80;
*rows = 24;
}
}
-static void send_size(int fd, uint8_t type, uint16_t cols, uint16_t rows) {
- uint8_t b[4] = {(uint8_t)(cols >> 8), (uint8_t)cols, (uint8_t)(rows >> 8),
- (uint8_t)rows};
+static void send_size(int fd, uint8_t type, uint16_t cols, uint16_t rows,
+ uint16_t cell_w, uint16_t cell_h) {
+ uint8_t b[8] = {(uint8_t)(cols >> 8), (uint8_t)cols,
+ (uint8_t)(rows >> 8), (uint8_t)rows,
+ (uint8_t)(cell_w >> 8), (uint8_t)cell_w,
+ (uint8_t)(cell_h >> 8), (uint8_t)cell_h};
msg_send(fd, type, b, sizeof b);
}
@@ -71,8 +83,8 @@ static void write_all(int fd, const void *buf, size_t len) {
}
int client_run(int fd) {
- uint16_t cols, rows;
- term_size(&cols, &rows);
+ uint16_t cols, rows, cell_w, cell_h;
+ term_size(&cols, &rows, &cell_w, &cell_h);
if (tcgetattr(STDIN_FILENO, &g_saved) != 0) {
fprintf(stderr, "sl0ppty: not a terminal\n");
@@ -93,7 +105,7 @@ int client_run(int fd) {
sigaction(SIGHUP, &sa, NULL);
signal(SIGPIPE, SIG_IGN);
- send_size(fd, MSG_HELLO, cols, rows);
+ send_size(fd, MSG_HELLO, cols, rows, cell_w, cell_h);
msg_reader_t reader;
msg_reader_init(&reader);
@@ -108,8 +120,10 @@ int client_run(int fd) {
if (g_winch) {
g_winch = 0;
- term_size(&cols, &rows);
- send_size(fd, MSG_RESIZE, cols, rows);
+ /* A resize can also be a move to another monitor, so the cell size is
+ * re-read rather than remembered from the first frame. */
+ term_size(&cols, &rows, &cell_w, &cell_h);
+ send_size(fd, MSG_RESIZE, cols, rows, cell_w, cell_h);
}
if (n < 0) {
if (errno == EINTR) continue;
diff --git a/src/cmd.c b/src/cmd.c
index f23c6f4..2292b52 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -127,6 +127,12 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
if (!c || !r) return jerr("bad size");
screen_resize(s, c, r);
app_resize(a, c, r);
+ /* The cell size in pixels, which a real client reports when it attaches.
+ * Here so that a script -- and the test suite -- can be a client that
+ * knows its own metrics, since images are sized against these. */
+ uint16_t cw = (uint16_t)jv_geti(req, "cell_w", 0);
+ uint16_t ch = (uint16_t)jv_geti(req, "cell_h", 0);
+ app_set_cell_px(a, cw, ch);
return jok_int(NULL, 0);
}
if (strcmp(cmd, "split") == 0) {
diff --git a/src/pane.c b/src/pane.c
index eac7f92..38df2ed 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -26,6 +26,12 @@ struct pane {
GhosttyMouseEncoder menc;
GhosttyMouseEvent mev;
uint16_t cols, rows_n;
+ /* The client's cell size in pixels. A default rather than a zero, because
+ * zero is what makes an image vanish: a placement given no explicit cell
+ * count is sized from the image's pixels and this, and 0 covers no cells.
+ * 8x16 is a plausible terminal cell, and any client that knows better says
+ * so the moment it attaches. */
+ uint16_t cell_w, cell_h;
bool alive;
/* How the program ended. `exit_known` is separate from `!alive` because the
* two really are different: EOF on the master says the pane is over, and
@@ -225,7 +231,7 @@ bool pane_start(pane_t *p) {
if (!p->suspended) return false;
p->suspended = false;
if (pty_spawn(&p->pty, (const char *const *)p->argv, p->cols, p->rows_n,
- p->cwd) != 0) {
+ p->cwd, p->cell_w, p->cell_h) != 0) {
p->alive = false;
return false;
}
@@ -263,6 +269,8 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
pane_t *p = calloc(1, sizeof *p);
p->cols = cols;
p->rows_n = rows;
+ p->cell_w = 8;
+ p->cell_h = 16;
p->pty.fd = -1;
if (ghostty_terminal_new(NULL, &p->term, cols, rows) != GHOSTTY_SUCCESS)
@@ -313,7 +321,8 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
p->argv = argv_dup(argv);
p->cwd = cwd ? strdup(cwd) : NULL;
- if (pty_spawn(&p->pty, argv, cols, rows, cwd) != 0) goto fail;
+ if (pty_spawn(&p->pty, argv, cols, rows, cwd, p->cell_w, p->cell_h) != 0)
+ goto fail;
p->alive = true;
p->dirty = true;
return p;
@@ -423,7 +432,7 @@ bool pane_restart(pane_t *p) {
p->exit_code = 0;
if (pty_spawn(&p->pty, (const char *const *)p->argv, p->cols, p->rows_n,
- p->cwd) != 0)
+ p->cwd, p->cell_w, p->cell_h) != 0)
return false;
/* The terminal is deliberately not cleared: the run that ended, and the
* line saying it ended, stay above this one in the scrollback. That is the
@@ -789,8 +798,20 @@ void pane_resize(pane_t *p, uint16_t cols, uint16_t rows) {
if (cols == p->cols && rows == p->rows_n) return;
p->cols = cols;
p->rows_n = rows;
- ghostty_terminal_resize(p->term, cols, rows, 0, 0);
- if (p->pty.fd >= 0) pty_resize(&p->pty, cols, rows);
+ /* The cell size goes in every time: it is what lets lib-vt work out how
+ * many cells an image covers when the program did not say. Passing 0 here
+ * is why kitty graphics looked implemented and drew nothing. */
+ ghostty_terminal_resize(p->term, cols, rows, p->cell_w, p->cell_h);
+ if (p->pty.fd >= 0) pty_resize(&p->pty, cols, rows, p->cell_w, p->cell_h);
+ p->dirty = true;
+}
+
+void pane_set_cell_px(pane_t *p, uint16_t w, uint16_t h) {
+ if (!w || !h || (w == p->cell_w && h == p->cell_h)) return;
+ p->cell_w = w;
+ p->cell_h = h;
+ ghostty_terminal_resize(p->term, p->cols, p->rows_n, w, h);
+ if (p->pty.fd >= 0) pty_resize(&p->pty, p->cols, p->rows_n, w, h);
p->dirty = true;
}
diff --git a/src/proto.h b/src/proto.h
index ac68d44..3f39d0e 100644
--- a/src/proto.h
+++ b/src/proto.h
@@ -11,10 +11,15 @@
#include <stddef.h>
#include <stdint.h>
+/* MSG_HELLO and MSG_RESIZE carry u16 cols, u16 rows, and since the graphics
+ * fix two more: u16 cell_w, u16 cell_h, the client's cell size in pixels.
+ * A four-byte payload is still accepted and means "I did not say", which is
+ * what an older client sends and what a terminal that reports no pixel size
+ * leads to. */
enum {
- MSG_HELLO = 1, /* client -> server: u16 cols, u16 rows */
+ MSG_HELLO = 1, /* client -> server: u16 cols, rows[, cell_w, cell_h] */
MSG_INPUT = 2, /* client -> server: raw bytes from the terminal */
- MSG_RESIZE = 3, /* client -> server: u16 cols, u16 rows */
+ MSG_RESIZE = 3, /* client -> server: u16 cols, rows[, cell_w, cell_h] */
MSG_DETACH = 4, /* client -> server: leave, but keep running */
MSG_OUTPUT = 5, /* server -> client: bytes for the terminal */
MSG_EXIT = 6, /* server -> client: u8 reason */
diff --git a/src/pty.c b/src/pty.c
index 09d7ea6..59448df 100644
--- a/src/pty.c
+++ b/src/pty.c
@@ -15,7 +15,7 @@
#include <unistd.h>
int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
- const char *cwd) {
+ const char *cwd, uint16_t cell_w, uint16_t cell_h) {
int master = posix_openpt(O_RDWR | O_NOCTTY);
if (master < 0) return -1;
if (grantpt(master) < 0 || unlockpt(master) < 0) {
@@ -29,7 +29,14 @@ int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
return -1;
}
- struct winsize ws = {.ws_col = cols, .ws_row = rows};
+ /* The pixel fields matter: a program drawing images asks the tty how big a
+ * cell is, and a winsize that says zero is a program that cannot size an
+ * image. They are the *terminal's* pixels, so they are cols/rows times the
+ * cell size the client reported. */
+ struct winsize ws = {.ws_col = cols,
+ .ws_row = rows,
+ .ws_xpixel = (unsigned short)(cols * cell_w),
+ .ws_ypixel = (unsigned short)(rows * cell_h)};
ioctl(master, TIOCSWINSZ, &ws);
pid_t pid = fork();
@@ -71,8 +78,12 @@ int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
return 0;
}
-int pty_resize(pty_t *p, uint16_t cols, uint16_t rows) {
- struct winsize ws = {.ws_col = cols, .ws_row = rows};
+int pty_resize(pty_t *p, uint16_t cols, uint16_t rows, uint16_t cell_w,
+ uint16_t cell_h) {
+ struct winsize ws = {.ws_col = cols,
+ .ws_row = rows,
+ .ws_xpixel = (unsigned short)(cols * cell_w),
+ .ws_ypixel = (unsigned short)(rows * cell_h)};
return ioctl(p->fd, TIOCSWINSZ, &ws);
}
diff --git a/src/server.c b/src/server.c
index 9bddd0d..498b83f 100644
--- a/src/server.c
+++ b/src/server.c
@@ -346,6 +346,14 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
screen_resize(&s.screen, c, rr);
app_resize(s.app, c, rr);
}
+ /* The cell size arrived with it, from a client new enough to
+ * send one and a terminal willing to say. Zero means neither,
+ * and app_set_cell_px leaves the default standing. */
+ if (m.len >= 8) {
+ uint16_t cw = (uint16_t)(m.data[4] << 8 | m.data[5]);
+ uint16_t ch = (uint16_t)(m.data[6] << 8 | m.data[7]);
+ app_set_cell_px(s.app, cw, ch);
+ }
}
s.screen.force_full = true; /* a fresh client knows nothing */
app_graphics_reset(s.app); /* including any image we had sent */
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index f7b7ee6..2e56577 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -84,9 +84,13 @@ typedef struct {
pid_t pid;
} pty_t;
+/* `cell_w`/`cell_h` are the client's cell size in pixels, so the pty's
+ * winsize carries real pixel dimensions: a program that draws images asks the
+ * tty how big a cell is, and zeroes there mean it cannot size one. */
int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
- const char *cwd);
-int pty_resize(pty_t *p, uint16_t cols, uint16_t rows);
+ const char *cwd, uint16_t cell_w, uint16_t cell_h);
+int pty_resize(pty_t *p, uint16_t cols, uint16_t rows, uint16_t cell_w,
+ uint16_t cell_h);
void pty_close(pty_t *p);
/* ---- pane: a pty + a libghostty-vt terminal ---------------------------- */
@@ -144,6 +148,10 @@ void pane_send_key(pane_t *p, const input_event_t *ev);
void pane_send_mouse(pane_t *p, const input_event_t *ev);
void pane_send_paste(pane_t *p, const char *text, size_t len);
void pane_resize(pane_t *p, uint16_t cols, uint16_t rows);
+/* The client's cell size in pixels. Everything about images depends on it:
+ * without it a placement that asks for its natural size covers zero cells and
+ * is dropped, which is a picture that silently does not appear. */
+void pane_set_cell_px(pane_t *p, uint16_t w, uint16_t h);
bool pane_dirty(pane_t *p);
/* Scrollback. Negative delta scrolls up (towards older output). */
void pane_scroll(pane_t *p, int delta);
diff --git a/tests/test_graphics.py b/tests/test_graphics.py
index 586c6f7..3665631 100644
--- a/tests/test_graphics.py
+++ b/tests/test_graphics.py
@@ -24,6 +24,16 @@ def places(s):
return s.api("graphics")["placements"]
+def transmits_then_places(place_keys, before_place="", image_id=7):
+ """The other half of the protocol: upload once with `a=t`, place later
+ with `a=p`. Everything a program that draws repeatedly does."""
+ t = f"\\033_Ga=t,f=24,s=4,v=2,i={image_id},q=2;{PX}\\033\\\\"
+ p = f"\\033_Ga={place_keys}\\033\\\\"
+ return ["/bin/sh", "-c",
+ f'stty raw -echo; printf "{t}"; printf "{before_place}"; '
+ f'printf "{p}"; sleep 5']
+
+
def test_a_pane_image_reaches_the_screen():
with Session(sends_image(), cols=44, rows=10) as s:
s.settle(200)
@@ -39,6 +49,77 @@ def test_a_pane_image_reaches_the_screen():
(pl[0]["cols"], pl[0]["rows"]) == (6, 2), str(pl[0]))
+def test_a_placement_that_does_not_say_how_big_it_is():
+ """`c=`/`r=` are optional: without them the image covers as many cells as
+ its pixels need, which the terminal can only work out if it knows how big
+ a cell is. We were passing 0 -- so every such image covered no cells and
+ silently did not appear, which is most of what "kitty graphics works"
+ was worth."""
+ with Session(transmits_then_places("p,i=7,p=1,q=2"), cols=44, rows=10) as s:
+ s.settle(200)
+ pl = places(s)
+ check("it is placed", len(pl) == 1, str(pl))
+ if not pl:
+ return
+ # 4x2 pixels at the default 8x16 cell: one cell each way.
+ check("sized from its pixels and the cell size",
+ (pl[0]["cols"], pl[0]["rows"]) == (1, 1), str(pl[0]))
+
+
+def test_the_cell_size_a_client_reports_is_what_sizes_an_image():
+ with Session(transmits_then_places("p,i=7,p=1,q=2"), cols=44, rows=10) as s:
+ s.settle(200)
+ s.api("resize", cols=44, rows=10, cell_w=2, cell_h=1)
+ s.settle(150)
+ pl = places(s)
+ # The same 4x2 image against a 2x1 cell is 2 cells wide and 2 tall.
+ check("a different cell means a different number of cells",
+ pl and (pl[0]["cols"], pl[0]["rows"]) == (2, 2), str(pl))
+
+
+def test_a_program_can_read_the_pixel_size_from_its_pty():
+ """TIOCGWINSZ has pixel fields, and a program that draws images reads
+ them. Zeroes there are why dvd.py fell back to guessing 8x16."""
+ prog = ("import fcntl, termios, struct, sys, time;"
+ "r, c, xp, yp = struct.unpack('HHHH', "
+ "fcntl.ioctl(0, termios.TIOCGWINSZ, b'\\0' * 8));"
+ "print('WS', c, r, xp, yp); sys.stdout.flush(); time.sleep(5)")
+ with Session(["python3", "-c", prog], cols=50, rows=10) as s:
+ snap = s.until_text("WS ")
+ line = [l for l in snap.text if "WS " in l][0].strip()
+ cols, rows, xp, yp = (int(v) for v in line.split()[1:5])
+ check("the pty carries pixel dimensions", xp and yp, line)
+ check("consistent with the cell size we were told",
+ xp == cols * 8 and yp == rows * 16, line)
+
+
+def test_an_image_outlives_a_screen_clear():
+ """A program transmits once and places every frame, and full-screen
+ programs clear the screen. libghostty-vt freed the image data on ED(2),
+ so every placement after the first clear drew nothing -- see
+ vendor/patches. The placements go, the image stays."""
+ with Session(transmits_then_places("p,i=7,p=1,q=2,c=6,r=2",
+ before_place="\\033[2J"),
+ cols=44, rows=10) as s:
+ s.settle(200)
+ check("a clear before the placement does not lose the image",
+ len(places(s)) == 1, str(places(s)))
+
+
+def test_a_screen_clear_still_removes_what_is_on_screen():
+ argv = ["/bin/sh", "-c",
+ f'stty raw -echo; printf "\\033_Ga=T,f=24,s=4,v=2,i=7,q=2,c=6,r=2;{PX}\\033\\\\"; '
+ f'sleep 0.2; printf "\\033[2J"; printf CLEARED; sleep 5']
+ with Session(argv, cols=44, rows=10) as s:
+ # Both waits are on something observable: the image arrives, then the
+ # clear that follows it. A settle would race the sleep between them.
+ s.until(lambda _: len(places(s)) == 1)
+ check("placed to begin with", len(places(s)) == 1, str(places(s)))
+ s.until_text("CLEARED")
+ check("the placement is gone with the screen it was on",
+ len(places(s)) == 0, str(places(s)))
+
+
def test_ids_cannot_collide_between_panes():
"""Two panes, both using image id 7. They must not become one image."""
with Session(sends_image(image_id=7), cols=90, rows=12) as s:
@@ -140,6 +221,11 @@ def test_scrolled_away_placements_are_dropped():
if __name__ == "__main__":
test_a_pane_image_reaches_the_screen()
+ test_a_placement_that_does_not_say_how_big_it_is()
+ test_the_cell_size_a_client_reports_is_what_sizes_an_image()
+ test_a_program_can_read_the_pixel_size_from_its_pty()
+ test_an_image_outlives_a_screen_clear()
+ test_a_screen_clear_still_removes_what_is_on_screen()
test_ids_cannot_collide_between_panes()
test_placement_follows_the_layout()
test_cropped_at_the_pane_edge()