aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-07 15:52:00 +0200
committeruser <user@clank>2026-09-07 15:52:00 +0200
commite9147fba11b735259a90089dd7207853ff49a3ad (patch)
tree6dc86b56987c7bb2e2dba4a3151cf4576efab2b0 /src
parentscreen: commit a wide cell's tail to prev, or the diff leaves a hole (diff)
parentapp: cut the modals, toasts and splash out of the images under them (diff)
downloadslosh-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.c10
-rw-r--r--src/app_draw.c275
-rw-r--r--src/app_ui.c7
-rw-r--r--src/graphics.c96
-rw-r--r--src/server.c112
5 files changed, 340 insertions, 160 deletions
diff --git a/src/app.c b/src/app.c
index cc06924..7ff584b 100644
--- a/src/app.c
+++ b/src/app.c
@@ -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;
}