aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorIngmar Steen <iksteen@gmail.com>2026-09-06 19:41:18 +0200
committerIngmar Steen <iksteen@gmail.com>2026-09-06 19:41:18 +0200
commit06b22770e0ff2d03dffafed223c63d7d4608020c (patch)
tree1e83190fa17ca21530502608e705923e8134cc07 /src
parentserver: one synchronized message per frame, cells and images together (diff)
downloadslosh-06b22770e0ff2d03dffafed223c63d7d4608020c.tar.gz
slosh-06b22770e0ff2d03dffafed223c63d7d4608020c.tar.xz
slosh-06b22770e0ff2d03dffafed223c63d7d4608020c.zip
graphics: place an image once, not once per frame
Every frame re-emitted `a=p` for every visible placement. On a terminal that is invisible: 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 saying it again says nothing new. It is not free anywhere else. A mirror cannot know that the identical placement is identical: shellglass re-decodes and re-encodes the image on every one, holds the placement back while that runs, and -- because an image change rides only in a full frame -- republishes the whole screen each time. Measured on one image and a pane printing text for twenty seconds, the mirror published 430 full frames and re-sent the image list 215 times; 215 of those fulls carried no image at all, because the placement was still mid-decode. That alternation is what was visible in the browser: a picture that flickered while anything on screen moved and vanished when it stopped. The same twenty seconds now costs one full frame, 412 diffs and one image list. So a placement is emitted only when it is new or has actually changed, and the client's state records what it is holding. Three things have to be right for that to be safe: a dropped frame un-marks the placements it carried (they are owed again, exactly as transmissions and deletions already were); a retransmit of the pixels invalidates the placement, because kitty deletes an image's placements when its data is replaced; and a full repaint clears the screen, which takes the images with it, so every placement is owed again -- gfx_repaint(), not gfx_reset(), because the pixels the terminal was sent survive a clear and a deletion still owed from a dropped frame is still owed. The live check drives a picture that does not move while the screen around it repaints: one placement across 121 frames, where the old behaviour emitted 120. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
Diffstat (limited to 'src')
-rw-r--r--src/app_draw.c2
-rw-r--r--src/graphics.c71
-rw-r--r--src/server.c7
3 files changed, 74 insertions, 6 deletions
diff --git a/src/app_draw.c b/src/app_draw.c
index 2955c11..c3486fc 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -2532,6 +2532,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);
}
diff --git a/src/graphics.c b/src/graphics.c
index ac8c244..726e564 100644
--- a/src/graphics.c
+++ b/src/graphics.c
@@ -32,6 +32,10 @@ struct graphics {
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;
@@ -90,6 +94,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 +105,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 +128,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,9 +213,12 @@ 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;
@@ -217,6 +235,22 @@ void gfx_place(graphics_t *g, const gfx_req_t *req) {
slot->out_id = img->out_id;
slot->place_id = pid;
}
+ /* 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 +264,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) {
@@ -282,10 +317,30 @@ char *gfx_flush(graphics_t *g, size_t *out_len) {
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);
- /* 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);
/* c=/r= mean *scale into this many cells*, so they are passed on only
@@ -326,12 +381,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 bb233ca..c8a481d 100644
--- a/src/server.c
+++ b/src/server.c
@@ -353,8 +353,15 @@ static bool push_display(server_t *s, conn_t *c) {
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);
+ /* 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);