diff options
| author | user <user@clank> | 2026-09-07 15:52:00 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-07 15:52:00 +0200 |
| commit | e9147fba11b735259a90089dd7207853ff49a3ad (patch) | |
| tree | 6dc86b56987c7bb2e2dba4a3151cf4576efab2b0 | |
| parent | screen: commit a wide cell's tail to prev, or the diff leaves a hole (diff) | |
| parent | app: cut the modals, toasts and splash out of the images under them (diff) | |
| download | slosh-e9147fba11b735259a90089dd7207853ff49a3ad.tar.gz slosh-e9147fba11b735259a90089dd7207853ff49a3ad.tar.xz slosh-e9147fba11b735259a90089dd7207853ff49a3ad.zip | |
Merge branch 'graphics-fixes' (PR #2 from iksteen): five fixes to the kitty passthrough, found through a mirror
One synchronized message per frame with cells and images together, a
placement said once rather than every frame, z=0 stated so a mirror
knows where the picture goes, a float cutting an image into pieces
instead of erasing it, and overlays cut out of the images under them.
Found running slosh inside shellglass, real on a terminal too.
| -rw-r--r-- | include/app.h | 3 | ||||
| -rw-r--r-- | include/app_internal.h | 16 | ||||
| -rw-r--r-- | include/graphics.h | 26 | ||||
| -rw-r--r-- | src/app.c | 10 | ||||
| -rw-r--r-- | src/app_draw.c | 275 | ||||
| -rw-r--r-- | src/app_ui.c | 7 | ||||
| -rw-r--r-- | src/graphics.c | 96 | ||||
| -rw-r--r-- | src/server.c | 112 | ||||
| -rw-r--r-- | tests/live_graphics.py | 67 | ||||
| -rw-r--r-- | tests/test_graphics.py | 110 |
10 files changed, 551 insertions, 171 deletions
diff --git a/include/app.h b/include/app.h index 6cf6886..106d54a 100644 --- a/include/app.h +++ b/include/app.h @@ -29,6 +29,9 @@ const char *app_graphics_view(app_t *a, graphics_t *state, uint16_t x, uint16_t y, uint16_t cols, uint16_t rows, size_t *len); void app_graphics_reset(app_t *a); +/* One client's own graphics state, after its screen was cleared: the + * placements it was holding went with the clear and are owed again. */ +void app_graphics_view_repaint(graphics_t *state); /* Did the last app_graphics() stream reach the client? Undelivered frames * are regenerated: transmissions repeat and deletions stay owed. */ void app_graphics_commit(app_t *a, bool delivered); diff --git a/include/app_internal.h b/include/app_internal.h index 38a1a6d..af59b6f 100644 --- a/include/app_internal.h +++ b/include/app_internal.h @@ -10,6 +10,10 @@ #include "expr.h" #include "shader.h" +/* How many overlays one frame can have cut out of its images: the modal box + * (one at a time), three toasts, the splash, and room to spare. */ +#define APP_MAX_OVERLAYS 8 + /* A chain attached to one pane, with the programs its expressions compiled to * kept beside it. Together because they are one lifetime: freeing the chain * without the programs leaks, and freeing the programs without the chain leaves @@ -229,6 +233,16 @@ struct app { } toasts[3]; size_t ntoasts; + /* Where this frame's overlays landed: a modal's box, a toast, the splash. + * The compositor needs no such list -- painted later is on top -- but an + * image is not painted, it is placed, and the client's terminal draws it + * over the cells whatever order they arrived in. So the graphics pass cuts + * the overlays out of a placement the way it cuts out a float, and this is + * how it learns where they were. Rebuilt every compose; a rect that is not + * re-registered is one that is no longer on screen. */ + rect_t overlays[APP_MAX_OVERLAYS]; + size_t noverlays; + /* What a selection put on the clipboard, kept for middle-click paste (the * X11 primary-selection habit) and handed to the client as OSC 52. */ char *clipboard; @@ -444,6 +458,8 @@ void rename_begin(app_t *a, uint32_t id); void rename_tab_begin(app_t *a, uint32_t id); void draw_splash(app_t *a, screen_t *s); +/* Say that a rect was painted over the panes this frame; see `overlays`. */ +void app_overlay(app_t *a, rect_t r); /* round two */ void drop_pane_on_strip(app_t *a); diff --git a/include/graphics.h b/include/graphics.h index 13cc5dc..cebb53c 100644 --- a/include/graphics.h +++ b/include/graphics.h @@ -26,10 +26,20 @@ typedef struct { bool sent; } gfx_image_t; -/* One placement on screen this frame. */ +/* One placement on screen this frame -- or one visible piece of it, when + * something on top has cut it into more than one rectangle. */ typedef struct { uint32_t out_id; - uint32_t place_id; + uint32_t place_id; /* the id the program chose */ + /* Which visible rectangle of that placement this is. A placement nothing + * covers has one piece; a float across the middle of an image leaves two, + * and a float in a corner leaves two or three -- each of them a placement + * of its own on the wire, which is the only shape the protocol has. */ + uint16_t piece; + /* The placement id the client is told, ours to hand out: the pieces of one + * placement need an id each, and the program's own id is one number that + * cannot be several. */ + uint32_t wire_id; uint16_t col, row; /* screen cells */ uint16_t cols, rows; /* Where the image starts *inside* its first cell, in pixels. This is what @@ -41,6 +51,9 @@ typedef struct { uint16_t scale_cols, scale_rows; uint32_t sx, sy, sw, sh; /* source rectangle, in image pixels */ bool live; + /* The client is holding this placement, exactly as described above. A + * placement the terminal already has is not re-sent: see gfx_flush(). */ + bool shown; } gfx_place_t; graphics_t *gfx_new(void); @@ -60,6 +73,7 @@ typedef struct { 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 piece; /* which visible rectangle of it */ 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 */ @@ -86,8 +100,14 @@ char *gfx_flush(graphics_t *g, size_t *out_len); * commits counts as undelivered at the next gfx_begin(). */ void gfx_commit(graphics_t *g, bool delivered); -/* Forget what the client has seen (it is a different client now). */ +/* Forget what the client has seen (it is a different client now, or its screen + * was cleared -- ESC[2J takes the images with it). Everything still on screen + * is transmitted and placed again on the next frame. */ void gfx_reset(graphics_t *g); +/* The client's screen was cleared (a full repaint takes the images with it): + * every placement is owed again, while transmitted pixels and owed deletions + * stand. */ +void gfx_repaint(graphics_t *g); /* Drop everything belonging to a pane that is gone. */ void gfx_forget_pane(graphics_t *g, uint32_t pane); @@ -668,6 +668,15 @@ void draw_status_line(app_t *a, screen_t *s) { screen_text(s, (uint16_t)from, y, middle, middle_fg, NO_COLOR, 0); } +/* Register something painted over the panes, so the graphics pass can cut it + * out of any image underneath it. Opaque things only: a scrim tints what is + * behind it and an image under one should stay on screen, undimmed and wrong + * rather than gone. */ +void app_overlay(app_t *a, rect_t r) { + if (!a || !r.w || !r.h || a->noverlays >= APP_MAX_OVERLAYS) return; + a->overlays[a->noverlays++] = r; +} + void draw_toasts(app_t *a, screen_t *s) { toasts_expire(a); if (!a->ntoasts) return; @@ -706,6 +715,7 @@ void draw_toasts(app_t *a, screen_t *s) { char clipped[160]; snprintf(clipped, sizeof clipped, "%.*s", (int)w, line); screen_text(s, x, y, clipped, TOAST_FG, TOAST_BG, ATTR_BOLD); + app_overlay(a, (rect_t){x, y, (uint16_t)cells(clipped), 1}); } } diff --git a/src/app_draw.c b/src/app_draw.c index 2955c11..495add6 100644 --- a/src/app_draw.c +++ b/src/app_draw.c @@ -2309,6 +2309,94 @@ static void gfx_place_view(graphics_t *out, gfx_req_t r, rect_t v) { gfx_place(out, &r); } +/* The most rectangles one image is cut into. One float leaves at most four + * pieces, so this is two floats' worth; past it the image is dropped rather + * than shredded, which is what the old emitter did for every cut. */ +#define GFX_MAX_PIECES 8 + +/* What is left of `r` once the occluders are taken out of it, as rectangles. + * Zero when nothing is left, or when saying it would take more than `max`. */ +static size_t rect_subtract(rect_t r, const rect_t *occ, size_t nocc, + rect_t *out, size_t max) { + if (!r.w || !r.h) return 0; + if (max > GFX_MAX_PIECES) max = GFX_MAX_PIECES; /* `next` below is that big */ + size_t n = 0; + out[n++] = r; + for (size_t i = 0; i < nocc; i++) { + rect_t next[GFX_MAX_PIECES]; + size_t m = 0; + for (size_t j = 0; j < n; j++) { + int px0 = out[j].x, py0 = out[j].y; + int px1 = px0 + out[j].w, py1 = py0 + out[j].h; + int ox0 = occ[i].x, oy0 = occ[i].y; + int ox1 = ox0 + occ[i].w, oy1 = oy0 + occ[i].h; + if (ox1 <= px0 || ox0 >= px1 || oy1 <= py0 || oy0 >= py1) { + if (m == max) return 0; + next[m++] = out[j]; + continue; + } + int cx0 = ox0 > px0 ? ox0 : px0, cx1 = ox1 < px1 ? ox1 : px1; + int cy0 = oy0 > py0 ? oy0 : py0, cy1 = oy1 < py1 ? oy1 : py1; + /* The strips around the covered part: the full width above and below + * it, and what is beside it between those two. Four at most, and each + * of them empty when the cover reaches that edge. */ + const int strips[4][4] = { + {px0, py0, px1, cy0}, + {px0, cy1, px1, py1}, + {px0, cy0, cx0, cy1}, + {cx1, cy0, px1, cy1}, + }; + for (size_t k = 0; k < 4; k++) { + int x0 = strips[k][0], y0 = strips[k][1]; + int x1 = strips[k][2], y1 = strips[k][3]; + if (x1 <= x0 || y1 <= y0) continue; + if (m == max) return 0; + next[m++] = (rect_t){(uint16_t)x0, (uint16_t)y0, (uint16_t)(x1 - x0), + (uint16_t)(y1 - y0)}; + } + } + memcpy(out, next, m * sizeof *out); + n = m; + if (!n) return 0; + } + return n; +} + +/* One axis of one piece: the source rectangle for the `n` cells that start + * `d` cells into a placement `total` cells wide, and the offset the piece + * begins at inside its own first cell. + * + * Scaled and natural are different sums, for the reason the pane-edge crop + * gives: with c=/r= a cell is worth source/cells pixels, and at natural size + * a cell is worth its own pixels -- measured from where the image starts, + * which is `off` into the first cell and nowhere else. */ +static void axis_crop(bool scaled, uint16_t total, uint16_t d, uint16_t n, + uint32_t cell_px, uint32_t *src, uint32_t *len, + uint32_t *off) { + if (!total || !n) return; + if (scaled) { + uint32_t s0 = (uint32_t)((uint64_t)*len * d / total); + uint32_t s1 = (uint32_t)((uint64_t)*len * (d + n) / total); + *src += s0; + *len = s1 - s0; + /* Scaling does not move where the image starts inside its first cell, + * so the first piece keeps that offset and a later one starts on a cell + * boundary by construction. */ + if (d) *off = 0; + return; + } + uint32_t edge = (uint32_t)d * cell_px; + uint32_t skip = d ? (edge > *off ? edge - *off : 0) : 0; + if (skip > *len) skip = *len; + uint32_t keep = d ? 0 : *off; + uint32_t avail = (uint32_t)n * cell_px; + avail = avail > keep ? avail - keep : 0; + *src += skip; + *len -= skip; + if (*len > avail) *len = avail; + *off = keep; +} + static void gfx_from_pane(pane_t *p, const pane_gfx_t *g, void *ud) { struct gfx_ctx *c = ud; node_t *leaf = c->leaf; @@ -2358,121 +2446,62 @@ static void gfx_from_pane(pane_t *p, const pane_gfx_t *g, void *ud) { /* The floats above this pane. The cell compositor gets occlusion free from * paint order; placements are sent after the diff and get it from this: - * what one clean edge can express is cropped — the same source-rectangle - * arithmetic as the pane-edge clipping, aimed at another clipper — and a - * float in the *middle* of an image is a shape one placement cannot - * express, so that placement is suppressed for the frame and returns when - * the float moves. A corner overlap leaves an L, which is two rects, which - * is the same refusal. */ - uint16_t col = g->col, row = g->row; - uint32_t sx = g->sx, sy = g->sy; - uint32_t xo = g->x_off, yo = g->y_off; - for (size_t i = 0; i < c->nocc; i++) { - int px0 = leaf->content.x + col, py0 = leaf->content.y + row; - int px1 = px0 + cols, py1 = py0 + rows; - int ox0 = c->occ[i].x, oy0 = c->occ[i].y; - int ox1 = ox0 + c->occ[i].w, oy1 = oy0 + c->occ[i].h; - if (ox1 <= px0 || ox0 >= px1 || oy1 <= py0 || oy0 >= py1) continue; - bool spans_x = ox0 <= px0 && ox1 >= px1; - bool spans_y = oy0 <= py0 && oy1 >= py1; - if (spans_x && spans_y) return; /* fully covered */ - if (spans_x) { - if (oy0 <= py0) { /* trimmed from the top: the source origin moves */ - uint16_t cut = (uint16_t)(oy1 - py0); - if (g->req_rows) { - uint32_t drop = rows ? (uint32_t)((uint64_t)sh * cut / rows) : 0; - sy += drop; - sh -= drop; - } else { - uint32_t px = (uint32_t)cut * g->cell_px_h; - px = px > yo ? px - yo : 0; - yo = 0; - sy += px < sh ? px : sh; - sh = px < sh ? sh - px : 0; - } - row = (uint16_t)(row + cut); - rows = (uint16_t)(rows - cut); - } else if (oy1 >= py1) { /* trimmed from the bottom, like the edge */ - uint16_t keep = (uint16_t)(oy0 - py0); - if (g->req_rows) { - sh = rows ? (uint32_t)((uint64_t)sh * keep / rows) : sh; - } else { - uint32_t px = (uint32_t)keep * g->cell_px_h; - px = px > yo ? px - yo : 0; - if (sh > px) sh = px; - } - rows = keep; - } else { - return; /* a strip across the middle */ - } - } else if (spans_y) { - if (ox0 <= px0) { - uint16_t cut = (uint16_t)(ox1 - px0); - if (g->req_cols) { - uint32_t drop = cols ? (uint32_t)((uint64_t)sw * cut / cols) : 0; - sx += drop; - sw -= drop; - } else { - uint32_t px = (uint32_t)cut * g->cell_px_w; - px = px > xo ? px - xo : 0; - xo = 0; - sx += px < sw ? px : sw; - sw = px < sw ? sw - px : 0; - } - col = (uint16_t)(col + cut); - cols = (uint16_t)(cols - cut); - } else if (ox1 >= px1) { - uint16_t keep = (uint16_t)(ox0 - px0); - if (g->req_cols) { - sw = cols ? (uint32_t)((uint64_t)sw * keep / cols) : sw; - } else { - uint32_t px = (uint32_t)keep * g->cell_px_w; - px = px > xo ? px - xo : 0; - if (sw > px) sw = px; - } - cols = keep; - } else { - return; - } - } else { - return; /* a corner: the remainder is an L, not a rect */ - } - if (!cols || !rows || !sw || !sh) return; + * what is left of the image once the floats above it are taken away. + * + * That remainder is not always one rectangle -- a float across the middle + * of an image leaves two, a float in a corner leaves two, a float wholly + * inside it leaves four -- and a placement is one rectangle. So the + * remainder is cut into rectangles and each one is placed in its own + * right, which is a shape the protocol does have. It used to be suppressed + * instead, on the grounds that an L is not a rect: the whole image vanished + * the moment a floating pane crossed it, on the terminal and in anything + * mirroring it, and came back when the float moved off. */ + rect_t whole = {(uint16_t)(leaf->content.x + g->col), + (uint16_t)(leaf->content.y + g->row), cols, rows}; + rect_t vis[GFX_MAX_PIECES]; + size_t nvis = rect_subtract(whole, c->occ, c->nocc, vis, GFX_MAX_PIECES); + for (size_t i = 0; i < nvis; i++) { + /* Where the piece starts inside the placement, in cells, is all the + * source rectangle needs -- the pane-edge arithmetic again, aimed at a + * cut that can be on any side now. */ + uint32_t psx = g->sx, psy = g->sy, psw = sw, psh = sh; + uint32_t pxo = g->x_off, pyo = g->y_off; + axis_crop(g->req_cols != 0, cols, (uint16_t)(vis[i].x - whole.x), vis[i].w, + g->cell_px_w, &psx, &psw, &pxo); + axis_crop(g->req_rows != 0, rows, (uint16_t)(vis[i].y - whole.y), vis[i].h, + g->cell_px_h, &psy, &psh, &pyo); + gfx_place_view(c->out, + (gfx_req_t){ + .pane = leaf->id, + .src_id = g->image_id, + .gen = g->generation, + .place_id = g->place_id, + .piece = (uint16_t)i, + .col = vis[i].x, + .row = vis[i].y, + .cols = vis[i].w, + .rows = vis[i].h, + .x_off = pxo, + .y_off = pyo, + /* Zero when the program never asked to scale; when it + * did, the piece scales into the cells it covers. */ + .scale_cols = g->req_cols ? vis[i].w : 0, + .scale_rows = g->req_rows ? vis[i].h : 0, + .sx = psx, + .sy = psy, + .sw = psw, + .sh = psh, + .px_w = g->src_w, + .px_h = g->src_h, + .cell_px_w = g->cell_px_w, + .cell_px_h = g->cell_px_h, + .format = g->format, + .compression = g->compression, + .data = g->data, + .data_len = g->data_len, + }, + c->viewport); } - - gfx_place_view( - c->out, - (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 + col), - .row = (uint16_t)(leaf->content.y + row), - .cols = cols, - .rows = rows, - /* The offsets survive the pane's own clipping untouched; an - * occlusion trim from the left or top zeroes the one it consumed. */ - .x_off = xo, - .y_off = yo, - /* 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 = sx, - .sy = sy, - .sw = sw, - .sh = sh, - .px_w = g->src_w, - .px_h = g->src_h, - .cell_px_w = g->cell_px_w, - .cell_px_h = g->cell_px_h, - .format = g->format, - .compression = g->compression, - .data = g->data, - .data_len = g->data_len, - }, - c->viewport); } /* One frame's worth of "what covers what": the visible floats, so every @@ -2491,11 +2520,16 @@ static void gfx_leaf_cb(node_t *n, void *ud) { if (n->hidden || n->collapsed) return; /* not drawn: images included */ /* Above this leaf: every float for a tiled pane, the higher-raised for a * float — the same order draw_floats paints in, so an image is clipped by - * exactly what its cells are covered by. */ - rect_t occ[64]; + * exactly what its cells are covered by. Then the frame's overlays, which + * are above the floats too: the cheatsheet, a toast, the splash. They are + * drawn as cells and a placement is not, so without this the terminal puts + * the picture back over the top of them -- the cheatsheet appearing under + * an image is what asked for this. */ + rect_t occ[64 + APP_MAX_OVERLAYS]; size_t k = 0; for (size_t i = 0; i < w->n; i++) if (!n->floating || w->raised[i] > n->raised) occ[k++] = w->occ[i]; + for (size_t i = 0; i < w->a->noverlays; i++) occ[k++] = w->a->overlays[i]; struct gfx_ctx ctx = {.a = w->a, .out = w->out, .leaf = n, @@ -2532,6 +2566,8 @@ const char *app_graphics(app_t *a, size_t *len) { void app_graphics_reset(app_t *a) { gfx_reset(a->gfx); } +void app_graphics_view_repaint(graphics_t *state) { gfx_repaint(state); } + void app_graphics_commit(app_t *a, bool delivered) { gfx_commit(a->gfx, delivered); } @@ -2552,6 +2588,9 @@ char *app_graphics_json(app_t *a) { json_obj_open(&j, NULL); json_int(&j, "image", places[i].out_id); json_int(&j, "placement", places[i].place_id); + /* Which rectangle of that placement: a float lying across an image + * leaves more than one, and they are only told apart by this. */ + json_int(&j, "piece", places[i].piece); json_int(&j, "x", places[i].col); json_int(&j, "y", places[i].row); json_int(&j, "cols", places[i].cols); @@ -2783,6 +2822,10 @@ void draw_splash(app_t *a, screen_t *s) { uint16_t x0 = (uint16_t)((s->cols - bw) / 2); uint16_t y0 = (uint16_t)((s->rows - bh) / 2); + /* A cleared backdrop, so the logo reads over whatever a pane put there -- + * including an image, which is cleared by being cut out of its placement + * rather than by writing cells over it. */ + app_overlay(a, (rect_t){x0, y0, bw, bh}); /* A cleared backdrop, so the logo reads over whatever a pane put there. */ char blank[512]; size_t nb = bw < sizeof blank - 1 ? bw : sizeof blank - 1; diff --git a/src/app_ui.c b/src/app_ui.c index 8daf8f1..21b3529 100644 --- a/src/app_ui.c +++ b/src/app_ui.c @@ -189,10 +189,14 @@ static rect_t modal_frame(app_t *a, screen_t *s, uint16_t w, uint16_t h, uint16_t x1 = (uint16_t)(x + w - 1), y1 = (uint16_t)(y + h - 1); /* Opaque, or the panes underneath read through it: a modal you can see a - * shell prompt through is a modal nobody trusts. */ + * shell prompt through is a modal nobody trusts. Opaque for the cells is + * this loop; opaque for an image under it is the registration, without + * which the terminal draws the picture over the modal and the cheatsheet + * reads through a screenshot. */ for (uint16_t yy = y; yy < y + h; yy++) for (uint16_t xx = x; xx < x + w; xx++) screen_text(s, xx, yy, " ", NO_COLOR, MODAL_BG, 0); + app_overlay(a, (rect_t){x, y, w, h}); const char *tl = CFG.rounded ? "\u256d" : "\u250c", *tr = CFG.rounded ? "\u256e" : "\u2510"; @@ -993,6 +997,7 @@ static void drop_render_cb(node_t *n, void *ud) { void app_compose(app_t *a, screen_t *s) { screen_clear(s); /* every frame starts blank: no ghosts in the gap ring */ hit_reset(&s->hits); + a->noverlays = 0; /* re-registered by whatever draws over the panes below */ s->cursor_visible = false; a->painted = s; /* Re-derived by the shader passes below, every frame, from what they ran. */ diff --git a/src/graphics.c b/src/graphics.c index ac8c244..77b3b7f 100644 --- a/src/graphics.c +++ b/src/graphics.c @@ -18,7 +18,7 @@ * pinned to a spot on the screen no scrolling will move. The queue survives * until gfx_commit() says the client actually heard it. */ typedef struct { - uint32_t out_id, place_id; + uint32_t out_id, wire_id; } gfx_dead_t; struct graphics { @@ -27,11 +27,16 @@ struct graphics { gfx_place_t *places; size_t nplaces, placecap; uint32_t next_out_id; + uint32_t next_wire_id; /* placement ids we hand out; see gfx_place_t */ gfx_dead_t *dead; /* deletions owed to the client */ size_t ndead, deadcap; size_t *txed; /* imgs[] indices transmitted in the frame being built */ size_t ntxed, txedcap; + /* places[] indices emitted in the frame being built, for the same reason: + * a placement the client never heard is one it is not holding. */ + size_t *placed; + size_t nplaced, placedcap; char *out; size_t len, cap; @@ -81,6 +86,7 @@ static void out_b64(graphics_t *g, const uint8_t *in, size_t len) { graphics_t *gfx_new(void) { graphics_t *g = calloc(1, sizeof *g); g->next_out_id = OUT_ID_BASE; + g->next_wire_id = 1; return g; } @@ -90,6 +96,7 @@ void gfx_free(graphics_t *g) { free(g->places); free(g->dead); free(g->txed); + free(g->placed); free(g->out); free(g); } @@ -100,6 +107,16 @@ void gfx_reset(graphics_t *g) { /* A fresh client has no placements to delete and has seen no frame. */ g->ndead = 0; g->ntxed = 0; + g->nplaced = 0; +} + +void gfx_repaint(graphics_t *g) { + /* The client's screen was cleared, so it is holding no placements: say them + * all again. Not gfx_reset(): the terminal keeps the pixels it was sent + * (an id-keyed image survives a clear; only its placements go), and a + * deletion still owed from a dropped frame is still owed -- forgetting one + * here is a picture parked on the client's screen for good. */ + for (size_t i = 0; i < g->nplaces; i++) g->places[i].shown = false; } void gfx_forget_pane(graphics_t *g, uint32_t pane) { @@ -113,7 +130,7 @@ void gfx_begin(graphics_t *g) { /* A flushed frame nobody committed was never delivered: the `graphics` * control command renders the stream for a CLI, not for the client, and * anything it claimed to transmit the client still has not seen. */ - if (g->ntxed) gfx_commit(g, false); + if (g->ntxed || g->nplaced) gfx_commit(g, false); for (size_t i = 0; i < g->nplaces; i++) g->places[i].live = false; g->len = 0; if (g->out) g->out[0] = 0; @@ -198,14 +215,18 @@ void gfx_place(graphics_t *g, const gfx_req_t *req) { img->gen = gen; img->sent = false; } - if (!img->sent) + bool transmitted = false; + if (!img->sent) { transmit(g, img, req->px_w, req->px_h, req->format, req->compression, req->data, req->data_len); + transmitted = img->sent; + } 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) + if (g->places[i].out_id == img->out_id && g->places[i].place_id == pid && + g->places[i].piece == req->piece) slot = &g->places[i]; if (!slot) { if (g->nplaces == g->placecap) { @@ -216,7 +237,25 @@ void gfx_place(graphics_t *g, const gfx_req_t *req) { memset(slot, 0, sizeof *slot); slot->out_id = img->out_id; slot->place_id = pid; + slot->piece = req->piece; + slot->wire_id = g->next_wire_id++; } + /* What the client is holding, against what it should be holding. A + * placement that has not moved, resized or re-cropped is left alone: see + * gfx_flush() for why saying it again is not free. A retransmit invalidates + * it too -- kitty deletes an image's placements when its data is replaced, + * so the picture is gone until we place it once more. + * + * Field by field rather than a memcmp of the struct: the padding a compiler + * is free to leave between these members is not initialised by an + * assignment, so a memcmp compares it and reports a change that isn't one -- + * which here would mean re-placing every image every frame, silently, again. */ + bool same = slot->shown && !transmitted && 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->col = req->col; slot->row = req->row; slot->cols = req->cols; @@ -230,6 +269,7 @@ void gfx_place(graphics_t *g, const gfx_req_t *req) { slot->sw = req->sw; slot->sh = req->sh; slot->live = true; + slot->shown = same; } char *gfx_flush(graphics_t *g, size_t *out_len) { @@ -264,14 +304,14 @@ char *gfx_flush(graphics_t *g, size_t *out_len) { } bool queued = false; for (size_t j = 0; j < g->ndead; j++) - if (g->dead[j].out_id == p->out_id && g->dead[j].place_id == p->place_id) + if (g->dead[j].out_id == p->out_id && g->dead[j].wire_id == p->wire_id) queued = true; if (!queued) { if (g->ndead == g->deadcap) { g->deadcap = g->deadcap ? g->deadcap * 2 : 8; g->dead = realloc(g->dead, g->deadcap * sizeof *g->dead); } - g->dead[g->ndead++] = (gfx_dead_t){p->out_id, p->place_id}; + g->dead[g->ndead++] = (gfx_dead_t){p->out_id, p->wire_id}; } } g->nplaces = keep; @@ -280,14 +320,42 @@ char *gfx_flush(graphics_t *g, size_t *out_len) { * came back is deleted and re-placed in the same stream, which nets out. */ for (size_t i = 0; i < g->ndead; i++) out_fmt(g, "\x1b_Ga=d,d=i,q=2,i=%u,p=%u\x1b\\", g->dead[i].out_id, - g->dead[i].place_id); + g->dead[i].wire_id); - /* Then (re)place everything that is. C=1 keeps the cursor where the text - * renderer left it, which matters because we are interleaving with a diff. */ + /* Then place what the client is not already holding. C=1 keeps the cursor + * where the text renderer left it, which matters because we are + * interleaving with a diff. + * + * Only the placements that changed. A kitty placement is a standing + * instruction, not a per-frame one: "the other commands to erase text must + * have no effect on graphics", so a repaint of the cells under a picture + * does not disturb it and re-placing it says nothing new. It is not free, + * though. A terminal mirroring the stream cannot know that the identical + * placement is identical -- it re-decodes the image and holds the picture + * back until that finishes, so re-placing at frame rate meant the mirrored + * copy was mid-decode nearly always and flickered or vanished, while the + * local terminal (which keeps the pixels) looked fine. That was the bug + * this diff exists for, and it costs nothing on the terminal path either: + * a still picture on a busy screen now costs zero bytes. */ for (size_t i = 0; i < g->nplaces; i++) { gfx_place_t *p = &g->places[i]; + if (p->shown) continue; + if (g->nplaced == g->placedcap) { + g->placedcap = g->placedcap ? g->placedcap * 2 : 8; + g->placed = realloc(g->placed, g->placedcap * sizeof *g->placed); + } + g->placed[g->nplaced++] = i; + p->shown = true; 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", p->out_id, p->place_id); + /* z=0 is kitty's own default, so saying it changes nothing on a terminal + * -- and it is the whole picture for anything mirroring the stream, which + * cannot otherwise tell "the emitter wants this over the text" from "the + * emitter said nothing". Left unsaid, a mirror is free to paint the image + * under the cells, and the first pane-wide background to land on those + * cells (dim_unfocused paints one over every cell it touches) hides the + * picture completely -- on the mirrored copy only, while the terminal, + * which took the default, still shows it. */ + out_fmt(g, "\x1b_Ga=p,q=2,C=1,z=0,i=%u,p=%u", p->out_id, p->wire_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 @@ -326,12 +394,16 @@ void gfx_commit(graphics_t *g, bool delivered) { } else { /* The transmissions in that frame never happened as far as the client * is concerned; forget we made them so the next frame sends them again. - * Deletions stay queued for the same reason. Placements need nothing: - * every live one is re-emitted every frame anyway. */ + * Deletions stay queued for the same reason, and so do the placements: + * now that a placement is said once rather than every frame, a placement + * that left with a dropped frame is a picture the client never draws. */ for (size_t i = 0; i < g->ntxed; i++) if (g->txed[i] < g->nimgs) g->imgs[g->txed[i]].sent = false; + for (size_t i = 0; i < g->nplaced; i++) + if (g->placed[i] < g->nplaces) g->places[g->placed[i]].shown = false; } g->ntxed = 0; + g->nplaced = 0; } size_t gfx_placements(const graphics_t *g, const gfx_place_t **out) { diff --git a/src/server.c b/src/server.c index 387fa7d..c8a481d 100644 --- a/src/server.c +++ b/src/server.c @@ -110,6 +110,11 @@ typedef struct { uint16_t fallback_cell_w, fallback_cell_h; bool pending_paint; int64_t next_frame; + /* One frame, assembled for whichever client is being pushed to: cells, + * images and the synchronized-update markers around them. Server-wide + * rather than per-connection because it never outlives the send. */ + char *frame; + size_t frame_cap; } server_t; static conn_t *active_conn(server_t *s) { @@ -247,29 +252,6 @@ static char *b64(const char *in, size_t len, size_t *out_len) { return out; } -/* Images go out after the cell diff, so a repainted cell cannot land on top - * of a placement we just made. - * - * Returns whether the client is caught up. The stream is indivisible -- half - * a frame is a dangling APC that eats whatever follows it -- so a full outbox - * drops the whole frame, the model is told (deletions stay owed, transmits - * are re-sent), and the caller keeps a repaint pending until it lands. Losing - * a frame here silently was how a scrolled-away screenshot stayed parked on - * the screen: its deletion left with the dropped bytes and was never owed - * again. */ -static bool push_graphics(server_t *s, conn_t *c) { - size_t len = 0; - const char *bytes = app_graphics_view(s->app, c->gfx, c->view_x, c->view_y, - c->cols, c->rows, &len); - if (!len) { - app_graphics_view_commit(c->gfx, true); - return true; - } - bool ok = conn_send(c, MSG_OUTPUT, bytes, len); - app_graphics_view_commit(c->gfx, ok); - return ok; -} - /* The clipboard lives on the client's machine, not ours, so a copy travels as * OSC 52 for the outer terminal to honour. Input-driven copies name their * originating connection; asynchronous pane copies use the active one. */ @@ -331,15 +313,84 @@ static void stamp_indicator(server_t *s, conn_t *c) { screen_put_utf8(&c->view, (uint16_t)(x0 + i), 0, &tag[i], 1, fg, bg, 0); } -static bool push_frame(server_t *s, conn_t *c) { +/* Synchronized output (DEC private mode 2026): the terminal buffers + * everything between the two markers and presents it as one update. + * + * A frame is a cell diff and then the image placements that belong on top of + * it -- and a terminal that draws the diff *before* the placements arrive + * shows, for one refresh, the cells where the picture is with no picture on + * them. Direct on a tty the two halves usually land in one read and nobody + * sees the gap; put anything that re-chunks the stream in between (a mirror + * like shellglass reads the pty 8KB at a time and writes each read straight + * through) and the gap becomes a refresh of its own. Every image on screen + * then blinks once per frame, which is how it was reported: "unstable, as if + * constantly redrawn". + * + * Terminals that do not implement 2026 ignore the private mode, which is why + * this can go out unconditionally. */ +#define SYNC_BEGIN "\x1b[?2026h" +#define SYNC_END "\x1b[?2026l" + +/* One client's whole frame: the cell diff, then the images that belong on top + * of it, in a single message. + * + * One message rather than the two this used to be, because the two are one + * picture: split across messages they are split across writes at the client, + * and everything downstream is then free to present the halves separately. + * Images last within it, so a repainted cell cannot land on top of a + * placement we just made. + * + * Returns whether the client is caught up. The message is indivisible -- half + * a frame is a dangling APC that eats whatever follows it -- so a full outbox + * drops the whole frame, the model is told (deletions stay owed, transmits + * are re-sent), and the caller keeps a repaint pending until it lands. Losing + * a frame here silently was how a scrolled-away screenshot stayed parked on + * the screen: its deletion left with the dropped bytes and was never owed + * again. */ +static bool push_display(server_t *s, conn_t *c) { uint16_t old_x = c->view_x, old_y = c->view_y; uint16_t old_cols = c->shown_cols, old_rows = c->shown_rows; update_viewport(c, &s->screen); screen_project(&c->view, &s->screen, c->view_x, c->view_y); stamp_indicator(s, c); + bool full = c->view.force_full; /* consumed by the render below */ screen_render(&c->view); - if (!c->view.out_len) return true; - if (!conn_send(c, MSG_OUTPUT, c->view.out, c->view.out_len)) { + + /* A full repaint clears the screen, and "the clear screen escape code + * should also clear all images" -- so every placement the client was + * holding went with it. Owe them again: without this the picture would + * survive only as long as nothing forced a repaint under it. */ + if (full) app_graphics_view_repaint(c->gfx); + + size_t glen = 0; + const char *gfx = app_graphics_view(s->app, c->gfx, c->view_x, c->view_y, + c->cols, c->rows, &glen); + if (!c->view.out_len && !glen) { /* nothing changed: not one byte */ + app_graphics_view_commit(c->gfx, true); + return true; + } + + const size_t pre = sizeof SYNC_BEGIN - 1, post = sizeof SYNC_END - 1; + size_t len = pre + c->view.out_len + glen + post; + if (len > s->frame_cap) { + size_t cap = s->frame_cap ? s->frame_cap : 8192; + while (cap < len) cap *= 2; + char *grown = realloc(s->frame, cap); + if (!grown) return false; + s->frame = grown; + s->frame_cap = cap; + } + size_t off = 0; + memcpy(s->frame + off, SYNC_BEGIN, pre); + off += pre; + memcpy(s->frame + off, c->view.out, c->view.out_len); + off += c->view.out_len; + memcpy(s->frame + off, gfx, glen); + off += glen; + memcpy(s->frame + off, SYNC_END, post); + + bool ok = conn_send(c, MSG_OUTPUT, s->frame, len); + if (!ok) { /* The projection never entered this client's stream. Keep mouse mapping * tied to the last viewport we did enqueue, then retry this one in full. */ c->view_x = old_x; @@ -347,9 +398,9 @@ static bool push_frame(server_t *s, conn_t *c) { c->shown_cols = old_cols; c->shown_rows = old_rows; c->view.force_full = true; - return false; } - return true; + app_graphics_view_commit(c->gfx, ok); + return ok; } static bool push_clients(server_t *s) { @@ -368,9 +419,7 @@ static bool push_clients(server_t *s) { for (size_t i = 0; i < s->nconns; i++) { conn_t *c = &s->conns[i]; if (!c->display) continue; - bool frame_ok = push_frame(s, c); - bool gfx_ok = push_graphics(s, c); - if (!(frame_ok && gfx_ok)) ok = false; + if (!push_display(s, c)) ok = false; } return ok; } @@ -1099,6 +1148,7 @@ int server_run(const char *name, const char *const argv[], uint16_t cols, close(lfd); input_free(s.cmd_in); screen_free(&s.screen); + free(s.frame); app_free(s.app); return 0; } diff --git a/tests/live_graphics.py b/tests/live_graphics.py index 74e4b2c..934cd46 100644 --- a/tests/live_graphics.py +++ b/tests/live_graphics.py @@ -58,12 +58,30 @@ time.sleep(30) """ -def spawn(cols=80, rows=24): +# A small image that stays put while the screen around it changes: the counter +# repaints one line in place (\r, no newline), so nothing scrolls and the +# picture neither moves nor changes. Every frame it provokes is a frame that +# must NOT say the placement again. +STILL = r""" +import base64, sys, time +px = base64.b64encode(bytes([255, 0, 0] * 8)).decode() # 4x2 RGB +sys.stdout.write("\x1b_Ga=T,f=24,q=2,s=4,v=2,i=7,c=6,r=2;%s\x1b\\\r\n" % px) +sys.stdout.flush() +for i in range(120): + sys.stdout.write("tick %d \r" % i) + sys.stdout.flush() + time.sleep(0.02) +time.sleep(30) +""" + + +def spawn(cols=80, rows=24, child=None, session=None): pid, fd = pty.fork() if pid == 0: os.environ["TERM"] = "xterm-ghostty" os.environ["SLOSH_CONFIG"] = "/nonexistent/slosh.kdl" - os.execv(BIN, [BIN, "-s", SESSION, "--", "python3", "-c", CHILD]) + os.execv(BIN, [BIN, "-s", session or SESSION, "--", "python3", "-c", + child or CHILD]) os._exit(127) fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) return pid, fd @@ -87,6 +105,28 @@ def drain(fd, idle=0.4, limit=8.0): return buf +def unsynced(buf): + """kitty commands that reach the client outside a synchronized update. + + A frame is a cell diff and then the placements that sit on top of it. A + terminal that presents the diff before the placements arrive draws the + cells under a picture with no picture on them, so every image blinks once + per frame -- which is what happens the moment anything re-chunks the + stream on the way out (a mirror reading the pty a few KB at a time). The + markers are what stop the halves being presented separately, so every + image command has to be inside a pair. + """ + depth, out = 0, [] + for m in re.finditer(rb"\x1b\[\?2026h|\x1b\[\?2026l|\x1b_G([^\x1b]*)\x1b", buf): + if m.group() == b"\x1b[?2026h": + depth += 1 + elif m.group() == b"\x1b[?2026l": + depth -= 1 + elif depth <= 0: + out.append(m.group(1)[:40]) + return out + + def check(name, cond, detail=""): print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}") return cond @@ -130,6 +170,13 @@ def main(): placed = any(ctl.startswith(b"a=p") for ctl, _ in cmds) ok &= check("and the image is placed while visible", placed, repr(buf[-300:])) + loose = unsynced(buf) + ok &= check( + "every image command travels inside a synchronized update", + not loose, + f"{len(loose)} outside: {loose[:4]}", + ) + # The child's flood scrolled it away while nothing was being read: the # deletion rode a frame the full outbox may well have dropped, and it must # be said again until it lands -- or the client draws the placement at a @@ -172,6 +219,22 @@ def main(): os.waitpid(pid, os.WNOHANG) == (0, 0), ) + # A placement is a standing instruction, not a per-frame one: a picture + # that has not moved must not be re-sent while the screen around it + # repaints. Re-sending it is invisible on a terminal that keeps the pixels + # and ruinous on anything mirroring the stream, which re-decodes the image + # and rebuilds its view every time it hears the placement again. + pid3, fd3 = spawn(child=STILL, session=SESSION + "-still") + still = drain(fd3, idle=0.6, limit=20.0) + places = [ctl for ctl, _ in gfx_commands(still) if ctl.startswith(b"a=p")] + frames = still.count(b"\x1b[?2026h") + ok &= check( + "a still picture is placed once, however much repaints around it", + len(places) == 1 and frames > 10, + f"{len(places)} placements across {frames} frames", + ) + os.kill(pid3, 9) + os.write(fd2, b"\x01q") drain(fd2, idle=0.2, limit=2.0) for child in (pid, pid2): diff --git a/tests/test_graphics.py b/tests/test_graphics.py index f8d7a0d..7430224 100644 --- a/tests/test_graphics.py +++ b/tests/test_graphics.py @@ -289,6 +289,26 @@ def test_sub_cell_offsets_survive_to_the_client(): ) +def test_a_placement_says_it_sits_above_the_text(): + """z=0 is kitty's default, so saying it changes nothing on a terminal. + + It is the whole picture for anything mirroring the stream, which cannot + otherwise tell "the emitter wants this over the text" from "the emitter + said nothing" -- and one that guesses "under" loses the image behind the + first background painted over those cells, dim_unfocused being the one + that paints over every cell of a pane at once. + """ + with Session(sends_image(), cols=44, rows=10) as s: + s.settle(200) + raw = s.api("graphics", format="bytes")["bytes"] + place = [c for c in raw.split("\x1b") if c.startswith("_Ga=p")] + check( + "the placement states its z", + place and "z=0" in place[0], + str(place), + ) + + def test_a_placement_with_no_offset_emits_none(): with Session(transmits_then_places("p,i=7,p=1,q=2,c=6,r=2"), cols=44, rows=10) as s: s.settle(200) @@ -592,10 +612,12 @@ def test_scrolled_away_placements_are_dropped(): def test_a_float_occludes_a_placement(): """The cell compositor gets occlusion free from paint order; placements - are sent after the diff and get it from clipping (D22): a clean edge - crops -- the pane-edge arithmetic aimed at another clipper -- and a - float in the middle is a shape one placement cannot express, so that - placement is suppressed for the frame and returns when the float moves. + are sent after the diff and get it from clipping (D22): what is left of + the image once the float is taken out of it. A clean edge leaves one + rectangle, and anything else leaves several -- each placed in its own + right, because a placement is one rectangle and the remainder need not + be. Suppressing the image instead, which is what this used to do, meant + a floating pane crossing a picture erased all of it. Every pane here runs the same command, so the float shows the image too; the tiled pane's placement is told apart by the image id it had before @@ -622,10 +644,22 @@ def test_a_float_occludes_a_placement(): str(got), ) - # Across the middle: no single rect can say what remains. + # Across the middle: what is left is the columns on either side of it. s.api("float", x=img["x"] + 10, y=0, w=30, h=28) s.settle(20) - check("a middle strip suppresses it", mine() == [], str(places(s))) + got = sorted(mine(), key=lambda p: p["x"]) + check( + "a middle strip leaves the columns beside it", + len(got) == 2 + and (got[0]["x"], got[0]["cols"]) == (img["x"], 10) + and (got[1]["x"], got[1]["cols"]) == (img["x"] + 40, img["cols"] - 40), + str(got), + ) + check( + "and each piece crops to the cells it covers", + all(p["cols"] and p["rows"] == img["rows"] for p in got), + str(got), + ) # Moved clear: the placement returns whole. s.api("float", x=img["x"], y=14, w=30, h=12) @@ -637,6 +671,25 @@ def test_a_float_occludes_a_placement(): str(got), ) + # Landing inside it: what is left is the strips around the float, and + # they tile exactly the image minus what the float covers -- the + # float's rect being its frame's, not the content's. + s.api("float", x=img["x"] + 10, y=img["y"] + 1, w=20, h=3) + s.settle(20) + got = mine() + fl = [p for p in s.api("panes")["panes"] if p["floating"]][0] + ox0, oy0 = max(img["x"], fl["x"]), max(img["y"], fl["y"]) + ox1 = min(img["x"] + img["cols"], fl["x"] + fl["w"]) + oy1 = min(img["y"] + img["rows"], fl["y"] + fl["h"]) + hidden = max(0, ox1 - ox0) * max(0, oy1 - oy0) + covered = sum(p["cols"] * p["rows"] for p in got) + check( + "a float over the middle of an image leaves the strips around it", + len(got) > 1 and covered == img["cols"] * img["rows"] - hidden, + f"{len(got)} pieces covering {covered} of " + f"{img['cols'] * img['rows'] - hidden}: {got}", + ) + # Over the top rows: cropped from the top, the source origin moving. s.api("float", x=0, y=0, w=100, h=img["y"] + 3) s.settle(20) @@ -648,6 +701,49 @@ def test_a_float_occludes_a_placement(): ) +def test_a_modal_is_not_drawn_under_an_image(): + """The cheatsheet, a toast and the splash are painted over the panes, and + a placement is not painted at all -- the client's terminal draws the image + over the cells whatever order they arrived in. So an overlay has to be cut + out of the image underneath it, the way a float is, or it reads through a + picture that is drawn on top of it.""" + with Session(sends_image(cols=60, rows=16, after="cat"), cols=100, rows=28) as s: + s.settle(200) + base = places(s) + check("placed to begin with", len(base) == 1, str(base)) + if not base: + return + img = base[0] + mid = (img["x"] + img["cols"] // 2, img["y"] + img["rows"] // 2) + covers = lambda x, y: any( + p["x"] <= x < p["x"] + p["cols"] and p["y"] <= y < p["y"] + p["rows"] + for p in places(s) + ) + check("the image covers the middle of itself", covers(*mid), str(base)) + + s.send(r"\x01?") # the cheatsheet, centred over everything + s.settle() + check( + "the cheatsheet is cut out of the image", + not covers(*mid) and places(s) != [], + str(places(s)), + ) + check( + "and what is beside it is still placed", + covers(img["x"], img["y"]), + str(places(s)), + ) + + s.send("q") # anything dismisses it + s.settle() + got = places(s) + check( + "the image is whole again once it is dismissed", + len(got) == 1 and got[0]["cols"] == img["cols"], + str(got), + ) + + if __name__ == "__main__": test_a_pane_image_reaches_the_screen() test_a_png_is_decoded_and_placed() @@ -661,6 +757,7 @@ if __name__ == "__main__": test_an_image_outlives_a_screen_clear() test_a_screen_clear_still_removes_what_is_on_screen() test_sub_cell_offsets_survive_to_the_client() + test_a_placement_says_it_sits_above_the_text() test_a_placement_with_no_offset_emits_none() test_a_natural_image_is_never_rescaled_as_it_moves() test_a_scaled_image_keeps_the_cell_count_it_asked_for() @@ -675,4 +772,5 @@ if __name__ == "__main__": test_scrolled_away_placements_are_dropped() test_an_undelivered_frame_stays_owed() test_a_float_occludes_a_placement() + test_a_modal_is_not_drawn_under_an_image() sys.exit(report()) |
