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 /src | |
| 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.
Diffstat (limited to 'src')
| -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 |
5 files changed, 340 insertions, 160 deletions
@@ -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; } |
