aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 23:25:46 +0200
committeruser <user@clank>2026-08-14 23:25:46 +0200
commitaa01b72770d83091a53a43722ecc9a527bc18931 (patch)
tree04a35b6ba2566a154cc344ebc0e031941cbce873 /src
parentsl0ppty: carry the terminal's cell size, so images can have a size (diff)
downloadslosh-aa01b72770d83091a53a43722ecc9a527bc18931.tar.gz
slosh-aa01b72770d83091a53a43722ecc9a527bc18931.tar.xz
slosh-aa01b72770d83091a53a43722ecc9a527bc18931.zip
sl0ppty: re-emit what the program meant, not what it covers
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.
Diffstat (limited to 'src')
-rw-r--r--src/app.c75
-rw-r--r--src/cmd.c15
-rw-r--r--src/graphics.c49
-rw-r--r--src/graphics.h36
-rw-r--r--src/pane.c33
-rw-r--r--src/sl0ppty.h10
6 files changed, 178 insertions, 40 deletions
diff --git a/src/app.c b/src/app.c
index d1a8fcd..97c4067 100644
--- a/src/app.c
+++ b/src/app.c
@@ -3191,25 +3191,70 @@ static void gfx_from_pane(pane_t *p, const pane_gfx_t *g, void *ud) {
if (g->col >= leaf->content.w || g->row >= leaf->content.h) return;
uint16_t cols = g->cols, rows = g->rows;
uint32_t sw = g->sw, sh = g->sh;
+ /* Cropping means moving the source rectangle, and how much source a lost
+ * cell is worth depends on whether the image is being scaled:
+ *
+ * natural size one source pixel is one screen pixel, so the crop is the
+ * screen pixels the pane has left -- measured from where
+ * the image starts, which is `x_off` into its first cell
+ * and not at the cell's edge. Cropping by whole cells alone
+ * let an offset image hang up to a cell over the border.
+ * scaled the program asked for c=/r=, so a cell is worth
+ * source/cells pixels and the crop is proportional. Taking
+ * screen pixels off a scaled image would crop far too much
+ * -- a 4px image across 40 cells loses its whole self. */
if (g->col + cols > leaf->content.w) {
- uint16_t over = (uint16_t)(g->col + cols - leaf->content.w);
- cols = (uint16_t)(cols - over);
- uint32_t px = over * g->cell_px_w;
- sw = sw > px ? sw - px : 0;
+ uint16_t avail = (uint16_t)(leaf->content.w - g->col);
+ if (g->req_cols) {
+ sw = cols ? (uint32_t)((uint64_t)sw * avail / cols) : sw;
+ } else {
+ uint32_t px = (uint32_t)avail * g->cell_px_w;
+ px = px > g->x_off ? px - g->x_off : 0;
+ if (sw > px) sw = px;
+ }
+ cols = avail;
}
if (g->row + rows > leaf->content.h) {
- uint16_t over = (uint16_t)(g->row + rows - leaf->content.h);
- rows = (uint16_t)(rows - over);
- uint32_t px = over * g->cell_px_h;
- sh = sh > px ? sh - px : 0;
+ uint16_t avail = (uint16_t)(leaf->content.h - g->row);
+ if (g->req_rows) {
+ sh = rows ? (uint32_t)((uint64_t)sh * avail / rows) : sh;
+ } else {
+ uint32_t px = (uint32_t)avail * g->cell_px_h;
+ px = px > g->y_off ? px - g->y_off : 0;
+ if (sh > px) sh = px;
+ }
+ rows = avail;
}
if (!cols || !rows) return;
- gfx_place(c->a->gfx, leaf->id, g->image_id, g->generation, g->place_id,
- (uint16_t)(leaf->content.x + g->col),
- (uint16_t)(leaf->content.y + g->row), cols, rows, g->sx, g->sy, sw,
- sh, g->src_w, g->src_h, g->format, g->compression, g->data,
- g->data_len);
+ gfx_place(c->a->gfx, &(gfx_req_t){
+ .pane = leaf->id,
+ .src_id = g->image_id,
+ .gen = g->generation,
+ .place_id = g->place_id,
+ .col = (uint16_t)(leaf->content.x + g->col),
+ .row = (uint16_t)(leaf->content.y + g->row),
+ .cols = cols,
+ .rows = rows,
+ /* Carried through untouched: the pane's own clipping already dealt with
+ * it, and the offset is relative to the first cell either way. */
+ .x_off = g->x_off,
+ .y_off = g->y_off,
+ /* Clipped the same way the cell counts were, and zero when the program
+ * never asked to scale. */
+ .scale_cols = g->req_cols ? cols : 0,
+ .scale_rows = g->req_rows ? rows : 0,
+ .sx = g->sx,
+ .sy = g->sy,
+ .sw = sw,
+ .sh = sh,
+ .px_w = g->src_w,
+ .px_h = g->src_h,
+ .format = g->format,
+ .compression = g->compression,
+ .data = g->data,
+ .data_len = g->data_len,
+ });
}
static void gfx_leaf_cb(node_t *n, void *ud) {
@@ -3245,6 +3290,10 @@ char *app_graphics_json(app_t *a) {
json_int(&j, "y", places[i].row);
json_int(&j, "cols", places[i].cols);
json_int(&j, "rows", places[i].rows);
+ /* Reported so a test can see sub-cell motion, which is otherwise only
+ * visible as "the picture moves smoothly" on somebody's screen. */
+ json_int(&j, "x_off", places[i].x_off);
+ json_int(&j, "y_off", places[i].y_off);
json_obj_close(&j);
}
json_arr_close(&j);
diff --git a/src/cmd.c b/src/cmd.c
index 2292b52..618669c 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -220,6 +220,21 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
}
if (strcmp(cmd, "graphics") == 0) {
app_compose(a, s);
+ /* `format:"bytes"` answers with what the client's terminal is actually
+ * sent, escape sequences and all. The model and the bytes are two
+ * different things, and this session was one long lesson in asserting on
+ * the model while the bytes were wrong. */
+ if (strcmp(jv_gets(req, "format", "placements"), "bytes") == 0) {
+ size_t len = 0;
+ const char *raw = app_graphics(a, &len);
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ json_str(&j, "bytes", raw ? raw : "", raw ? len : 0);
+ json_obj_close(&j);
+ return j.buf;
+ }
return jok_raw("placements", app_graphics_json(a));
}
if (strcmp(cmd, "clipboard") == 0) {
diff --git a/src/graphics.c b/src/graphics.c
index bf9aa1d..d3fc07c 100644
--- a/src/graphics.c
+++ b/src/graphics.c
@@ -127,11 +127,10 @@ static void transmit(graphics_t *g, gfx_image_t *img, uint32_t px_w,
img->sent = true;
}
-void gfx_place(graphics_t *g, uint32_t pane, uint32_t src_id, uint64_t gen,
- uint32_t place_id, uint16_t col, uint16_t row, uint16_t cols,
- uint16_t rows, uint32_t sx, uint32_t sy, uint32_t sw,
- uint32_t sh, uint32_t px_w, uint32_t px_h, int format,
- int compression, const uint8_t *data, size_t data_len) {
+void gfx_place(graphics_t *g, const gfx_req_t *req) {
+ if (!g || !req) return;
+ const uint32_t pane = req->pane, src_id = req->src_id;
+ const uint64_t gen = req->gen;
gfx_image_t *img = img_find(g, pane, src_id);
if (!img) {
if (g->nimgs == g->imgcap) {
@@ -147,9 +146,10 @@ void gfx_place(graphics_t *g, uint32_t pane, uint32_t src_id, uint64_t gen,
img->sent = false;
}
if (!img->sent)
- transmit(g, img, px_w, px_h, format, compression, data, data_len);
+ transmit(g, img, req->px_w, req->px_h, req->format, req->compression,
+ req->data, req->data_len);
- uint32_t pid = place_id ? place_id : 1;
+ uint32_t pid = req->place_id ? req->place_id : 1;
gfx_place_t *slot = NULL;
for (size_t i = 0; i < g->nplaces; i++)
if (g->places[i].out_id == img->out_id && g->places[i].place_id == pid)
@@ -164,14 +164,18 @@ void gfx_place(graphics_t *g, uint32_t pane, uint32_t src_id, uint64_t gen,
slot->out_id = img->out_id;
slot->place_id = pid;
}
- slot->col = col;
- slot->row = row;
- slot->cols = cols;
- slot->rows = rows;
- slot->sx = sx;
- slot->sy = sy;
- slot->sw = sw;
- slot->sh = sh;
+ slot->col = req->col;
+ slot->row = req->row;
+ slot->cols = req->cols;
+ slot->rows = req->rows;
+ slot->x_off = req->x_off;
+ slot->y_off = req->y_off;
+ slot->scale_cols = req->scale_cols;
+ slot->scale_rows = req->scale_rows;
+ slot->sx = req->sx;
+ slot->sy = req->sy;
+ slot->sw = req->sw;
+ slot->sh = req->sh;
slot->live = true;
}
@@ -194,8 +198,19 @@ char *gfx_flush(graphics_t *g, size_t *out_len) {
for (size_t i = 0; i < g->nplaces; i++) {
gfx_place_t *p = &g->places[i];
out_fmt(g, "\x1b[%u;%uH", p->row + 1, p->col + 1);
- out_fmt(g, "\x1b_Ga=p,q=2,C=1,i=%u,p=%u,c=%u,r=%u", p->out_id, p->place_id,
- p->cols, p->rows);
+ out_fmt(g, "\x1b_Ga=p,q=2,C=1,i=%u,p=%u", p->out_id, p->place_id);
+ /* c=/r= mean *scale into this many cells*, so they are passed on only
+ * when the program asked for them. Sending the cell count a natural-size
+ * image happens to cover looks identical in a still picture and makes a
+ * moving one change size, because that count goes up by one whenever the
+ * image straddles one more cell boundary. */
+ if (p->scale_cols || p->scale_rows)
+ out_fmt(g, ",c=%u,r=%u", p->scale_cols, p->scale_rows);
+ /* Where the image sits inside its first cell. Without these an image can
+ * only ever land on a cell boundary, so anything moving smoothly arrives
+ * in steps of a whole cell -- eight pixels across, sixteen down. */
+ if (p->x_off) out_fmt(g, ",X=%u", p->x_off);
+ if (p->y_off) out_fmt(g, ",Y=%u", p->y_off);
/* The source rectangle is what actually crops; c/r alone would scale. */
if (p->sw || p->sh)
out_fmt(g, ",x=%u,y=%u,w=%u,h=%u", p->sx, p->sy, p->sw, p->sh);
diff --git a/src/graphics.h b/src/graphics.h
index e31b733..cc495ec 100644
--- a/src/graphics.h
+++ b/src/graphics.h
@@ -32,6 +32,13 @@ typedef struct {
uint32_t place_id;
uint16_t col, row; /* screen cells */
uint16_t cols, rows;
+ /* Where the image starts *inside* its first cell, in pixels. This is what
+ * makes motion smooth instead of snapping a cell at a time, and dropping it
+ * is invisible in a still picture and obvious the moment anything moves. */
+ uint32_t x_off, y_off;
+ /* Cells to scale into, or 0 to draw at natural size. Only sent on when the
+ * program asked for it. */
+ uint16_t scale_cols, scale_rows;
uint32_t sx, sy, sw, sh; /* source rectangle, in image pixels */
bool live;
} gfx_place_t;
@@ -41,13 +48,32 @@ void gfx_free(graphics_t *g);
/* Start a frame: everything placed last frame is marked stale. */
void gfx_begin(graphics_t *g);
+/* Everything gfx_place() needs: where it goes, what it crops to, and the
+ * image itself for the first time it is seen.
+ *
+ * A struct rather than the twenty positional arguments this used to be. Half
+ * of them are uint32_t pixel quantities, so a transposed pair compiles
+ * perfectly and shows up as a picture in slightly the wrong place — which is
+ * exactly the bug class this file is for. */
+typedef struct {
+ uint32_t pane; /* which pane it belongs to */
+ uint32_t src_id; /* the id the program chose */
+ uint64_t gen; /* image generation, so a changed image is re-sent */
+ uint32_t place_id;
+ uint16_t col, row; /* screen cells */
+ uint16_t cols, rows; /* cells covered */
+ uint16_t scale_cols, scale_rows; /* cells to scale into, 0 for natural */
+ uint32_t x_off, y_off; /* offset within the first cell, in pixels */
+ uint32_t sx, sy, sw, sh; /* source rectangle, in image pixels */
+ uint32_t px_w, px_h; /* the image's own size */
+ int format, compression;
+ const uint8_t *data; /* NULL once the client has it */
+ size_t data_len;
+} gfx_req_t;
+
/* Record a visible placement, transmitting the image if the client has not
* seen it yet. `data` may be NULL when the image is already transmitted. */
-void gfx_place(graphics_t *g, uint32_t pane, uint32_t src_id, uint64_t gen,
- uint32_t place_id, uint16_t col, uint16_t row, uint16_t cols,
- uint16_t rows, uint32_t sx, uint32_t sy, uint32_t sw,
- uint32_t sh, uint32_t px_w, uint32_t px_h, int format,
- int compression, const uint8_t *data, size_t data_len);
+void gfx_place(graphics_t *g, const gfx_req_t *req);
/* End a frame: emit transmits, placements and deletions. Caller frees. */
char *gfx_flush(graphics_t *g, size_t *out_len);
diff --git a/src/pane.c b/src/pane.c
index 38df2ed..e540049 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -593,11 +593,20 @@ size_t pane_graphics(pane_t *p, pane_gfx_fn cb, void *ud) {
size_t n = 0;
while (ghostty_kitty_graphics_placement_next(it)) {
- uint32_t image_id = 0, place_id = 0;
+ uint32_t image_id = 0, place_id = 0, x_off = 0, y_off = 0;
ghostty_kitty_graphics_placement_get(
it, GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, &image_id);
ghostty_kitty_graphics_placement_get(
it, GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, &place_id);
+ ghostty_kitty_graphics_placement_get(
+ it, GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, &x_off);
+ ghostty_kitty_graphics_placement_get(
+ it, GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET, &y_off);
+ uint32_t req_cols = 0, req_rows = 0;
+ ghostty_kitty_graphics_placement_get(
+ it, GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS, &req_cols);
+ ghostty_kitty_graphics_placement_get(
+ it, GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS, &req_rows);
GhosttyKittyGraphicsImage img = ghostty_kitty_graphics_image(gfx, image_id);
GhosttyKittyGraphicsPlacementRenderInfo info =
@@ -621,21 +630,31 @@ size_t pane_graphics(pane_t *p, pane_gfx_fn cb, void *ud) {
uint32_t sw = info.source_width, sh = info.source_height;
int32_t vc = info.viewport_col, vr = info.viewport_row;
uint32_t cols = info.grid_cols, rows = info.grid_rows;
+ /* Clipping consumes the sub-cell offset first. The image starts `x_off`
+ * pixels into its origin cell, so dropping `off` whole cells from the
+ * left skips `off * cw - x_off` pixels of image, and what remains begins
+ * exactly on a cell boundary — offset zero from there on. */
if (vc < 0) {
uint32_t off = (uint32_t)(-vc);
if (off >= cols) continue;
- sx += off * cw;
- sw = sw > off * cw ? sw - off * cw : 0;
+ uint32_t skip = off * cw > x_off ? off * cw - x_off : 0;
+ sx += skip;
+ sw = sw > skip ? sw - skip : 0;
cols -= off;
+ if (req_cols) req_cols = req_cols > off ? req_cols - off : 0;
vc = 0;
+ x_off = 0;
}
if (vr < 0) {
uint32_t off = (uint32_t)(-vr);
if (off >= rows) continue;
- sy += off * ch;
- sh = sh > off * ch ? sh - off * ch : 0;
+ uint32_t skip = off * ch > y_off ? off * ch - y_off : 0;
+ sy += skip;
+ sh = sh > skip ? sh - skip : 0;
rows -= off;
+ if (req_rows) req_rows = req_rows > off ? req_rows - off : 0;
vr = 0;
+ y_off = 0;
}
if (!cols || !rows) continue;
@@ -646,6 +665,10 @@ size_t pane_graphics(pane_t *p, pane_gfx_fn cb, void *ud) {
.row = (uint16_t)vr,
.cols = (uint16_t)cols,
.rows = (uint16_t)rows,
+ .x_off = x_off,
+ .y_off = y_off,
+ .req_cols = (uint16_t)req_cols,
+ .req_rows = (uint16_t)req_rows,
.px_w = info.pixel_width,
.px_h = info.pixel_height,
.sx = sx,
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index 2e56577..fb7ba2d 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -179,6 +179,16 @@ void pane_set_notify_handler(pane_t *p, pane_notify_fn fn, void *ud);
typedef struct {
uint32_t image_id, place_id;
uint16_t col, row, cols, rows;
+ /* Where the image starts inside its first cell, in pixels. Sub-cell
+ * positioning: what a program moving something smoothly relies on, and the
+ * difference between motion and a slideshow. */
+ uint32_t x_off, y_off;
+ /* What the program asked to scale into, or 0 for "draw it at its natural
+ * size". The difference matters on the way out: naming a cell count tells
+ * the terminal to *scale* the image into it, and the count a natural
+ * placement happens to cover changes by one as it slides across a cell
+ * boundary — so passing it on makes a moving image change size. */
+ uint16_t req_cols, req_rows;
uint32_t px_w, px_h; /* rendered size */
uint32_t src_w, src_h; /* the image's own size */
/* The part of the image to draw, in pixels. Clipping has to move this: