aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--include/app.h3
-rw-r--r--include/app_internal.h16
-rw-r--r--include/graphics.h26
-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
-rw-r--r--tests/live_graphics.py67
-rw-r--r--tests/test_graphics.py110
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);
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;
}
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())