From 42374342711adfe3d39bb60d0bb9b45240ed5b06 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Fri, 16 Oct 2026 17:26:41 +0100 Subject: [PATCH] Kitty image input path. --- .github/workflows/regress.yml | 5 +- Makefile.am | 2 + configure.ac | 38 ++ grid-view.c | 73 ++- grid.c | 15 +- image-decode.c | 208 ++++++++ image-grid.c | 426 ++++++++++++++-- image-kitty.c | 781 +++++++++++++++++++++++++++++ image-sixel.c | 4 +- image.c | 36 +- input.c | 16 +- regress/image-kitty-decode.sh | 127 +++++ regress/image-kitty-delete.sh | 148 ++++++ regress/image-kitty-layers.sh | 135 +++++ regress/image-kitty-lifecycle.sh | 109 ++++ regress/image-kitty-persistence.sh | 207 ++++++++ regress/image-kitty-placements.sh | 193 +++++++ regress/image-kitty-prompts.sh | 53 ++ regress/image-kitty-protocol.sh | 432 ++++++++++++++++ regress/image-kitty-transfers.sh | 102 ++++ regress/image-limits.sh | 141 ++++++ regress/image-resize-height.sh | 55 ++ regress/image-sixel-palette.sh | 40 +- regress/image_terminal.py | 16 + regress/image_test.py | 22 + screen-write.c | 18 +- screen.c | 8 + tmux-image.h | 50 +- tmux.h | 2 + 29 files changed, 3389 insertions(+), 73 deletions(-) create mode 100644 image-decode.c create mode 100644 image-kitty.c create mode 100755 regress/image-kitty-decode.sh create mode 100755 regress/image-kitty-delete.sh create mode 100644 regress/image-kitty-layers.sh create mode 100755 regress/image-kitty-lifecycle.sh create mode 100755 regress/image-kitty-persistence.sh create mode 100644 regress/image-kitty-placements.sh create mode 100644 regress/image-kitty-prompts.sh create mode 100644 regress/image-kitty-protocol.sh create mode 100755 regress/image-kitty-transfers.sh create mode 100644 regress/image-limits.sh create mode 100644 regress/image-resize-height.sh diff --git a/.github/workflows/regress.yml b/.github/workflows/regress.yml index 17504b1e1..107fb01f8 100644 --- a/.github/workflows/regress.yml +++ b/.github/workflows/regress.yml @@ -53,8 +53,10 @@ jobs: build-essential \ libevent-dev \ libncurses-dev \ + libpng-dev \ libutf8proc-dev \ - pkg-config + pkg-config \ + zlib1g-dev - name: dependencies if: runner.os == 'macOS' @@ -64,6 +66,7 @@ jobs: automake \ bison \ libevent \ + libpng \ jemalloc \ make \ ncurses \ diff --git a/Makefile.am b/Makefile.am index 81add05ef..78d9f1248 100644 --- a/Makefile.am +++ b/Makefile.am @@ -259,8 +259,10 @@ endif if ENABLE_IMAGES dist_tmux_SOURCES += \ image.c \ + image-decode.c \ image-grid.c \ image-sixel.c \ + image-kitty.c \ image-fallback.c endif diff --git a/configure.ac b/configure.ac index 0a7d81d1d..98221fba0 100644 --- a/configure.ac +++ b/configure.ac @@ -557,6 +557,8 @@ fi AM_CONDITIONAL(HAVE_KEYCTL, [test "x$found_keyctl" = xyes]) # Enable image support. +libpng_version=off +libz_version=off AC_ARG_ENABLE( images, AS_HELP_STRING(--enable-images, enable image support) @@ -571,6 +573,40 @@ AC_ARG_ENABLE( ] ) if test "x$enable_images" = xyes; then + PKG_CHECK_MODULES( + LIBZ, + zlib, + [ + AM_CPPFLAGS="$LIBZ_CFLAGS $AM_CPPFLAGS" + CPPFLAGS="$AM_CPPFLAGS $SAVED_CPPFLAGS" + LIBS="$LIBZ_LIBS $LIBS" + AC_DEFINE(HAVE_LIBZ) + libz_version=`$PKG_CONFIG --modversion zlib 2>/dev/null` + test "x$libz_version" = x && libz_version=on + ], + [ + AC_CHECK_HEADER(zlib.h, [ + AC_CHECK_LIB(z, uncompress, [ + LIBS="-lz $LIBS" + AC_DEFINE(HAVE_LIBZ) + libz_version=on + ]) + ]) + ] + ) + PKG_CHECK_MODULES( + LIBPNG, + [libpng >= 1.6], + [ + AM_CPPFLAGS="$LIBPNG_CFLAGS $AM_CPPFLAGS" + CPPFLAGS="$AM_CPPFLAGS $SAVED_CPPFLAGS" + LIBS="$LIBPNG_LIBS $LIBS" + AC_DEFINE(HAVE_LIBPNG) + libpng_version=`$PKG_CONFIG --modversion libpng 2>/dev/null` + test "x$libpng_version" = x && libpng_version=on + ], + [:] + ) AC_DEFINE(ENABLE_IMAGES) fi AM_CONDITIONAL(ENABLE_IMAGES, [test "x$enable_images" = xyes]) @@ -1184,6 +1220,8 @@ else fi AC_MSG_NOTICE([jemalloc: $jemalloc_version]) AC_MSG_NOTICE([libevent: $libevent_version]) +AC_MSG_NOTICE([libpng: $libpng_version]) +AC_MSG_NOTICE([libz: $libz_version]) AC_MSG_NOTICE([ncurses: $ncurses_version]) AC_MSG_NOTICE([systemd: $systemd_version]) AC_MSG_NOTICE([utempter: $utempter_version]) diff --git a/grid-view.c b/grid-view.c index 8c9794114..0bfb2c12d 100644 --- a/grid-view.c +++ b/grid-view.c @@ -110,6 +110,11 @@ void grid_view_scroll_region_up(struct grid *gd, u_int rupper, u_int rlower, u_int bg) { +#ifdef ENABLE_IMAGES + struct image_saved *saved; +#endif + u_int n = rlower - rupper; + if (gd->flags & GRID_HISTORY) { grid_collect_history(gd, 0); if (rupper == 0 && rlower == gd->sy - 1) @@ -117,12 +122,24 @@ grid_view_scroll_region_up(struct grid *gd, u_int rupper, u_int rlower, else { rupper = grid_view_y(gd, rupper); rlower = grid_view_y(gd, rlower); +#ifdef ENABLE_IMAGES + saved = image_grid_save_lines(gd, rupper, rupper + 1, n, 1); +#endif grid_scroll_history_region(gd, rupper, rlower, bg); +#ifdef ENABLE_IMAGES + image_grid_restore_lines(gd, saved); +#endif } } else { rupper = grid_view_y(gd, rupper); rlower = grid_view_y(gd, rlower); - grid_move_lines(gd, rupper, rupper + 1, rlower - rupper, bg); +#ifdef ENABLE_IMAGES + saved = image_grid_save_lines(gd, rupper, rupper + 1, n, 1); +#endif + grid_move_lines(gd, rupper, rupper + 1, n, bg); +#ifdef ENABLE_IMAGES + image_grid_restore_lines(gd, saved); +#endif } } @@ -131,23 +148,42 @@ void grid_view_scroll_region_down(struct grid *gd, u_int rupper, u_int rlower, u_int bg) { +#ifdef ENABLE_IMAGES + struct image_saved *saved; +#endif + rupper = grid_view_y(gd, rupper); rlower = grid_view_y(gd, rlower); +#ifdef ENABLE_IMAGES + saved = image_grid_save_lines(gd, rupper + 1, rupper, rlower - rupper, 1); +#endif grid_move_lines(gd, rupper + 1, rupper, rlower - rupper, bg); +#ifdef ENABLE_IMAGES + image_grid_restore_lines(gd, saved); +#endif } /* Insert lines. */ void grid_view_insert_lines(struct grid *gd, u_int py, u_int ny, u_int bg) { - u_int sy; +#ifdef ENABLE_IMAGES + struct image_saved *saved; +#endif + u_int sy; py = grid_view_y(gd, py); sy = grid_view_y(gd, gd->sy); +#ifdef ENABLE_IMAGES + saved = image_grid_save_lines(gd, py + ny, py, sy - py - ny, 0); +#endif grid_move_lines(gd, py + ny, py, sy - py - ny, bg); +#ifdef ENABLE_IMAGES + image_grid_restore_lines(gd, saved); +#endif } /* Insert lines in region. */ @@ -155,14 +191,23 @@ void grid_view_insert_lines_region(struct grid *gd, u_int rlower, u_int py, u_int ny, u_int bg) { - u_int ny2; +#ifdef ENABLE_IMAGES + struct image_saved *saved; +#endif + u_int ny2; rlower = grid_view_y(gd, rlower); py = grid_view_y(gd, py); ny2 = rlower + 1 - py - ny; +#ifdef ENABLE_IMAGES + saved = image_grid_save_lines(gd, rlower + 1 - ny2, py, ny2, 0); +#endif grid_move_lines(gd, rlower + 1 - ny2, py, ny2, bg); +#ifdef ENABLE_IMAGES + image_grid_restore_lines(gd, saved); +#endif grid_clear(gd, 0, py + ny2, gd->sx, ny - ny2, bg); } @@ -170,13 +215,22 @@ grid_view_insert_lines_region(struct grid *gd, u_int rlower, u_int py, void grid_view_delete_lines(struct grid *gd, u_int py, u_int ny, u_int bg) { - u_int sy; +#ifdef ENABLE_IMAGES + struct image_saved *saved; +#endif + u_int sy; py = grid_view_y(gd, py); sy = grid_view_y(gd, gd->sy); +#ifdef ENABLE_IMAGES + saved = image_grid_save_lines(gd, py, py + ny, sy - py - ny, 0); +#endif grid_move_lines(gd, py, py + ny, sy - py - ny, bg); +#ifdef ENABLE_IMAGES + image_grid_restore_lines(gd, saved); +#endif grid_clear(gd, 0, sy - ny, gd->sx, ny, bg); } @@ -185,14 +239,23 @@ void grid_view_delete_lines_region(struct grid *gd, u_int rlower, u_int py, u_int ny, u_int bg) { - u_int ny2; +#ifdef ENABLE_IMAGES + struct image_saved *saved; +#endif + u_int ny2; rlower = grid_view_y(gd, rlower); py = grid_view_y(gd, py); ny2 = rlower + 1 - py - ny; +#ifdef ENABLE_IMAGES + saved = image_grid_save_lines(gd, py, py + ny, ny2, 0); +#endif grid_move_lines(gd, py, py + ny, ny2, bg); +#ifdef ENABLE_IMAGES + image_grid_restore_lines(gd, saved); +#endif grid_clear(gd, 0, py + ny2, gd->sx, ny - ny2, bg); } diff --git a/grid.c b/grid.c index 55207ec13..0e01132c1 100644 --- a/grid.c +++ b/grid.c @@ -699,7 +699,7 @@ grid_clear(struct grid *gd, u_int px, u_int py, u_int nx, u_int ny, u_int bg) if (grid_check_y(gd, __func__, py + ny - 1) != 0) return; #ifdef ENABLE_IMAGES - image_grid_remove(gd, px, py, nx, ny); + image_grid_overwrite(gd, px, py, nx, ny); #endif for (yy = py; yy < py + ny; yy++) { @@ -726,7 +726,10 @@ grid_clear(struct grid *gd, u_int px, u_int py, u_int nx, u_int ny, u_int bg) void grid_clear_lines(struct grid *gd, u_int py, u_int ny, u_int bg) { - u_int yy; +#ifdef ENABLE_IMAGES + struct image_line *images; +#endif + u_int yy; if (ny == 0) return; @@ -737,8 +740,16 @@ grid_clear_lines(struct grid *gd, u_int py, u_int ny, u_int bg) return; for (yy = py; yy < py + ny; yy++) { +#ifdef ENABLE_IMAGES + image_grid_overwrite(gd, 0, yy, gd->sx, 1); + images = gd->linedata[yy].images; + gd->linedata[yy].images = NULL; +#endif grid_free_line(gd, yy); grid_empty_line(gd, yy, bg); +#ifdef ENABLE_IMAGES + gd->linedata[yy].images = images; +#endif } if (py != 0) gd->linedata[py - 1].flags &= ~GRID_LINE_WRAPPED; diff --git a/image-decode.c b/image-decode.c new file mode 100644 index 000000000..25b1dabdc --- /dev/null +++ b/image-decode.c @@ -0,0 +1,208 @@ +/* $OpenBSD$ */ + +/* + * Copyright (c) 2026 Nicholas Marriott + * Copyright (c) 2026 Michael Grant + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER + * IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#include +#ifdef HAVE_LIBPNG +#include +#endif +#include +#include +#include +#ifdef HAVE_LIBZ +#include +#endif + +#include "tmux.h" + +/* Decode a Base64 payload. */ +u_char * +image_base64_decode(const char *data, size_t size, size_t limit, size_t *osize) +{ + char *copy; + u_char *o; + size_t needed, padded, padding; + int n; + + if (size > SIZE_MAX - 3) + return (NULL); + padding = (4 - size % 4) % 4; + if (padding == 3) + return (NULL); + padded = size + padding; + needed = padded / 4 * 3; + if (needed > limit || needed > INT_MAX) + return (NULL); + + copy = xmalloc(padded + 1); + memcpy(copy, data, size); + memset(copy + size, '=', padding); + copy[padded] = '\0'; + + if (needed == 0) + o = xmalloc(1); + else + o = xmalloc(needed); + n = b64_pton(copy, o, needed); + free(copy); + if (n < 0) { + free(o); + return (NULL); + } + *osize = n; + return (o); +} + +/* Decompress a payload. */ +#ifdef HAVE_LIBZ +static u_char * +image_zlib_decode(const u_char *data, size_t size, size_t expected, + size_t limit) +{ + u_char *r; + uLongf rsize; + + if (expected == 0 || + expected > limit || + size > limit || + expected > ULONG_MAX || + size > ULONG_MAX) + return (NULL); + rsize = expected; + + r = xmalloc(expected); + if (uncompress(r, &rsize, data, size) != Z_OK || rsize != expected) { + free(r); + return (NULL); + } + return (r); +} +#else +static u_char * +image_zlib_decode(__unused const u_char *data, __unused size_t size, + __unused size_t expected, __unused size_t limit) +{ + return (NULL); +} +#endif + +/* Decode PNG data into RGBA pixels. */ +#ifdef HAVE_LIBPNG +u_char * +image_png_decode(const u_char *data, size_t size, size_t limit, int z, + size_t expected, u_int *width, u_int *height) +{ + png_image pi; + u_char *r = NULL, *p = NULL; + + if (size == 0 || size > limit) + return (NULL); + if (z) { + r = image_zlib_decode(data, size, expected, limit); + if (r == NULL) + return (NULL); + data = r; + size = expected; + } + + memset(&pi, 0, sizeof pi); + pi.version = PNG_IMAGE_VERSION; + if (!png_image_begin_read_from_memory(&pi, data, size)) + goto out; + pi.format = PNG_FORMAT_RGBA; + + if (pi.width == 0 || + pi.height == 0 || + (uint64_t)pi.width * pi.height > limit / 4) + goto out; + p = xmalloc(PNG_IMAGE_SIZE(pi)); + + if (!png_image_finish_read(&pi, NULL, p, 0, NULL)) { + free(p); + p = NULL; + goto out; + } + + *width = pi.width; + *height = pi.height; + +out: + png_image_free(&pi); + free(r); + return (p); +} +#else +u_char * +image_png_decode(__unused const u_char *data, __unused size_t size, + __unused size_t limit, __unused int z, __unused size_t expected, + __unused u_int *width, __unused u_int *height) +{ + return (NULL); +} +#endif + +/* Decode raw RGB or RGBA data. */ +u_char * +image_raw_decode(const u_char *data, size_t size, size_t limit, u_int format, + u_int width, u_int height, int z) +{ + u_char *r = NULL, *o; + size_t expected, i, j; + u_int bytes; + + if (format != 24 && format != 32) + return (NULL); + + if (width == 0 || + height == 0 || + size > limit || + (uint64_t)width * height > limit / 4) + return (NULL); + bytes = (format == 24 ? 3 : 4); + expected = (size_t)width * height * bytes; + + if (z) { + r = image_zlib_decode(data, size, expected, limit); + if (r == NULL) + return (NULL); + data = r; + } else { + if (size != expected) + return (NULL); + } + + if (bytes == 4) { + if (r != NULL) + return (r); + o = xmalloc(expected); + memcpy(o, data, expected); + return (o); + } + + o = xmalloc((size_t)width * height * 4); + for (i = j = 0; i < expected; i += 3, j += 4) { + o[j + 0] = data[i + 0]; + o[j + 1] = data[i + 1]; + o[j + 2] = data[i + 2]; + o[j + 3] = 255; + } + free(r); + return (o); +} diff --git a/image-grid.c b/image-grid.c index 558c34454..12506d013 100644 --- a/image-grid.c +++ b/image-grid.c @@ -23,6 +23,9 @@ #include "tmux.h" +/* Next image placement index. */ +static uint64_t next_image_placement_index; + /* A run of cells on one grid line. */ struct image_span { u_int x; @@ -41,17 +44,19 @@ TAILQ_HEAD(image_spans, image_span); /* Spans on one grid line. */ struct image_line { struct image_spans spans; + int in_region; /* Inside the current scroll region. */ }; /* A placement of an image, may be across multiple lines */ struct image_placement { struct image_store *store; struct image *image; + uint64_t index; struct image_spans spans; - /* Original column and first source column not yet placed. */ u_int x; u_int recover_from; + int scroll; /* -1 unchecked, 0 fixed, 1 moving. */ TAILQ_ENTRY(image_placement) entry; }; @@ -67,6 +72,16 @@ TAILQ_HEAD(image_stores, image_store); /* All current placements. */ static struct image_stores image_stores = TAILQ_HEAD_INITIALIZER(image_stores); +/* Images saved while grid lines are moved. */ +struct image_saved { + u_int dy; + u_int py; + u_int ny; + u_int hsize; + int scroll; + struct image_line **lines; +}; + /* A shared placements copied when duplicating lines. */ struct image_copy_map { struct image_placement *source; @@ -88,7 +103,7 @@ struct image_recover_row { u_int source_y; }; -/* A backend to draw an image. */ +/* Image backend callbacks. */ struct image_backend { const char *name; int (*get_flags)(struct tty *); @@ -100,6 +115,7 @@ struct image_backend { void (*start_draw)(struct tty *, u_int, u_int, u_int, u_int); void (*flush)(struct tty *); void (*free)(struct tty *); + void (*remove_image)(struct image *); }; /* Backend definitions. */ @@ -111,6 +127,7 @@ static struct image_backend image_backend_fallback = { NULL, NULL, NULL, + NULL, NULL }; static struct image_backend image_backend_sixel = { @@ -121,11 +138,24 @@ static struct image_backend image_backend_sixel = { sixel_draw_rect, sixel_start_draw, sixel_flush_pending, - sixel_free_pending + sixel_free_pending, + NULL +}; +static struct image_backend image_backend_kitty = { + "kitty", + NULL, + kitty_select, + NULL, + NULL, + NULL, + NULL, + NULL, + kitty_remove_image }; /* Backends in order of preference. */ static struct image_backend *image_backends[] = { + &image_backend_kitty, &image_backend_sixel, &image_backend_fallback }; @@ -190,7 +220,7 @@ image_draw_span(struct image_backend *backend, struct tty *tty, backend->draw_rect(tty, &rect); } -/* Draw images in the visible part of one screen line. */ +/* Draw images after text on one screen line. */ void image_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, u_int nx, u_int atx, u_int aty) @@ -198,7 +228,7 @@ image_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, struct image_backend *backend; struct image_line *line; struct image_span *span; - u_int start, end, span_end; + u_int start, end, span_end, gy; image_tty_update(tty); backend = tty->image_backend; @@ -206,7 +236,8 @@ image_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, return; if (py >= s->grid->sy) return; - line = s->grid->linedata[s->grid->hsize + py].images; + gy = s->grid->hsize + py; + line = s->grid->linedata[gy].images; if (line == NULL) return; end = px + nx; @@ -297,6 +328,7 @@ image_placement_create(struct grid *gd, struct image *im) plmt = xcalloc(1, sizeof *plmt); plmt->store = store; plmt->image = im; + plmt->index = ++next_image_placement_index; TAILQ_INIT(&plmt->spans); TAILQ_INSERT_TAIL(&store->placements, plmt, entry); image_ref(im); @@ -313,7 +345,42 @@ image_placement_free(struct image_placement *plmt) free(plmt); } -/* Attach a span to both its grid line and its placement. */ +/* Draw images in source order, then placement order. */ +static int +image_compare_placements(struct image_placement *a, struct image_placement *b) +{ + u_int aorder, border; + + aorder = image_get_order_id(a->image); + border = image_get_order_id(b->image); + if (aorder != border) + return (aorder < border ? -1 : 1); + if (a->index != b->index) + return (a->index < b->index ? -1 : 1); + return (0); +} + +/* Insert a span into a grid line. */ +static void +image_span_insert(struct image_line *line, struct image_span *span) +{ + struct image_placement *plmt = span->placement; + struct image_span *at; + int c; + + span->line = line; + TAILQ_FOREACH(at, &line->spans, line_entry) { + c = image_compare_placements(plmt, at->placement); + if (c < 0 || (plmt == at->placement && span->x < at->x)) { + TAILQ_INSERT_BEFORE(at, span, line_entry); + break; + } + } + if (at == NULL) + TAILQ_INSERT_TAIL(&line->spans, span, line_entry); +} + +/* Attach a span in drawing order to its grid line and placement. */ static void image_span_add(struct image_line *line, struct image_placement *plmt, u_int x, u_int sx, u_int src_x, u_int src_y) @@ -322,15 +389,13 @@ image_span_add(struct image_line *line, struct image_placement *plmt, u_int x, if (sx == 0) return; - span = xcalloc(1, sizeof *span); span->x = x; span->sx = sx; span->source_x = src_x; span->source_y = src_y; - span->line = line; span->placement = plmt; - TAILQ_INSERT_TAIL(&line->spans, span, line_entry); + image_span_insert(line, span); TAILQ_INSERT_TAIL(&plmt->spans, span, placement_entry); } @@ -416,11 +481,12 @@ image_span_remove(struct image_span *span, u_int x, u_int end) image_span_free(span); } -/* Remove a range of cells from every span on a line. */ +/* Remove overwritten spans. */ static void -image_line_remove(struct image_line *line, u_int x, u_int w) +image_line_overwrite(struct image_line *line, u_int x, u_int w) { struct image_span *span, *next; + enum image_type type; u_int end; if (line == NULL || w == 0) @@ -428,13 +494,16 @@ image_line_remove(struct image_line *line, u_int x, u_int w) end = x + w; if (end < x) end = UINT_MAX; - TAILQ_FOREACH_SAFE(span, &line->spans, line_entry, next) - image_span_remove(span, x, end); + TAILQ_FOREACH_SAFE(span, &line->spans, line_entry, next) { + type = image_get_type(span->placement->image); + if (type != IMAGE_TYPE_KITTY) + image_span_remove(span, x, end); + } } -/* Remove a rectangle of image spans from a grid. */ +/* Remove SIXEL spans overwritten by text writes or erases. */ void -image_grid_remove(struct grid *gd, u_int x, u_int y, u_int sx, u_int sy) +image_grid_overwrite(struct grid *gd, u_int x, u_int y, u_int sx, u_int sy) { u_int rows = gd->hsize + gd->sy; u_int row; @@ -444,10 +513,30 @@ image_grid_remove(struct grid *gd, u_int x, u_int y, u_int sx, u_int sy) if (sy > rows - y) sy = rows - y; for (row = y; row < y + sy; row++) - image_line_remove(gd->linedata[row].images, x, sx); + image_line_overwrite(gd->linedata[row].images, x, sx); image_store_prune(gd->images); } +/* Check whether a placement has any spans in the visible cells. */ +static int +image_placement_visible(struct grid *gd, struct image_placement *plmt) +{ + struct image_line *line; + struct image_span *span; + u_int row; + + for (row = gd->hsize; row < gd->hsize + gd->sy; row++) { + line = gd->linedata[row].images; + if (line == NULL) + continue; + TAILQ_FOREACH(span, &line->spans, line_entry) { + if (span->placement == plmt && span->x < gd->sx) + return (1); + } + } + return (0); +} + /* Remove a placement and all of its spans. */ static void image_remove_placement(struct image_placement *plmt) @@ -459,12 +548,77 @@ image_remove_placement(struct image_placement *plmt) image_placement_free(plmt); } +/* Check whether a grid retains any placements of an image. */ +int +image_grid_has_image(struct grid *gd, u_int id) +{ + struct image_placement *plmt; + + if (gd->images == NULL) + return (0); + TAILQ_FOREACH(plmt, &gd->images->placements, entry) { + if (image_get_id(plmt->image) == id) + return (1); + } + return (0); +} + +/* Remove all placements of an image from a grid. */ +u_int +image_grid_clear(struct grid *gd, u_int id) +{ + struct image_placement *plmt, *plmt1; + u_int removed = 0; + + if (gd->images == NULL) + return (0); + TAILQ_FOREACH_SAFE(plmt, &gd->images->placements, entry, plmt1) { + if (image_get_id(plmt->image) != id) + continue; + image_remove_placement(plmt); + removed++; + } + return (removed); +} + +/* Remove whole placements of a type which have visible spans. */ +u_int +image_grid_clear_visible(struct grid *gd, enum image_type type) +{ + struct image_placement *plmt, *plmt1; + u_int removed = 0; + + if (gd->images == NULL) + return (0); + TAILQ_FOREACH_SAFE(plmt, &gd->images->placements, entry, plmt1) { + if (image_get_type(plmt->image) != type) + continue; + if (!image_placement_visible(gd, plmt)) + continue; + image_remove_placement(plmt); + removed++; + } + return (removed); +} + +/* Remove placements of an image from a screen. */ +void +image_clear(struct screen_write_ctx *ctx, u_int id) +{ + image_grid_clear(ctx->s->grid, id); + if (ctx->s->saved_grid != NULL) + image_grid_clear(ctx->s->saved_grid, id); + if (ctx->wp != NULL) + ctx->wp->flags |= PANE_REDRAW; +} + /* Remove every placement for an image. */ void image_grid_remove_all(struct image *im) { struct image_store *store; struct image_placement *plmt, *next; + u_int i; TAILQ_FOREACH(store, &image_stores, entry) { TAILQ_FOREACH_SAFE(plmt, &store->placements, entry, next) { @@ -472,6 +626,10 @@ image_grid_remove_all(struct image *im) image_remove_placement(plmt); } } + for (i = 0; i < nitems(image_backends); i++) { + if (image_backends[i]->remove_image != NULL) + image_backends[i]->remove_image(im); + } } /* Free all spans on a grid line. */ @@ -532,6 +690,8 @@ image_grid_move_cells(struct grid *gd, u_int dx, u_int px, u_int py, u_int nx) if (span_end > end) span_end = end; + if (image_get_type(span->placement->image) == IMAGE_TYPE_KITTY) + continue; moves = xreallocarray(moves, n + 1, sizeof *moves); moves[n].placement = span->placement; moves[n].x = dx + start - px; @@ -541,8 +701,8 @@ image_grid_move_cells(struct grid *gd, u_int dx, u_int px, u_int py, u_int nx) span->placement->recover_from = 0; n++; } - image_line_remove(line, px, nx); - image_line_remove(line, dx, nx); + image_line_overwrite(line, px, nx); + image_line_overwrite(line, dx, nx); for (i = 0; i < n; i++) { image_span_add(line, moves[i].placement, moves[i].x, moves[i].sx, moves[i].source_x, moves[i].source_y); @@ -551,7 +711,175 @@ image_grid_move_cells(struct grid *gd, u_int dx, u_int px, u_int py, u_int nx) image_store_prune(gd->images); } -/* Map one source placement to one destination placement during a copy. */ +/* Mark placements to scroll, except those crossing a scroll region edge. */ +static void +image_grid_mark_scroll(struct grid *gd, u_int upper, u_int lower) +{ + struct image_line *line; + struct image_placement *plmt; + struct image_span *span, *at; + u_int y, hsize = gd->hsize; + + for (y = upper; y <= lower; y++) { + line = gd->linedata[y].images; + if (line == NULL) + continue; + line->in_region = 1; + TAILQ_FOREACH(span, &line->spans, line_entry) + span->placement->scroll = -1; + } + for (y = upper; y <= lower; y++) { + line = gd->linedata[y].images; + if (line == NULL) + continue; + TAILQ_FOREACH(span, &line->spans, line_entry) { + plmt = span->placement; + if (plmt->scroll != -1) + continue; + plmt->scroll = 1; + if (upper == hsize && lower == hsize + gd->sy - 1) + continue; + TAILQ_FOREACH(at, &plmt->spans, placement_entry) { + if (!at->line->in_region) { + plmt->scroll = 0; + break; + } + } + } + } +} + +/* Move spans between lines. */ +static void +image_line_move(struct grid_line *dst, struct grid_line *src, int scroll) +{ + struct image_line *line = src->images, *to = NULL; + struct image_span *span, *next; + enum image_type type; + + if (line == NULL) + return; + TAILQ_FOREACH_SAFE(span, &line->spans, line_entry, next) { + type = image_get_type(span->placement->image); + if (type == IMAGE_TYPE_KITTY && + (!scroll || !span->placement->scroll)) + continue; + if (dst == NULL) + image_span_free(span); + else { + if (to == NULL) + to = image_line_get(dst); + TAILQ_REMOVE(&line->spans, span, line_entry); + image_span_insert(to, span); + } + } +} + +/* Move spans if needed. */ +static void +image_grid_move_lines(struct grid *gd, u_int dy, u_int py, u_int ny, int scroll) +{ + struct image_line *line; + u_int rows = gd->hsize + gd->sy; + u_int upper, lower, y, dst, src; + + if (gd->images == NULL || + ny == 0 || + dy == py || + dy >= rows || + py >= rows || + ny > rows - dy || + ny > rows - py) + return; + upper = (dy < py ? dy : py); + lower = (dy > py ? dy : py) + ny - 1; + if (scroll) + image_grid_mark_scroll(gd, upper, lower); + + for (y = dy; y < dy + ny; y++) { + if (y < py || y >= py + ny) + image_line_move(NULL, &gd->linedata[y], scroll); + } + for (y = 0; y < ny; y++) { + if (dy < py) { + dst = dy + y; + src = py + y; + } else { + dst = dy + ny - 1 - y; + src = py + ny - 1 - y; + } + image_line_move(&gd->linedata[dst], &gd->linedata[src], scroll); + } + if (scroll) { + for (y = upper; y <= lower; y++) { + line = gd->linedata[y].images; + if (line != NULL) + line->in_region = 0; + } + } + image_store_prune(gd->images); +} + +/* Detach images before moving lines. */ +struct image_saved * +image_grid_save_lines(struct grid *gd, u_int dy, u_int py, u_int ny, int scroll) +{ + struct image_saved *saved; + u_int rows = gd->hsize + gd->sy; + u_int upper, lower, y, n; + + if (gd->images == NULL || ny == 0 || dy == py || + dy >= rows || py >= rows || ny > rows - dy || ny > rows - py) + return (NULL); + upper = (dy < py ? dy : py); + lower = (dy > py ? dy : py) + ny - 1; + for (y = upper; y <= lower; y++) { + if (gd->linedata[y].images != NULL) + break; + } + if (y > lower) + return (NULL); + + n = lower - upper + 1; + saved = xcalloc(1, sizeof *saved); + saved->lines = xcalloc(n, sizeof *saved->lines); + saved->dy = dy; + saved->py = py; + saved->ny = ny; + saved->hsize = gd->hsize; + saved->scroll = scroll; + for (y = upper; y <= lower; y++) { + saved->lines[y - upper] = gd->linedata[y].images; + gd->linedata[y].images = NULL; + } + return (saved); +} + +/* Restore images and move if needed. */ +void +image_grid_restore_lines(struct grid *gd, struct image_saved *saved) +{ + u_int dy, py, upper, lower, y, offset; + + if (saved == NULL) + return; + + offset = gd->hsize - saved->hsize; + dy = saved->dy + offset; + py = saved->py + offset; + upper = (dy < py ? dy : py); + lower = (dy > py ? dy : py) + saved->ny - 1; + for (y = upper; y <= lower; y++) + gd->linedata[y].images = saved->lines[y - upper]; + + if (offset != 0) + image_grid_move_lines(gd, saved->hsize, dy, 1, 0); + image_grid_move_lines(gd, dy, py, saved->ny, saved->scroll); + free(saved->lines); + free(saved); +} + +/* Map placements during a copy. */ static struct image_placement * image_copy_placement(struct grid *dst, struct image_placement *src, struct image_copy_map **maps, u_int *nmaps) @@ -565,6 +893,7 @@ image_copy_placement(struct grid *dst, struct image_placement *src, return (map[i].destination); } plmt = image_placement_create(dst, src->image); + plmt->index = src->index; plmt->x = src->x; plmt->recover_from = src->recover_from; @@ -745,7 +1074,7 @@ image_damage_screen(struct screen_write_ctx *ctx, u_int x, u_int y, u_int sx, image_redraw_area(ctx, x, y, sx, sy); if (ctx->flags & SCREEN_WRITE_INPUT) - image_grid_remove(s->grid, x, s->grid->hsize + y, sx, sy); + image_grid_overwrite(s->grid, x, s->grid->hsize + y, sx, sy); } /* Check whether an image cell has any visible pixels. */ @@ -803,7 +1132,7 @@ image_cell_covers(struct image *new, u_int nx, u_int ny, struct image *old, return (1); } -/* Discard cells of older placements fully covered by a new image. */ +/* Discard older SIXEL cells fully covered by a new SIXEL image. */ static void image_line_cover(struct image_line *line, struct image_placement *plmt, u_int x, u_int src_x, u_int src_y) @@ -813,7 +1142,11 @@ image_line_cover(struct image_line *line, struct image_placement *plmt, TAILQ_FOREACH_SAFE(span, &line->spans, line_entry, next) { old = span->placement; - if (old == plmt || x < span->x || x >= span->x + span->sx) + if (old == plmt) + continue; + if (image_get_type(old->image) != IMAGE_TYPE_SIXEL) + continue; + if (x < span->x || x >= span->x + span->sx) continue; if (!image_cell_covers(plmt->image, src_x, src_y, old->image, span->source_x + x - span->x, @@ -831,6 +1164,10 @@ image_add_row(struct image_line *line, struct image_placement *plmt, struct image *im = plmt->image; u_int x, i, run; + if (image_get_type(im) == IMAGE_TYPE_KITTY) { + image_span_add(line, plmt, cx, sx, 0, src_y); + return; + } for (x = 0; x < sx; x += run) { if (!image_cell_has_visible_pixels(im, x, src_y)) { run = 1; @@ -840,6 +1177,7 @@ image_add_row(struct image_line *line, struct image_placement *plmt, if (!image_cell_has_visible_pixels(im, x + run, src_y)) break; } + for (i = 0; i < run; i++) image_line_cover(line, plmt, cx + x + i, x + i, src_y); image_span_add(line, plmt, cx + x, run, x, src_y); @@ -854,6 +1192,10 @@ image_extend_row(struct image_line *line, struct image_placement *plmt, struct image *im = plmt->image; u_int x, run; + if (image_get_type(im) == IMAGE_TYPE_KITTY) { + image_span_add(line, plmt, cx + from, to - from, from, src_y); + return; + } for (x = from; x < to; x += run) { if (!image_cell_has_visible_pixels(im, x, src_y)) { run = 1; @@ -915,9 +1257,10 @@ image_grid_resize_width(struct grid *gd, u_int new_sx) } } -/* Place a SIXEL image at the cursor. */ +/* Place an image at the cursor. */ void -image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg) +image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg, + int no_cursor) { struct image_geom *ig = image_get_geometry(im); struct screen *s = ctx->s; @@ -927,6 +1270,7 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg) struct image_line *line; u_int cx = s->cx, cy = s->cy, y, lines; u_int sx = ig->sx, sy = ig->sy; + u_int cursor_x = 0, cursor_rows; u_int origin_y = 0, h_origin_y, region_height; u_int remaining, n; @@ -935,9 +1279,17 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg) if (sx == 0) return; + /* Kitty moves to the last image row; SIXEL advances past it. */ + cursor_rows = sy; + if (image_get_type(im) == IMAGE_TYPE_KITTY) + cursor_rows--; + /* Scroll images that move the cursor. */ - if (screen_size_y(s) - cy <= sy) { - lines = sy - (screen_size_y(s) - cy) + 1; + if (no_cursor) { + if (sy > screen_size_y(s) - cy) + sy = screen_size_y(s) - cy; + } else if (screen_size_y(s) - cy <= cursor_rows) { + lines = cursor_rows - (screen_size_y(s) - cy) + 1; region_height = s->rlower - s->rupper + 1; remaining = lines; @@ -959,6 +1311,7 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg) } cy = s->cy; sy -= origin_y; + cursor_rows -= origin_y; } plmt = image_placement_create(gd, im); if (sx < ig->sx) { @@ -982,10 +1335,14 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg) image_add_row(line, plmt, cx, origin_y + y, sx); } - /* Schedule a redraw and advance the cursor past the image. */ + /* Schedule a redraw and move the cursor. */ image_store_prune(gd->images); image_redraw_area(ctx, cx, cy, sx, sy); - screen_write_cursormove(ctx, 0, cy + sy, 0); + if (!no_cursor) { + if (image_get_type(im) == IMAGE_TYPE_KITTY) + cursor_x = cx + sx; + screen_write_cursormove(ctx, cursor_x, cy + cursor_rows, 0); + } } /* Find the cell within an image at a grid position. */ @@ -995,6 +1352,8 @@ image_grid_get_at(struct grid *gd, u_int x, u_int y, struct image **im, { struct image_line *line; struct image_span *span, *found = NULL; + struct image *pim; + u_int px; if (y >= gd->hsize + gd->sy) return (0); @@ -1002,8 +1361,13 @@ image_grid_get_at(struct grid *gd, u_int x, u_int y, struct image **im, if (line == NULL) return (0); TAILQ_FOREACH(span, &line->spans, line_entry) { - if (x >= span->x && x < span->x + span->sx) - found = span; + if (x < span->x || x >= span->x + span->sx) + continue; + pim = span->placement->image; + px = span->source_x + x - span->x; + if (!image_cell_has_visible_pixels(pim, px, span->source_y)) + continue; + found = span; } if (found == NULL) return (-1); diff --git a/image-kitty.c b/image-kitty.c new file mode 100644 index 000000000..4ef47469a --- /dev/null +++ b/image-kitty.c @@ -0,0 +1,781 @@ +/* $OpenBSD$ */ + +/* + * Copyright (c) 2026 Michael Grant + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY, FITNESS AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH + * THIS SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#include + +#include +#include +#include +#include + +#include "tmux.h" + +/* State attached to an input parser. */ +struct kitty_state { + char action; + u_int format; + char medium; + char compression; + char delete; + u_int width; + u_int height; + u_int source_x; + u_int source_y; + u_int source_width; + u_int source_height; + u_int x_offset; + u_int y_offset; + u_int columns; + u_int rows; + u_int image_id; + u_int image_number; + u_int placement_id; + u_int quiet; + u_int data_size; + int no_cursor; + int more; + + u_char *data; + size_t size; + size_t allocated; + + TAILQ_HEAD(, kitty_image_map) images; + TAILQ_ENTRY(kitty_state) entry; +}; +TAILQ_HEAD(kitty_states, kitty_state); + +/* Kitty input states holding uploaded images. */ +static struct kitty_states kitty_states = TAILQ_HEAD_INITIALIZER(kitty_states); + +/* Map a Kitty placement ID to an image ID. */ +struct kitty_placement_map { + u_int kitty_id; + u_int image_id; + + TAILQ_ENTRY(kitty_placement_map) entry; +}; + +/* Map Kitty image ID to image ID and placement views. */ +struct kitty_image_map { + u_int kitty_id; + u_int image_id; + TAILQ_HEAD(, kitty_placement_map) placements; + + TAILQ_ENTRY(kitty_image_map) entry; +}; + +/* Select Kitty output. */ +int +kitty_select(__unused struct tty *tty) +{ + return (0); +} + +/* Parse Kitty graphics control data. */ +static int +kitty_parse_control_data(struct kitty_state *ks, const u_char *data, + size_t size, const char **m) +{ + char *copy, *ptr, *next, *value; + const char *errstr; + u_int number; + char key; + int z; + + *m = NULL; + + copy = ptr = xstrndup(data, size); + while ((next = strsep(&ptr, ",")) != NULL) { + if (*next == '\0') { + if (ptr == NULL) + break; + *m = (*m == NULL ? "EINVAL" : *m); + continue; + } + key = next[0]; + if (next[1] != '=' || next[2] == '\0') { + *m = (*m == NULL ? "EINVAL" : *m); + continue; + } + value = next + 2; + + switch (key) { + case 'a': + case 't': + case 'o': + case 'd': + if (value[1] != '\0') { + *m = (*m == NULL ? "EINVAL" : *m); + break; + } + switch (key) { + case 'a': + ks->action = value[0]; + break; + case 't': + ks->medium = value[0]; + break; + case 'o': + ks->compression = value[0]; + break; + case 'd': + ks->delete = value[0]; + break; + } + break; + case 'z': + z = strtonum(value, INT_MIN, INT_MAX, &errstr); + if (errstr != NULL) + *m = (*m == NULL ? "EINVAL" : *m); + else if (z != 0) + *m = (*m == NULL ? "ENOTSUP" : *m); + break; + case 'U': + case 'P': + case 'Q': + case 'H': + case 'V': + if (strtonum(value, INT_MIN, UINT_MAX, &errstr) != 0) + *m = (*m == NULL ? "ENOTSUP" : *m); + else if (errstr != NULL) + *m = (*m == NULL ? "ENOTSUP" : *m); + break; + case 'f': + case 's': + case 'v': + case 'i': + case 'I': + case 'p': + case 'q': + case 'm': + case 'S': + case 'x': + case 'y': + case 'w': + case 'h': + case 'X': + case 'Y': + case 'c': + case 'r': + case 'C': + number = strtonum(value, 0, UINT_MAX, &errstr); + if (errstr != NULL) { + *m = (*m == NULL ? "EINVAL" : *m); + break; + } + switch (key) { + case 'f': + ks->format = number; + break; + case 's': + ks->width = number; + break; + case 'v': + ks->height = number; + break; + case 'i': + ks->image_id = number; + break; + case 'I': + ks->image_number = number; + break; + case 'p': + ks->placement_id = number; + break; + case 'q': + ks->quiet = number; + break; + case 'm': + ks->more = (number != 0); + break; + case 'S': + ks->data_size = number; + break; + case 'x': + ks->source_x = number; + break; + case 'y': + ks->source_y = number; + break; + case 'w': + ks->source_width = number; + break; + case 'h': + ks->source_height = number; + break; + case 'X': + ks->x_offset = number; + break; + case 'Y': + ks->y_offset = number; + break; + case 'c': + ks->columns = number; + break; + case 'r': + ks->rows = number; + break; + case 'C': + ks->no_cursor = (number != 0); + break; + } + break; + } + } + free(copy); + if (*m != NULL) + return (-1); + return (0); +} + +/* Free Kitty graphics parser state. */ +void +kitty_free_state(void *state) +{ + struct kitty_state *ks = state; + struct kitty_image_map *map; + struct kitty_placement_map *kpl; + + if (ks == NULL) + return; + TAILQ_REMOVE(&kitty_states, ks, entry); + while ((map = TAILQ_FIRST(&ks->images)) != NULL) { + TAILQ_REMOVE(&ks->images, map, entry); + while ((kpl = TAILQ_FIRST(&map->placements)) != NULL) { + TAILQ_REMOVE(&map->placements, kpl, entry); + free(kpl); + } + image_free(image_find(map->image_id)); + free(map); + } + free(ks->data); + free(ks); +} + +/* Remove a Kitty image. */ +void +kitty_remove_image(struct image *im) +{ + struct kitty_state *ks; + struct kitty_image_map *map, *next; + struct kitty_placement_map *kpl, *kpl1; + u_int id = image_get_id(im); + + TAILQ_FOREACH(ks, &kitty_states, entry) { + TAILQ_FOREACH_SAFE(map, &ks->images, entry, next) { + TAILQ_FOREACH_SAFE(kpl, &map->placements, entry, kpl1) { + if (map->image_id != id && kpl->image_id != id) + continue; + TAILQ_REMOVE(&map->placements, kpl, entry); + free(kpl); + } + if (map->image_id != id) + continue; + TAILQ_REMOVE(&ks->images, map, entry); + image_free(im); + free(map); + } + } +} + +/* Find an image mapping by Kitty image ID. */ +static struct kitty_image_map * +kitty_image_map_find(struct kitty_state *ks, u_int id) +{ + struct kitty_image_map *map; + + TAILQ_FOREACH(map, &ks->images, entry) { + if (map->kitty_id == id) + return (map); + } + return (NULL); +} + +/* Replace the image associated with a Kitty image ID. */ +static struct kitty_image_map * +kitty_image_map_set(struct kitty_state *ks, struct screen_write_ctx *ctx, + u_int id, struct image *im) +{ + struct kitty_image_map *map; + struct kitty_placement_map *kpl; + struct image *old; + + if (id == 0) + return (NULL); + map = kitty_image_map_find(ks, id); + if (map != NULL) { + old = image_find(map->image_id); + image_set_order_id(im, image_get_order_id(old)); + while ((kpl = TAILQ_FIRST(&map->placements)) != NULL) { + TAILQ_REMOVE(&map->placements, kpl, entry); + image_clear(ctx, kpl->image_id); + free(kpl); + } + image_free(image_find(map->image_id)); + } else { + map = xcalloc(1, sizeof *map); + map->kitty_id = id; + TAILQ_INIT(&map->placements); + TAILQ_INSERT_HEAD(&ks->images, map, entry); + } + image_ref(im); + map->image_id = image_get_id(im); + return (map); +} + +/* Replace a named placement, or add a new anonymous placement. */ +static void +kitty_placement_map_set(struct screen_write_ctx *ctx, + struct kitty_image_map *map, u_int id, struct image *im) +{ + struct kitty_placement_map *kpl, *kpl1; + + if (map == NULL) + return; + TAILQ_FOREACH_SAFE(kpl, &map->placements, entry, kpl1) { + if (image_find(kpl->image_id) == NULL) { + TAILQ_REMOVE(&map->placements, kpl, entry); + free(kpl); + continue; + } + if (id != 0 && kpl->kitty_id == id) + break; + } + if (kpl == NULL) { + kpl = xcalloc(1, sizeof *kpl); + kpl->kitty_id = id; + TAILQ_INSERT_TAIL(&map->placements, kpl, entry); + } else + image_clear(ctx, kpl->image_id); + kpl->image_id = image_get_id(im); +} + +/* Remove mappings for absent placements. */ +static u_int +kitty_prune_placements(struct kitty_image_map *map, struct screen *s) +{ + struct kitty_placement_map *kpl, *kpl1; + u_int removed = 0; + struct grid *sgd = s->saved_grid; + + TAILQ_FOREACH_SAFE(kpl, &map->placements, entry, kpl1) { + if (image_grid_has_image(s->grid, kpl->image_id)) + continue; + if (sgd != NULL && image_grid_has_image(sgd, kpl->image_id)) + continue; + TAILQ_REMOVE(&map->placements, kpl, entry); + free(kpl); + removed++; + } + return (removed); +} + +/* Delete selected Kitty placements. */ +static void +kitty_delete_images(struct kitty_state *ks, struct screen_write_ctx *ctx) +{ + struct screen *s = ctx->s; + struct grid *gd = s->grid; + struct kitty_image_map *map, *map1; + struct kitty_placement_map *kpl; + u_int removed; + char how = ks->delete; + int release; + + release = (how >= 'A' && how <= 'Z'); + if (release) + how += 'a' - 'A'; + TAILQ_FOREACH(map, &ks->images, entry) + kitty_prune_placements(map, s); + if (how == 'a') + image_grid_clear_visible(gd, IMAGE_TYPE_KITTY); + + TAILQ_FOREACH_SAFE(map, &ks->images, entry, map1) { + removed = 0; + if (how == 'i') { + if (map->kitty_id != ks->image_id) + continue; + TAILQ_FOREACH(kpl, &map->placements, entry) { + if (ks->placement_id != 0 && + kpl->kitty_id != ks->placement_id) + continue; + removed += image_grid_clear(gd, kpl->image_id); + } + if (ks->placement_id == 0) + removed++; + } + removed += kitty_prune_placements(map, s); + if (!release || removed == 0 || !TAILQ_EMPTY(&map->placements)) + continue; + TAILQ_REMOVE(&ks->images, map, entry); + image_free(image_find(map->image_id)); + free(map); + } + if (ctx->wp != NULL) + ctx->wp->flags |= PANE_REDRAW; +} + +/* Decode and append one base64-encoded Kitty payload chunk. */ +static int +kitty_append_data(struct kitty_state *ks, const u_char *data, size_t size) +{ + u_char *d; + size_t dsize, allocated; + + if (size > IMAGE_SIZE_LIMIT) + return (-1); + + d = image_base64_decode(data, size, IMAGE_SIZE_LIMIT, &dsize); + if (d == NULL) + return (-1); + if (dsize == 0) { + free(d); + return (0); + } + if (dsize > IMAGE_SIZE_LIMIT - ks->size) { + free(d); + return (-1); + } + + if (ks->size + dsize > ks->allocated) { + allocated = ks->allocated; + if (allocated == 0) + allocated = 4096; + while (allocated < ks->size + dsize) + allocated *= 2; + ks->data = xrealloc(ks->data, allocated); + ks->allocated = allocated; + } + memcpy(ks->data + ks->size, d, dsize); + ks->size += dsize; + + free(d); + return (0); +} + +/* Reply to a Kitty graphics sequence. */ +static void +kitty_send_reply(struct input_ctx *ictx, struct kitty_state *ks, const char *m) +{ + char i[96] = ""; + char I[96] = ""; + char p[96] = ""; + + if (ks->quiet >= 2) + return; + if (ks->action == 'd' && strcmp(m, "OK") == 0) + return; + if (ks->quiet == 1 && strcmp(m, "OK") == 0) + return; + if (ks->image_id == 0 && ks->image_number == 0 && ks->action != 'q') + return; + + if (ks->image_id != 0 || ks->image_number == 0) + xsnprintf(i, sizeof i, "i=%u", ks->image_id); + if (ks->image_number != 0) { + if (*i != '\0') + strlcat(i, ",", sizeof i); + xsnprintf(I, sizeof I, "I=%u", ks->image_number); + } + if (ks->placement_id != 0) + xsnprintf(p, sizeof p, ",p=%u", ks->placement_id); + input_reply(ictx, 0, "\033_G%s%s%s;%s\033\\", i, I, p, m); +} + +/* Create a Kitty placement with shared source pixels and stored geometry. */ +static struct image * +kitty_place_image(struct image *src, struct kitty_state *ks, u_int xpixel, + u_int ypixel) +{ + struct image_geom *sg = image_get_geometry(src); + enum image_type type = image_get_type(src); + struct image_geom ig = { 0 }; + u_char *pixels; + size_t stride; + uint64_t cw, ch; + double size; + + ig.src_x = ks->source_x; + ig.src_y = ks->source_y; + + /* Clip the source rectangle. */ + if (ig.src_x >= sg->src_w || ig.src_y >= sg->src_h) + return (NULL); + ig.src_w = sg->src_w - ig.src_x; + if (ks->source_width != 0 && ig.src_w > ks->source_width) + ig.src_w = ks->source_width; + ig.src_h = sg->src_h - ig.src_y; + if (ks->source_height != 0 && ig.src_h > ks->source_height) + ig.src_h = ks->source_height; + + /* Clamp offsets to the starting cell. */ + if (xpixel == 0) + xpixel = 8; + if (ypixel == 0) + ypixel = 16; + ig.canvas_xoff = ks->x_offset; + if (ig.canvas_xoff >= xpixel) + ig.canvas_xoff = xpixel - 1; + ig.canvas_yoff = ks->y_offset; + if (ig.canvas_yoff >= ypixel) + ig.canvas_yoff = ypixel - 1; + + /* Work out the size. */ + ig.sx = ks->columns; + ig.sy = ks->rows; + ig.scaled_width = ig.src_w; + ig.scaled_height = ig.src_h; + if (ig.sx == 0 && ig.sy == 0) { + if (ig.canvas_xoff > UINT_MAX - ig.src_w || + ig.canvas_yoff > UINT_MAX - ig.src_h) + return (NULL); + image_size_in_cells(ig.src_w + ig.canvas_xoff, + ig.src_h + ig.canvas_yoff, xpixel, ypixel, &ig.sx, &ig.sy); + } else if (ig.sy == 0) { + ig.scaled_width = (double)ig.sx * xpixel - ig.canvas_xoff; + ig.scaled_height = ig.src_h * ig.scaled_width / ig.src_w; + + size = ceil((ig.scaled_height + ig.canvas_yoff) / ypixel); + if (size > USHRT_MAX) + return (NULL); + ig.sy = size; + } else if (ig.sx == 0) { + ig.scaled_height = (double)ig.sy * ypixel - ig.canvas_yoff; + ig.scaled_width = ig.src_w * ig.scaled_height / ig.src_h; + + size = ceil((ig.scaled_width + ig.canvas_xoff) / xpixel); + if (size > USHRT_MAX) + return (NULL); + ig.sx = size; + } else { + ig.scaled_width = (double)ig.sx * xpixel - ig.canvas_xoff; + ig.scaled_height = (double)ig.sy * ypixel - ig.canvas_yoff; + } + if (ig.sx > USHRT_MAX || + ig.sy > USHRT_MAX || + ig.sx * ig.sy > IMAGE_SIZE_LIMIT) + return (NULL); + + /* Record geometry in the input terminal's pixel coordinates. */ + cw = (uint64_t)ig.sx * xpixel; + ch = (uint64_t)ig.sy * ypixel; + if (cw > UINT_MAX || ch > UINT_MAX) + return (NULL); + ig.canvas_width = cw; + ig.canvas_height = ch; + pixels = image_get_pixels(src, &stride, NULL); + return (image_create_with_parent(type, src, &ig, stride, pixels)); +} + +/* Parse and reply to a Kitty graphics sequence. */ +void +kitty_parse_input(struct input_ctx *ictx, struct window_pane *wp, + struct screen_write_ctx *ctx, u_int bg, void **state, const u_char *buf, + size_t len) +{ + struct window *w = wp->window; + struct kitty_state *ks = *state; + struct kitty_state cks = { .action = 't' }; + struct image_geom ig = { 0 }; + struct kitty_image_map *map = NULL; + const u_char *found; + const char *m; + u_char *p = NULL; + size_t csize, psize; + u_int sx, sy; + uint64_t cw, ch; + struct image *src = NULL, *im = NULL; + int error; + + buf++; + len--; + + if (ks == NULL) { + ks = xcalloc(1, sizeof *ks); + TAILQ_INIT(&ks->images); + TAILQ_INSERT_TAIL(&kitty_states, ks, entry); + *state = ks; + } + + found = memchr(buf, ';', len); + if (found == NULL) { + csize = len; + psize = 0; + } else { + csize = found - buf; + psize = len - csize - 1; + } + + if (ks->more) { + kitty_parse_control_data(&cks, buf, csize, &m); + if (cks.action == 'd') { + free(ks->data); + ks->data = NULL; + ks->size = 0; + ks->allocated = 0; + ks->more = 0; + } + } + + if (!ks->more) { + ks->action = 't'; + ks->format = 32; + ks->medium = 'd'; + ks->compression = 'o'; + ks->delete = 'a'; + ks->width = 0; + ks->height = 0; + ks->source_x = 0; + ks->source_y = 0; + ks->source_width = 0; + ks->source_height = 0; + ks->x_offset = 0; + ks->y_offset = 0; + ks->columns = 0; + ks->rows = 0; + ks->image_id = 0; + ks->image_number = 0; + ks->placement_id = 0; + ks->quiet = 0; + ks->data_size = 0; + ks->no_cursor = 0; + } + ks->more = 0; + + error = kitty_parse_control_data(ks, buf, csize, &m); + if (error != 0) + goto out; + m = "EINVAL"; + if (ks->image_id == 0 && ks->image_number == 0) + ks->placement_id = 0; + if (ks->image_number != 0) { + if (ks->image_id != 0) + goto out; + m = "ENOTSUP"; + goto out; + } + if (ks->medium != 'd') { + m = "ENOTSUP"; + goto out; + } + if (ks->action != 't' && ks->action != 'T' && + ks->action != 'q' && ks->action != 'p' && ks->action != 'd') { + m = "ENOTSUP"; + goto out; + } + if (ks->action == 'd') { + switch (ks->delete) { + case 'a': + case 'A': + case 'i': + case 'I': + break; + default: + m = "ENOTSUP"; + goto out; + } + kitty_delete_images(ks, ctx); + m = "OK"; + goto out; + } + if (psize != 0 && kitty_append_data(ks, found + 1, psize) != 0) + goto out; + if (ks->more) + return; + + if (ks->action == 'p') { + map = kitty_image_map_find(ks, ks->image_id); + if (map == NULL) { + m = "ENOENT"; + goto out; + } + src = image_find(map->image_id); + image_ref(src); + + im = kitty_place_image(src, ks, w->xpixel, w->ypixel); + if (im == NULL) + goto out; + goto place; + } + + if (ks->compression != 'o' && ks->compression != 'z') + goto out; + switch (ks->format) { + case 24: + case 32: + p = image_raw_decode(ks->data, ks->size, IMAGE_SIZE_LIMIT, + ks->format, ks->width, ks->height, ks->compression == 'z'); + break; + case 100: + p = image_png_decode(ks->data, ks->size, IMAGE_SIZE_LIMIT, + ks->compression == 'z', ks->data_size, &ks->width, + &ks->height); + break; + } + if (p == NULL) + goto out; + + cw = (w->xpixel == 0 ? 8 : w->xpixel); + ch = (w->ypixel == 0 ? 16 : w->ypixel); + image_size_in_cells(ks->width, ks->height, cw, ch, &sx, &sy); + cw *= sx; + ch *= sy; + if (cw > UINT_MAX || ch > UINT_MAX) { + free(p); + goto out; + } + ig.src_w = ig.scaled_width = ks->width; + ig.src_h = ig.scaled_height = ks->height; + ig.canvas_width = cw; + ig.canvas_height = ch; + ig.sx = sx; + ig.sy = sy; + src = image_create(IMAGE_TYPE_KITTY, &ig, p); + if (src == NULL) { + free(p); + goto out; + } + + if (ks->action == 'T') { + im = kitty_place_image(src, ks, w->xpixel, w->ypixel); + if (im == NULL) + goto out; + } + if (ks->action != 'q') + map = kitty_image_map_set(ks, ctx, ks->image_id, src); + +place: + if (im != NULL) { + image_set_order_id(im, image_get_order_id(src)); + kitty_placement_map_set(ctx, map, ks->placement_id, im); + image_write(ctx, im, bg, ks->no_cursor); + } + m = "OK"; + +out: + kitty_send_reply(ictx, ks, m); + if (im != NULL) + image_free(im); + if (src != NULL) + image_free(src); + + free(ks->data); + ks->data = NULL; + ks->size = 0; + ks->allocated = 0; + ks->more = 0; +} diff --git a/image-sixel.c b/image-sixel.c index 0a16733ee..7fcf8b170 100644 --- a/image-sixel.c +++ b/image-sixel.c @@ -667,7 +667,7 @@ sixel_to_image(struct sixel_image *si) ig.canvas_height = sy * si->ypixel; ig.sx = sx; ig.sy = sy; - im = image_create(&ig, pixels); + im = image_create(IMAGE_TYPE_SIXEL, &ig, pixels); if (im == NULL) free(pixels); return (im); @@ -683,7 +683,7 @@ sixel_write(struct screen_write_ctx *ctx, struct sixel_image *si, u_int bg) sixel_free(si); if (im == NULL) return; - image_write(ctx, im, bg); + image_write(ctx, im, bg, 0); image_free(im); } diff --git a/image.c b/image.c index 19a312713..926448350 100644 --- a/image.c +++ b/image.c @@ -34,6 +34,7 @@ TAILQ_HEAD(image_list, image); struct image { u_int id; + enum image_type type; u_int references; struct image_geom geom; @@ -109,6 +110,27 @@ image_get_id(struct image *im) return (im->id); } +/* Get order ID of an image. */ +u_int +image_get_order_id(struct image *im) +{ + return (im->order_id); +} + +/* Set the order ID on an image. */ +void +image_set_order_id(struct image *im, u_int id) +{ + im->order_id = id; +} + +/* Get the type of an image. */ +enum image_type +image_get_type(struct image *im) +{ + return (im->type); +} + /* Remove an image and children. */ static void image_remove_all(struct image *im) @@ -181,7 +203,8 @@ image_check_limits(struct image *keep, size_t size) /* Allocate an image and assign a unique ID. */ static struct image * -image_alloc(struct image_geom *ig, size_t stride, u_char *pixels) +image_alloc(enum image_type type, struct image_geom *ig, size_t stride, + u_char *pixels) { struct image *im; u_int id; @@ -194,6 +217,7 @@ image_alloc(struct image_geom *ig, size_t stride, u_char *pixels) im = xcalloc(1, sizeof *im); im->id = id; + im->type = type; im->references = 1; im->order_id = id; im->geom = *ig; @@ -208,17 +232,17 @@ image_alloc(struct image_geom *ig, size_t stride, u_char *pixels) /* Create an image with the provided pixels. */ struct image * -image_create(struct image_geom *ig, u_char *pixels) +image_create(enum image_type type, struct image_geom *ig, u_char *pixels) { size_t stride = ig->src_w * 4; - return (image_create_with_parent(NULL, ig, stride, pixels)); + return (image_create_with_parent(type, NULL, ig, stride, pixels)); } /* Create an image based on a parent image. */ struct image * -image_create_with_parent(struct image *parent, struct image_geom *ig, - size_t stride, u_char *pixels) +image_create_with_parent(enum image_type type, struct image *parent, + struct image_geom *ig, size_t stride, u_char *pixels) { struct image *im; size_t size = 0; @@ -243,7 +267,7 @@ image_create_with_parent(struct image *parent, struct image_geom *ig, if (image_check_limits(parent, size) != 0) return (NULL); - im = image_alloc(ig, stride, pixels); + im = image_alloc(type, ig, stride, pixels); im->parent = parent; if (parent == NULL) image_size += im->size; diff --git a/input.c b/input.c index 202a7c9f2..7ae38e235 100644 --- a/input.c +++ b/input.c @@ -144,6 +144,9 @@ struct input_ctx { */ struct evbuffer *since_ground; struct event ground_timer; +#ifdef ENABLE_IMAGES + void *kitty_state; +#endif }; /* Helper functions. */ @@ -913,6 +916,9 @@ input_free(struct input_ctx *ictx) event_del(&ictx->request_timer); free(ictx->input_buf); +#ifdef ENABLE_IMAGES + kitty_free_state(ictx->kitty_state); +#endif evbuffer_free(ictx->since_ground); event_del(&ictx->ground_timer); @@ -1172,7 +1178,7 @@ input_send_reply(struct input_ctx *ictx, const char *reply) } /* Reply to terminal query. */ -static void printflike(3, 4) +void printflike(3, 4) input_reply(struct input_ctx *ictx, int add, const char *fmt, ...) { struct input_request *ir; @@ -2803,6 +2809,14 @@ input_exit_apc(struct input_ctx *ictx) return; log_debug("%s: \"%s\" (size %zu)", __func__, buf, len); +#ifdef ENABLE_IMAGES + if (wp != NULL && len > 1 && buf[0] == 'G') { + kitty_parse_input(ictx, wp, sctx, ictx->cell.cell.bg, + &ictx->kitty_state, buf, len); + return; + } +#endif + if (wp != NULL && options_get_number(wp->options, "allow-set-title") && screen_set_title(sctx->s, buf, 1)) { diff --git a/regress/image-kitty-decode.sh b/regress/image-kitty-decode.sh new file mode 100755 index 000000000..a18fa1969 --- /dev/null +++ b/regress/image-kitty-decode.sh @@ -0,0 +1,127 @@ +#!/bin/sh + +# Check RGB, RGBA and PNG pixels, including compression and transparency. +# These tests require libpng and zlib in the tmux build. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +import zlib +from image_test import image_test, kitty, png +from image_terminal import Terminal + + +def rows(data, width, height, channels): + """Split raster bytes into PNG rows.""" + size = width * channels + return [data[y * size:(y + 1) * size] for y in range(height)] + + +def check(test, client, command, expected): + """Check newly decoded pixels and their later redraw.""" + test.emit(b'\033[2J\033[H' + command) + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + + +colours = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)] +w, h = 16, 32 +rgb = [colours[2 * (y >= 16) + (x >= 8)] + for y in range(h) for x in range(w)] +expected = {(x, y): rgb[y * w + x] for y in range(h) for x in range(w)} +raw_rgb = bytes(c for pixel in rgb for c in pixel) +raw_rgba = bytes(c for pixel in rgb for c in (*pixel, 255)) +png_rgb = png(w, h, rows(raw_rgb, w, h, 3), colour_type=2) +png_rgba = png(w, h, rows(raw_rgba, w, h, 4)) + +with image_test(sys.argv[1]) as test: + test.run('set-option', '-g', 'scroll-on-clear', 'off') + client = test.attach() + for format, data in [(24, raw_rgb), (32, raw_rgba), + (100, png_rgb), (100, png_rgba)]: + for compressed in (False, True): + control = 'a=T,i=7,C=1,f=%d' % format + if format != 100: + control += ',s=16,v=32' + payload = data + if compressed: + control += ',o=z,S=%d' % len(data) + payload = zlib.compress(data) + try: + check(test, client, kitty(control, payload), expected) + except AssertionError as e: + raise AssertionError((format, compressed)) from e + + # Palette PNGs retain their colours and transparent palette entries. + indexes = bytes(2 * (y >= 16) + (x >= 8) + for y in range(h) for x in range(w)) + palette = bytes(c for pixel in colours for c in pixel) + data = png(w, h, rows(indexes, w, h, 1), colour_type=3, + chunks=[(b'PLTE', palette), (b'tRNS', b'\xff\0\xff\xff')]) + visible = {point: colour for point, colour in expected.items() + if not (point[0] >= 8 and point[1] < 16)} + check(test, client, kitty('a=T,i=7,f=100,C=1', data), visible) + + # Grayscale and grayscale-alpha PNGs expand to the shared RGBA format. + gray = bytes(255 if y >= 16 else 0 for y in range(h) for x in range(w)) + gray_expected = {(x, y): (gray[y * w + x],) * 3 + for y in range(h) for x in range(w)} + data = png(w, h, rows(gray, w, h, 1), colour_type=0) + check(test, client, kitty('a=T,i=7,f=100,C=1', data), gray_expected) + gray_alpha = bytes(c for y in range(h) for x in range(w) + for c in (gray[y * w + x], 255 if x < 8 else 0)) + data = png(w, h, rows(gray_alpha, w, h, 2), colour_type=4) + check(test, client, kitty('a=T,i=7,f=100,C=1', data), + {point: colour for point, colour in gray_expected.items() + if point[0] < 8}) + + # Test both an entirely transparent cell and holes inside a visible cell. + rgba = bytes(c for y in range(h) for x in range(w) + for c in (*rgb[y * w + x], 0 if (x >= 8 and y < 16) or + (x + y) % 3 == 0 else 255)) + visible = {point: colour for point, colour in expected.items() + if rgba[(point[1] * w + point[0]) * 4 + 3] != 0} + for format, data in [(32, rgba), (100, png(w, h, rows(rgba, w, h, 4)))]: + control = 'a=T,i=7,f=%d,C=1' % format + if format == 32: + control += ',s=16,v=32' + check(test, client, kitty(control, data), visible) + empty = b'\xff\0\0\0' * (w * h) + data = empty if format == 32 else png(w, h, rows(empty, w, h, 4)) + check(test, client, kitty(control, data), {}) + + # SIXEL represents partial alpha as a mixture of coloured and empty pixels. + test.run('set-option', '-as', 'terminal-features', 'screen-256color:RGB') + fallback = test.attach(graphical=False) + rgba = b'\xff\0\0\x40' * 64 + b'\xff\0\0\xc0' * 64 + for format, data in [(32, rgba), (100, png(8, 16, rows(rgba, 8, 16, 4)))]: + control = 'a=T,i=7,f=%d,C=1' % format + if format == 32: + control += ',s=8,v=16' + test.emit(b'\033[2J\033[H' + kitty(control, data)) + for redraw in (False, True): + if redraw: + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + upper = sum(y < 8 for x, y in terminal.pixels) + lower = len(terminal.pixels) - upper + assert 12 <= upper <= 20 and 44 <= lower <= 52, (upper, lower) + assert all(colour == (255, 0, 0) + for colour in terminal.pixels.values()), terminal.pixels + terminal = Terminal() + terminal.feed(fallback.data) + char, fg, bg, bold = terminal.cells[0, 0] + assert char == '\u2584' and not bold, (char, bold) + assert (fg, bg) == ((192, 0, 0), (64, 0, 0)), (fg, bg) +PY diff --git a/regress/image-kitty-delete.sh b/regress/image-kitty-delete.sh new file mode 100755 index 000000000..652558c92 --- /dev/null +++ b/regress/image-kitty-delete.sh @@ -0,0 +1,148 @@ +#!/bin/sh + +# Kitty deletion selects placements and preserves other images and text. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, kitty, sixel +from image_terminal import Terminal + + +def rectangle(colour, xoff=0, yoff=0): + """Expected pixels for a one-column, two-row placement.""" + return {(x + xoff, y + yoff): colour for y in range(32) for x in range(8)} + + +def snapshot(test, client, expected): + """Check image positions and colours after a full redraw.""" + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + return terminal + + +red, green, blue, white = (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255) +uploads = kitty('a=t,i=7,f=32,s=1,v=1', bytes((*red, 255))) + \ + kitty('a=t,i=8,f=32,s=1,v=1', bytes((*green, 255))) +put = 'a=p,i=7,C=1,c=1,r=2' +display = b'\033[H' + kitty(put + ',p=1') + b'\033[1;3H' + \ + kitty(put + ',p=2') + b'\033[1;5H' + \ + kitty('a=p,i=8,p=1,C=1,c=1,r=2') + b'\033[1;7H' + \ + kitty('a=T,f=32,s=1,v=1,C=1,c=1,r=2', bytes((*blue, 255))) + \ + b'\033[1;9H' + sixel(8, 32, [(100, 100, 100)], [0] * 256) + b'\033[2;3H' +pictures = dict(first=rectangle(red), second=rectangle(red, 16), + other=rectangle(green, 32), anonymous=rectangle(blue, 48), + sixel=rectangle(white, 64)) +cases = [ + ('one placement', kitty('a=d,d=i,i=7,p=1'), + ['second', 'other', 'anonymous', 'sixel']), + ('one image', kitty('a=d,d=i,i=7'), ['other', 'anonymous', 'sixel']), + ('placement zero', kitty('a=d,d=i,i=7,p=0'), + ['other', 'anonymous', 'sixel']), + ('placement IDs belong to each image', kitty('a=d,d=i,i=8,p=1'), + ['first', 'second', 'anonymous', 'sixel']), + ('missing image', kitty('a=d,d=I,i=999'), list(pictures)), + ('missing placement', kitty('a=d,d=I,i=7,p=999'), list(pictures)), + ('missing image ID', kitty('a=d,d=I'), list(pictures)), + ('all images', kitty('a=d,d=a'), ['sixel']), + ('default selector', kitty('a=d'), ['sixel']), + ('all ignores image and placement IDs', kitty('a=d,d=A,i=7,p=1'), + ['sixel']), + ('repeated deletion', kitty('a=d,d=I,i=7,p=1') * 2, + ['second', 'other', 'anonymous', 'sixel']), + ('selector resets', kitty('a=d,d=i,i=7') + kitty('a=d'), ['sixel']), +] +for selector in 'cCpPqQrRxXyYzZ': + cases.append(('unsupported selector ' + selector, + kitty('a=d,d=%s,i=7,p=1,x=1,y=2,z=0' % selector), list(pictures))) +for z in (-2147483648, -1, 1, 2147483647): + cases.append(('unsupported layer ' + str(z), + kitty('a=d,d=I,i=7,p=1,z=%d' % z), list(pictures))) +with image_test(sys.argv[1]) as test: + test.run('set-option', '-g', 'scroll-on-clear', 'off') + client = test.attach() + fallback = test.attach(graphical=False) + for name, command, retained in cases: + test.emit(b'\033[2J' + uploads + display) + original = {point: colour for image in pictures.values() + for point, colour in image.items()} + terminal = snapshot(test, client, original) + cursor = test.run('display-message', '-p', '#{cursor_x},#{cursor_y}') + test.emit(command) + wanted = {point: colour for name in retained + for point, colour in pictures[name].items()} + try: + terminal.feed(client.data) + terminal.check(wanted) + snapshot(test, client, wanted) + assert test.run('display-message', '-p', + '#{cursor_x},#{cursor_y}') == cursor + assert not fallback.images(), 'graphics sent to fallback client' + except AssertionError as e: + raise AssertionError(name) from e + + # Deleting the upper placement reveals the lower one. + test.emit(b'\033[2J\033[H' + uploads + kitty(put + ',p=1') + + kitty('a=p,i=8,p=1,C=1,c=1,r=2') + b'\033[1;3H' + kitty(put + ',p=2')) + terminal = snapshot(test, client, rectangle(green) | rectangle(red, 16)) + test.emit(kitty('a=d,d=I,i=8,p=1')) + terminal.feed(client.data) + terminal.check(rectangle(red) | rectangle(red, 16)) + snapshot(test, client, rectangle(red) | rectangle(red, 16)) + test.emit(kitty('a=d,d=A')) + snapshot(test, client, {}) + + # Deleting an image restores the text which was updated beneath it. + test.emit(b'\033[2J\033[H' + uploads + kitty(put + ',p=1') + b'\033[HX') + assert test.run('capture-pane', '-p') == 'X' + test.emit(kitty('a=d,d=I,i=7')) + snapshot(test, client, {}) + for attached in [client, fallback]: + terminal = Terminal() + terminal.feed(attached.data) + assert terminal.cells[0, 0][0] == 'X', terminal.cells[0, 0] + +# A visible part selects its whole placement, including the part in history. +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(uploads + b'\033[H' + kitty(put + ',p=1') + b'\033[S') + snapshot(test, client, {point: colour + for point, colour in rectangle(red, yoff=-16).items() if point[1] >= 0}) + test.emit(kitty('a=d,d=A')) + snapshot(test, client, {}) + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + snapshot(test, client, {}) + test.run('send-keys', '-X', 'cancel') + +# Deletion on the alternate screen leaves the saved primary placements intact. +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(uploads + b'\033[H' + kitty(put + ',p=1') + b'\033[?1049h\033[H' + + kitty(put + ',p=2')) + snapshot(test, client, rectangle(red)) + test.emit(kitty('a=d,d=I,i=7')) + snapshot(test, client, {}) + test.emit(b'\033[?1049l') + snapshot(test, client, rectangle(red)) + test.emit(b'\033[1;3H' + kitty(put + ',p=2')) + snapshot(test, client, rectangle(red) | rectangle(red, 16)) + +# Deletion by image ID also removes placements entirely in history. +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(uploads + b'\033[H' + kitty(put + ',p=1') + b'\033[16S') + snapshot(test, client, {}) + test.emit(kitty('a=d,d=I,i=7')) + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + snapshot(test, client, {}) + test.run('send-keys', '-X', 'cancel') +PY diff --git a/regress/image-kitty-layers.sh b/regress/image-kitty-layers.sh new file mode 100644 index 000000000..4800ec6ef --- /dev/null +++ b/regress/image-kitty-layers.sh @@ -0,0 +1,135 @@ +#!/bin/sh + +# Image ordering and overlapping Kitty placements through SIXEL and fallback. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, kitty, sixel +from image_terminal import Terminal + + +red, green, blue, white = (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255) + + +def upload(id, colour): + """Upload a solid pixel under a Kitty image ID.""" + return kitty('a=t,i=%d,f=32,s=1,v=1' % id, bytes((*colour, 255))) + + +def put(id, controls=''): + """Place a stored image in two columns without moving the cursor.""" + return kitty('a=p,i=%d,C=1,c=2,r=1' % id + controls) + + +def rectangle(colour, xoff=0, yoff=0, w=16, h=16): + """Expected image pixels for a solid rectangle.""" + return {(x + xoff, y + yoff): colour for y in range(h) for x in range(w)} + + +def check(test, client, terminal, expected): + """Check incremental output and a full redraw of the same scene.""" + terminal.feed(client.data) + terminal.check(expected) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + return terminal + + +with image_test(sys.argv[1]) as test: + test.run('set-option', '-g', 'scroll-on-clear', 'off') + client = test.attach() + fallback = test.attach(graphical=False) + terminal = Terminal() + + # The PR's tie-breaker cases: upload order wins over placement and app IDs. + test.emit(upload(200, red) + upload(100, white) + put(100)) + terminal = check(test, client, terminal, rectangle(white)) + test.emit(put(200)) + terminal = check(test, client, terminal, rectangle(white)) + # Replacing the older source preserves its place in the drawing order. + test.emit(upload(200, green) + put(200)) + terminal = check(test, client, terminal, rectangle(white)) + test.emit(kitty('a=d,d=I,i=200') + upload(200, red) + put(200)) + terminal = check(test, client, terminal, rectangle(red)) + # A retransmit-and-place also inherits the old source's drawing order. + test.emit(b'\033[2J\033[H' + upload(200, red) + upload(100, white) + + put(200) + put(100) + kitty('a=T,i=100,f=32,s=1,v=1,C=1,c=2,r=1', + bytes((*green, 255)))) + # 200 was recreated above, so it is newer than 100 despite retransmission. + terminal = check(test, client, terminal, rectangle(red)) + + # Placements of the same source use placement order, including after reflow. + pixels = bytes((*red, 255, *blue, 255)) + test.emit(b'\033[2J\033[H' + kitty('a=t,i=300,f=32,s=2,v=1', pixels) + + put(300, ',p=99,x=1,w=1') + put(300, ',p=1,x=0,w=1')) + terminal = check(test, client, terminal, rectangle(red)) + test.run('resize-window', '-x', '10') + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(rectangle(red)) + test.run('resize-window', '-x', '40') + + # Replacing a named placement puts it after the other source placements. + test.emit(put(300, ',p=99,x=1,w=1')) + terminal = check(test, client, terminal, rectangle(blue)) + test.emit(kitty('a=d,d=i,i=300,p=99')) + terminal = check(test, client, terminal, rectangle(red)) + + # Both protocols use source creation order, even when placed later. + graphics = sixel(16, 16, [(100, 100, 100)], [0] * 256) + test.emit(b'\033[2J\033[H' + upload(400, red) + graphics + + b'\033[H' + put(400)) + terminal = check(test, client, Terminal(), rectangle(white)) + # Replacing the older Kitty source leaves the newer SIXEL on top. + test.emit(upload(400, green) + put(400)) + terminal = check(test, client, terminal, rectangle(white)) + test.emit(b'\033[2J\033[H' + graphics + b'\033[H' + + upload(401, red) + put(401)) + terminal = check(test, client, Terminal(), rectangle(red)) + term = Terminal() + term.feed(fallback.data) + assert term.cells[0, 0][1:3] == (196, 196), term.cells[0, 0] + # Removing the newer Kitty placement reveals the retained SIXEL image. + test.emit(kitty('a=d,d=I,i=401')) + terminal = check(test, client, terminal, rectangle(white)) + term = Terminal() + term.feed(fallback.data) + assert term.cells[0, 0][1:3] == (231, 231), term.cells[0, 0] + + # Transparency preserves the lower image across redraws and deletion. + pixels = bytes(value for y in range(16) for x in range(16) + for value in (*blue, 255 if x < 8 else 0)) + for base, colour in [(upload(500, red) + put(500), red), (graphics, white)]: + test.emit(b'\033[2J\033[H' + base + b'\033[H' + + kitty('a=T,i=501,f=32,s=16,v=16,C=1', pixels)) + terminal = check(test, client, Terminal(), + rectangle(blue, w=8) | rectangle(colour, 8, w=8)) + test.emit(kitty('a=d,d=I,i=501')) + terminal = check(test, client, terminal, rectangle(colour)) + + # Rejected layers cannot replace stored pixels or remove placements. + test.emit(b'\033[2J\033[H' + upload(600, red) + put(600)) + terminal = check(test, client, Terminal(), rectangle(red)) + for action in ('t', 'T', 'p', 'q', 'd'): + test.emit(kitty('a=%s,i=600,p=9,f=32,s=1,v=1,C=1,d=I,z=1' % + action, bytes((*green, 255)))) + terminal = check(test, client, terminal, rectangle(red)) + test.emit(kitty('a=p,i=600,p=9,C=1,c=2,r=1,z=0')) + terminal = check(test, client, terminal, rectangle(red)) + + # Images at the default layer cover text and explicit backgrounds. + test.emit(b'\033[2J\033[H\033[44mX \033[0m\033[H' + put(600)) + check(test, client, Terminal(), rectangle(red)) + term = Terminal() + term.feed(fallback.data) + assert term.cells[0, 0][1:3] == (196, 196), term.cells[0, 0] +PY diff --git a/regress/image-kitty-lifecycle.sh b/regress/image-kitty-lifecycle.sh new file mode 100755 index 000000000..bd7c91491 --- /dev/null +++ b/regress/image-kitty-lifecycle.sh @@ -0,0 +1,109 @@ +#!/bin/sh + +# Kitty placement recovery, reflow, alternate screens and pane-local IDs. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, kitty +from image_terminal import Terminal + + +def snapshot(test, client, expected, sx=40, sy=16): + """Check visible image pixels after an independent redraw.""" + test.redraw() + terminal = Terminal(sx=sx, sy=sy) + terminal.feed(client.data) + terminal.check(expected) + + +red, green, blue = (255, 0, 0), (0, 255, 0), (0, 0, 255) +raw = bytes(c for y in range(32) for x in range(32) + for c in (*(red if x < 16 else blue), 255)) +upload = kitty('a=t,i=7,f=32,s=32,v=32', raw) +place = kitty('a=p,i=7,p=9,C=1') +expected = {(x, y): red if x < 16 else blue + for y in range(32) for x in range(32)} + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(upload + place) + for width in (1, 40, 2, 40): + test.resize(client, width, 16) + snapshot(test, client, + {point: colour for point, colour in expected.items() + if point[0] < width * 8}, sx=width) + # Recovering a clipped named placement must still allow its replacement. + test.emit(b'\033[4;5H' + place) + snapshot(test, client, + {(x + 32, y + 48): colour for (x, y), colour in expected.items()}) + + # An alternate screen has its own grid, while the original grid is retained. + test.emit(b'\033[?1049h') + snapshot(test, client, {}) + test.emit(b'\033[H' + kitty('a=T,i=8,p=9,f=32,s=1,v=1,C=1,c=1,r=1', + bytes((*green, 255)))) + snapshot(test, client, + {(x, y): green for y in range(16) for x in range(8)}) + test.emit(b'\033[?1049l') + snapshot(test, client, + {(x + 32, y + 48): colour for (x, y), colour in expected.items()}) + +# Kitty image rows retain their width while adjacent wrapped text reflows. +with image_test(sys.argv[1], sx=16, sy=20) as test: + client = test.attach() + text = b'abcdefghijklmnopqrstuvwx' + test.emit(text + b'\r\n' + upload + kitty('a=p,i=7,p=9') + + b'\r\n' + text + b'\r\n') + for width in (8, 16, 8, 16): + test.resize(client, width, 20) + top = (len(text) + width - 1) // width - int(test.run( + 'display-message', '-p', '#{history_size}')) + assert top >= 0, top + snapshot(test, client, + {(x, y + top * 16): colour for (x, y), colour in expected.items()}, + sx=width, sy=20) + captured = test.run('capture-pane', '-p', '-S', '-').replace('\n', '') + assert captured == text.decode() * 2, captured + +# Equal application image and placement IDs in separate panes are independent. +with image_test(sys.argv[1]) as test: + client = test.attach() + first = test.run('display-message', '-p', '#{pane_id}') + second = test.run('split-window', '-d', '-P', '-F', '#{pane_id}', + "stty raw -echo; printf '\033]2;SECOND\007'; exec cat") + test.wait(lambda: test.run('display-message', '-pt', second, + '#{pane_title}') == 'SECOND') + placed = {} + for pane, colour in [(first, red), (second, blue)]: + test.run('select-pane', '-t', pane) + test.emit(kitty('a=T,i=7,p=9,f=32,s=1,v=1,C=1,c=1,r=1', + bytes((*colour, 255)))) + x, y = map(int, test.run('display-message', '-pt', pane, + '#{pane_left},#{pane_top}').split(',')) + placed.update({(x * 8 + px, y * 16 + py): colour + for py in range(16) for px in range(8)}) + snapshot(test, client, placed) + test.run('select-pane', '-t', first) + test.emit(kitty('a=T,i=7,p=9,f=32,s=1,v=1,C=1,c=1,r=1', + bytes((*green, 255)))) + placed = {point: green if colour == red else colour + for point, colour in placed.items()} + snapshot(test, client, placed) + test.run('kill-pane', '-t', second) + snapshot(test, client, + {(x, y): green for y in range(16) for x in range(8)}) + # Respawning removes the previous application's placements and sources. + test.run('respawn-pane', '-k', '-t', first, + "stty raw -echo; printf '\033]2;RESPAWNED\007'; exec cat") + test.wait(lambda: test.run('display-message', '-p', + '#{pane_title}') == 'RESPAWNED') + snapshot(test, client, {}) + test.emit(kitty('a=p,i=7,p=9,C=1')) + snapshot(test, client, {}) +PY diff --git a/regress/image-kitty-persistence.sh b/regress/image-kitty-persistence.sh new file mode 100755 index 000000000..fef9b602b --- /dev/null +++ b/regress/image-kitty-persistence.sh @@ -0,0 +1,207 @@ +#!/bin/sh + +# Kitty placements survive text writes and erases, but follow screen clears +# and scrolling. Overwritten SIXEL pixels can reveal an older Kitty image. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import base64 +import sys +from image_test import image_test, sixel +from image_terminal import Terminal + + +def graphics(control, data=b''): + """Build a Kitty graphics APC with replies suppressed.""" + payload = b';' + base64.b64encode(data) if data else b'' + return b'\033_Gq=2,' + control.encode() + payload + b'\033\\' + + +red, blue = (255, 0, 0), (0, 0, 255) +origin = b'\033[3;3H' +upload = graphics('a=t,i=7,f=32,s=1,v=1', bytes((*red, 255))) +place = origin + graphics('a=p,i=7,p=9,c=4,r=3,C=1') +cells = {(x, y): red for y in range(2, 5) for x in range(2, 6)} + + +def snapshot(test, client, expected): + """Check image pixels after a full redraw.""" + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + return terminal + + +def pixels(cells): + """Expand solid image cells into positioned pixels.""" + return {(x * 8 + px, y * 16 + py): colour + for (x, y), colour in cells.items() + for py in range(16) for px in range(8)} + + +cases = [ + ('ASCII text', origin + b'ab'), + ('wide text', origin + '\u754c'.encode()), + ('insert characters', origin + b'\033[2@'), + ('delete characters', b'\033[3;4H\033[2P'), + ('insert mode', origin + b'\033[4hX\033[4l'), + ('wide insert mode', origin + b'\033[4h' + + '\u754c'.encode() + b'\033[4l'), + ('insert line', b'\033[4;1H\033[L'), + ('delete line', b'\033[4;1H\033[M'), + ('insert lines', b'\033[4;1H\033[2L'), + ('delete lines', b'\033[4;1H\033[2M'), + ('insert all remaining lines', b'\033[4;1H\033[99L'), + ('line edit outside margins', b'\033[6;12r\033[4;1H\033[L\033[r'), + ('erase characters', b'\033[3;4H\033[2X'), + ('erase line end', b'\033[4;5H\033[K'), + ('erase line start', b'\033[4;4H\033[1K'), + ('erase whole line', b'\033[4;4H\033[2K'), + ('erase display end', b'\033[4;5H\033[J'), + ('erase display start', b'\033[4;5H\033[1J'), + ('erase whole display', b'\033[2J'), +] +with image_test(sys.argv[1]) as test: + test.run('set-option', '-g', 'scroll-on-clear', 'off') + client = test.attach() + fallback = test.attach(graphical=False) + test.emit(upload) + for name, operation in cases: + test.emit(b'\033[2J' + place) + wanted = pixels(cells) + terminal = snapshot(test, client, wanted) + test.emit(operation) + if name == 'erase whole display': + wanted = {} + try: + terminal.feed(client.data) + terminal.check(wanted) + snapshot(test, client, wanted) + # Fallback must still show the retained image after a line erase. + term = Terminal() + term.feed(fallback.data) + for x, y in cells: + assert term.cells[x, y][2] == (196 if wanted else None), ( + x, y, term.cells[x, y]) + except AssertionError as e: + raise AssertionError(name) from e + + # Image persistence must not prevent writes and erases of underlying text. + test.emit(b'\033[2J' + place + origin + b'ab') + assert test.run('capture-pane', '-p') == 'ab' + test.emit(origin + b'\033[X') + assert test.run('capture-pane', '-p') == 'b' + snapshot(test, client, pixels(cells)) + test.emit(origin + '\u754c'.encode()) + assert test.run('capture-pane', '-p') == '\u754c' + test.emit(origin + b'\033[2K') + assert test.run('capture-pane', '-p') == '' + snapshot(test, client, pixels(cells)) + + # Text line edits preserve images; complete images follow scrolling. + colours = [red, (0, 255, 0), blue] + striped = {(x, y): colours[y - 2] for x, y in cells} + data = bytes(c for colour in colours for c in (*colour, 255)) + display = origin + graphics('a=T,i=8,p=9,f=32,s=1,v=3,c=4,r=3,C=1', data) + operations = [ + ('insert line', b'\033[4;1H\033[L', striped), + ('delete line', b'\033[4;1H\033[M', striped), + ('scroll down', b'\033[3;6r\033[T', + {(x, y + 1): c for (x, y), c in striped.items()}), + ('reverse index', b'\033[3;6r\033[3;1H\033M', + {(x, y + 1): c for (x, y), c in striped.items()}), + ] + for name, operation, wanted in operations: + test.emit(b'\033[r\033[2J' + display + operation) + try: + snapshot(test, client, pixels(wanted)) + except AssertionError as e: + raise AssertionError(name) from e + test.emit(b'\033[r') + + # Erasing a SIXEL layer reveals the Kitty placement retained beneath it. + wire = sixel(32, 48, [(0, 0, 100)], [0] * (32 * 48)) + test.emit(b'\033[2J' + place + origin + wire) + terminal = snapshot(test, client, + pixels({point: blue for point in cells})) + test.emit(b'\033[4;1H\033[2K') + wanted = pixels({(x, y): red if y == 3 else blue for x, y in cells}) + terminal.feed(client.data) + terminal.check(wanted) + snapshot(test, client, wanted) + + # An image crossing a scroll margin stays intact and stationary. + test.emit(b'\033[2J' + place + b'\033[4;7r\033[S') + wanted = pixels(cells) + snapshot(test, client, wanted) + test.emit(b'\033[r') + + # Complete images scroll and clip; images crossing either margin stay. + operations = [ + ('clip all', b'\033[3;6r\033[3S', {}), + ('clip top', b'\033[3;6r\033[S', + {(x, y - 1): c for (x, y), c in striped.items() if y > 2}), + ('clip bottom', b'\033[3;5r\033[T', + {(x, y + 1): c for (x, y), c in striped.items() if y < 4}), + ('cross top', b'\033[4;7r\033[S', striped), + ('cross bottom', b'\033[1;4r\033[T', striped), + ('cross top reverse index', b'\033[4;7r\033[4;1H\033M', striped), + ] + for name, operation, wanted in operations: + test.emit(b'\033[r\033[2J' + display + operation) + try: + snapshot(test, client, pixels(wanted)) + except AssertionError as e: + raise AssertionError(name) from e + test.emit(b'\033[r') + + # Two placements of the same image can scroll differently at a margin. + other = b'\033[5;9H' + graphics( + 'a=p,i=8,p=10,c=4,r=3,C=1') + test.emit(b'\033[2J' + display + other + b'\033[4;7r\033[S\033[r') + wanted = striped | {(x + 6, y + 1): c for (x, y), c in striped.items()} + snapshot(test, client, pixels(wanted)) + + # With scroll-on-clear enabled, Kitty images move into history. + test.emit(b'\033[2J') + test.run('clear-history') + test.run('set-option', '-g', 'scroll-on-clear', 'on') + test.emit(place + b'\033[2J') + snapshot(test, client, {}) + assert int(test.run('display-message', '-p', '#{history_size}')) > 0 + # Scrolling a region must leave images already in history in place. + test.emit(b'\033[4;7r\033[S\033[r') + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + snapshot(test, client, pixels(cells)) + test.run('send-keys', '-X', 'cancel') + # Replacing a source removes its placements even when wholly in history. + test.emit(graphics('a=t,i=7,f=32,s=1,v=1', bytes((*blue, 255)))) + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + snapshot(test, client, {}) + test.run('send-keys', '-X', 'cancel') + # Restore a historical placement before checking explicit history cleanup. + test.emit(place + b'\033[2J') + test.run('clear-history') + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + snapshot(test, client, {}) + + # The alternate screen has no history, but uses the same image rules. + test.run('send-keys', '-X', 'cancel') + test.emit(b'\033[?1049h') + for name, operation, wanted in operations: + test.emit(b'\033[r\033[2J' + display + operation) + try: + snapshot(test, client, pixels(wanted)) + except AssertionError as e: + raise AssertionError('alternate screen: ' + name) from e + test.emit(b'\033[r\033[?1049l') +PY diff --git a/regress/image-kitty-placements.sh b/regress/image-kitty-placements.sh new file mode 100644 index 000000000..6544a44fe --- /dev/null +++ b/regress/image-kitty-placements.sh @@ -0,0 +1,193 @@ +#!/bin/sh + +# Kitty ordinary placement geometry, cursor movement and replacement. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import base64 +import sys +from image_test import image_test +from image_terminal import Terminal + + +def graphics(control, data=b''): + """Build a Kitty graphics APC with replies suppressed.""" + payload = b';' + base64.b64encode(data) if data else b'' + return b'\033_Gq=2,' + control.encode() + payload + b'\033\\' + + +colours = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)] +raw = bytes(value for y in range(32) for x in range(16) + for value in (*colours[2 * (y >= 16) + (x >= 8)], 255)) +upload = graphics('a=t,i=7,f=32,s=16,v=32', raw) +put = 'a=p,i=7,C=1' + + +def pattern(w=16, h=32, xoff=0, yoff=0): + """Expected positions and colours for a scaled four-quadrant image.""" + return {(x + xoff, y + yoff): colours[2 * (y >= h // 2) + (x >= w // 2)] + for y in range(h) for x in range(w)} + + +def solid(w, h, colour, xoff=0, yoff=0): + """Expected positions for a solid rectangle.""" + return {(x + xoff, y + yoff): colour for y in range(h) for x in range(w)} + + +with image_test(sys.argv[1]) as test: + client = test.attach() + fallback = test.attach(graphical=False) + + def check(command, expected, cursor='0,0'): + """Check the displayed image, its redraw and the pane cursor.""" + test.emit(b'\033[2J\033[H' + command) + actual = test.run('display-message', '-p', '#{cursor_x},#{cursor_y}') + assert actual == cursor, ('cursor', actual, cursor) + term = Terminal() + term.feed(client.data) + term.check(expected) + test.redraw() + term = Terminal() + term.feed(client.data) + term.check(expected) + assert not fallback.images(), 'graphics sent to fallback client' + + # Uploading alone draws nothing; placement reuses the stored pixels. + check(upload, {}) + check(graphics(put), pattern()) + term = Terminal() + term.feed(fallback.data) + assert term.cells[0, 0][1:3] == (196, 196), term.cells[0, 0] + check(graphics('a=T,i=7,f=32,s=16,v=32', raw), pattern(), cursor='2,1') + check(graphics(put + ',c=4'), pattern(32, 64)) + check(graphics(put + ',r=4'), pattern(32, 64)) + check(graphics(put + ',c=4,r=1'), pattern(32, 16)) + + # Crop first, then scale; oversized crop dimensions stop at the source edge. + check(graphics(put + ',y=16,w=8,h=16,c=2,r=1'), solid(16, 16, colours[2])) + check(graphics(put + ',x=8,y=16,w=999,h=999,c=1,r=1'), + solid(8, 16, colours[3])) + + # Natural and explicit sizes both honour offsets within the first cell. + check(graphics(put + ',X=4,Y=8'), pattern(xoff=4, yoff=8)) + check(graphics(put + ',c=3,r=3,X=4,Y=8'), pattern(20, 40, 4, 8)) + check(graphics(put + ',c=1,r=1,X=4'), pattern(4, 16, 4)) + check(graphics(put + ',X=4294967295'), pattern(xoff=7)) + check(b'\033[1;40H' + graphics(put), + {(312 + x, y): colours[2 * (y >= 16)] + for y in range(32) for x in range(8)}, cursor='39,0') + + # Stationary images clip at the bottom; moving images scroll by one row. + check(b'\033[16;1H' + graphics(put), + {(x, 240 + y): colours[x >= 8] + for y in range(16) for x in range(16)}, cursor='0,15') + check(b'\033[16;1H' + graphics('a=p,i=7'), pattern(yoff=224), + cursor='2,15') + + # Named placement replacement removes the earlier display. + first = graphics(put + ',p=9') + second = b'\033[4;5H' + graphics(put + ',p=9,c=1,r=1') + check(first + second, pattern(8, 16, 32, 48), cursor='4,3') + + # Placement zero creates independent displays; IDs are local to an image. + check(graphics(put) + b'\033[4;5H' + graphics(put), + pattern() | pattern(xoff=32, yoff=48), cursor='4,3') + blue = graphics('a=t,i=8,f=32,s=1,v=1', b'\0\0\xff\xff') + other = b'\033[4;5H' + graphics('a=p,i=8,p=9,C=1,c=1,r=1') + check(first + blue + other, pattern() | solid(8, 16, colours[2], 32, 48), + cursor='4,3') + + # Failed placement validation preserves the old source and display. + bad = graphics('a=T,i=7,p=9,f=32,s=1,v=1,x=1,C=1', b'\0\xff\0\xff') + check(first + bad + b'\033[4;5H' + graphics(put + ',c=1,r=1'), + pattern() | pattern(8, 16, 32, 48), cursor='4,3') + check(first + graphics(put + ',p=9,x=16'), pattern()) + + # Successful source replacement removes all its old placements. + green = graphics('a=t,i=7,f=32,s=1,v=1', b'\0\xff\0\xff') + check(first + b'\033[4;5H' + graphics(put) + green, {}, cursor='4,3') + check(graphics(put + ',c=1,r=1'), solid(8, 16, colours[1])) + + # Replacement also removes displays saved by the alternate screen. + check(upload + first + b'\033[?1049h' + green + b'\033[?1049l', {}) + check(upload + first + b'\033[?1049h' + second + b'\033[?1049l', {}) + + # Anonymous upload-and-place does not require a stored source mapping. + check(graphics('a=T,f=32,s=1,v=1,p=9,C=1,c=1,r=1', b'\xff' * 4), + solid(8, 16, colours[3])) + + # A placement keeps shared pixels after its anonymous source is released. + check(graphics('a=T,f=32,s=16,v=32,X=4,Y=8,C=1', raw), + pattern(xoff=4, yoff=8)) + +# Clients sample the original crop independently, including partial spans. +with image_test(sys.argv[1]) as test: + client = test.attach() + source_width, source_height = 32, 32 + source = [(255, 0, 0) if (x + y) % 2 else (0, 0, 255) + for y in range(source_height) for x in range(source_width)] + pixels = bytes(value for colour in source for value in (*colour, 255)) + # Shrink horizontally and enlarge vertically with transparent offsets. + command = graphics('a=T,f=32,s=32,v=32,x=4,y=4,w=24,h=24,' + 'c=2,r=4,X=4,Y=8,C=1', pixels) + test.emit(command) + large = test.attach(cw=24, ch=24) + + def expected(cw, ch): + """Map output coordinates through the full placement to its crop.""" + return {(x, y): source[(4 + (y * 16 - 8 * ch) * 24 // (ch * 56)) * 32 + + 4 + (x * 8 - 4 * cw) * 24 // (cw * 12)] + for y in range(ch // 2, ch * 4) for x in range(cw // 2, cw * 2)} + + def check_clients(clipped=False): + """Check shared-source scaling and alignment on both clients.""" + test.redraw() + terminals = [] + for attached, cw, ch in [(client, 8, 16), (large, 24, 24)]: + term = Terminal(cw=cw, ch=ch) + term.feed(attached.data) + wanted = expected(cw, ch) + if clipped: + wanted = {point: colour for point, colour in wanted.items() + if point[0] < cw} + term.check(wanted) + terminals.append((attached, cw, ch, term)) + return terminals + + terminals = check_clients() + test.emit(b'\033[2;1H\033[X') + for attached, cw, ch, term in terminals: + term.feed(attached.data) + term.check(expected(cw, ch)) + check_clients() + # Clip the right-hand span without changing either client's cell pixels. + test.run('resize-window', '-x', '1') + check_clients(clipped=True) + # Changing cell pixels must retain the same partial-span source origin. + test.resize(client, 1, 16, cw=24, ch=24) + test.redraw() + term = Terminal(cw=24, ch=24) + term.feed(client.data) + wanted = {point: colour for point, colour in expected(24, 24).items() + if point[0] < 24} + term.check(wanted) + +# Offsets and source crops contribute fractional coverage to text fallback. +with image_test(sys.argv[1]) as test: + test.run('set-option', '-as', 'terminal-features', 'screen-256color:RGB') + client = test.attach(graphical=False) + pixels = b'\xff\0\0\xff' * 16 + b'\0\0\xff\xff' * 16 + test.emit(graphics('a=T,f=32,s=8,v=4,x=1,w=6,c=1,r=1,X=4,Y=4,C=1', + pixels)) + test.redraw() + term = Terminal() + term.feed(client.data) + char, fg, bg, bold = term.cells[0, 0] + assert char == '\u2584' and not bold, (char, bold) + assert fg == (31, 0, 95) and bg == (63, 0, 0), (fg, bg) +PY diff --git a/regress/image-kitty-prompts.sh b/regress/image-kitty-prompts.sh new file mode 100644 index 000000000..3a36aca0e --- /dev/null +++ b/regress/image-kitty-prompts.sh @@ -0,0 +1,53 @@ +#!/bin/sh + +# Pane prompts remain above Kitty images during damage and full redraws. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import shlex +import sys +from image_test import image_test, kitty +from image_terminal import Terminal + + +for position, row in [('top', 0), ('bottom', 3)]: + with image_test(sys.argv[1], sy=4) as test: + client = test.attach() + test.run('set-option', '-g', 'status-position', position) + test.emit(kitty('a=t,i=7,f=32,s=1,v=1', b'\xff\0\0\xff')) + test.run('command-prompt', '-P', '-C', '-b', '-l', '-p', 'PROMPT', + 'display-message %1') + test.redraw() + terminal = Terminal(sy=4) + terminal.feed(client.data) + expected = {(x, y): (255, 0, 0) for y in range(64) + for x in range(32) if y // 16 != row} + + client.clear() + test.marker += 1 + title = 'image-%d' % test.marker + data = kitty('a=p,i=7,C=1,c=4,r=4') + \ + b'\033]2;' + title.encode() + b'\007' + # Feed the pane directly, bypassing its open prompt. + test.run('pipe-pane', '-I', 'printf %%s ' + + shlex.quote(data.decode('ascii'))) + test.wait(lambda: test.run('display-message', '-p', + '#{pane_title}') == title) + client.read(0.2) + test.run('pipe-pane') + terminal.feed(client.data) + terminal.check(expected) + assert ''.join(terminal.cells[x, row][0] for x in range(6)) \ + == 'PROMPT', terminal.cells + test.redraw() + terminal = Terminal(sy=4) + terminal.feed(client.data) + terminal.check(expected) + assert ''.join(terminal.cells[x, row][0] for x in range(6)) \ + == 'PROMPT', terminal.cells +PY diff --git a/regress/image-kitty-protocol.sh b/regress/image-kitty-protocol.sh new file mode 100644 index 000000000..9f2bcabde --- /dev/null +++ b/regress/image-kitty-protocol.sh @@ -0,0 +1,432 @@ +#!/bin/sh + +# Kitty graphics uploads, chunking, queries, placements and replies. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +export TEST_TMUX +command -v python3 >/dev/null || exit 101 + +python3 -B - <<'PY' +import base64 +import json +import os +from pathlib import Path +import shlex +import struct +import subprocess +import tempfile +import time +import zlib + +tmux = [os.environ['TEST_TMUX'], '-u', '-Limage-protocol' + str(os.getpid()), + '-f/dev/null'] + +# Run a command on the test server. +def run(*args): + return subprocess.check_output(tmux + list(args), text=True).strip() + +# Build a Kitty graphics APC sequence. +def graphics(control, payload=''): + return '\033_G' + control + (';' + payload if payload else '') + '\033\\' + +# Read until a following device-attributes reply, including when q=2 is used. +reader = ''' +import json, os, select, sys, time, tty +command, output = sys.argv[1:] +tty.setraw(0) +with open(command, 'rb') as f: + while True: + data = f.read(65536) + if not data: + break + while data: + data = data[os.write(1, data):] +os.write(1, b'\\x1b[c') +reply = b'' +deadline = time.monotonic() + 3 +while time.monotonic() < deadline: + if select.select([0], [], [], 0.1)[0]: + reply += os.read(0, 4096) + if b'\\x1b[?' in reply and reply.endswith(b'c'): + break +else: + raise SystemExit('missing device-attributes reply') +with open(output, 'w') as f: + json.dump(reply.split(b'\\x1b[?')[0].decode(), f) +time.sleep(30) +''' + +# Check replies, cursor movement and unchanged text and title. +def check(name, command, expected, cursor='5,2', timeout=5): + output = directory / name + command_file = directory / (name + '.input') + with command_file.open('w', encoding='utf-8') as f: + f.write(origin) + if isinstance(command, str): + f.write(command) + else: + f.writelines(command) + pane_command = 'python3 -B ' + shlex.quote(str(helper)) + ' ' + \ + shlex.quote(str(command_file)) + ' ' + shlex.quote(str(output)) + pane = run('new-window', '-d', '-P', '-F', '#{pane_id}', pane_command) + try: + deadline = time.monotonic() + timeout + while not output.exists(): + if time.monotonic() >= deadline: + raise AssertionError(name + ': missing reply capture') + time.sleep(0.05) + reply = json.loads(output.read_text()) + assert reply == expected, (name, 'reply', repr(reply), repr(expected)) + actual = run('display-message', '-pt', pane, '#{cursor_x},#{cursor_y}') + assert actual == cursor, (name, 'cursor', actual, cursor) + assert run('capture-pane', '-pt', pane) == '', (name, 'text') + title = run('display-message', '-pt', pane, '#{pane_title}') + assert title == 'kitty-test', (name, 'title', title) + return reply + finally: + run('kill-window', '-t', pane) + +with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: + directory = Path(tmp) + helper = directory / 'read-reply.py' + helper.write_text(reader) + origin = '\033]2;kitty-test\007\033[3;6H' + try: + run('new-session', '-d', '-x', '40', '-y', '12') + if run('display-message', '-p', '#{image_support}') == '0': + raise SystemExit(101) + run('set', '-g', 'status', 'off') + pixel = '/wAA/w==' + transmit = 'a=t,i=7,f=32,s=1,v=1' + ok = graphics('i=7', 'OK') + invalid = graphics('i=7', 'EINVAL') + check('transmit', graphics(transmit, pixel), ok) + check('defaults', graphics('i=7,s=1,v=1', pixel), ok) + check('rgb', graphics('a=t,i=7,f=24,s=1,v=1', '/wAA'), ok) + check('unpadded', graphics(transmit, '/wAA/w'), ok) + check('placement-id-reply', graphics(transmit + ',p=9', pixel), + graphics('i=7,p=9', 'OK')) + check('chunks', graphics(transmit + ',m=1', '/wAA') + + graphics('m=0', '/w=='), ok) + check('nonzero-padding', graphics(transmit + ',m=1', '/wAA') + + graphics('m=0', '/z=='), invalid) + check('empty-decoded-chunk', graphics(transmit + ',m=1', ' ') + + graphics('m=0', pixel), ok) + check('empty-final-chunk', graphics(transmit + ',m=1', pixel) + + graphics('m=0'), ok) + check('incomplete', graphics(transmit + ',m=1', '/wAA'), '') + check('failed-chunk', graphics(transmit + ',m=1', '/wAA') + + graphics('m=0', '!!!!') + graphics(transmit, pixel), invalid + ok) + check('quiet-ok', graphics(transmit + ',q=1', pixel), '') + check('quiet-two', graphics(transmit + ',q=2', pixel), '') + check('quiet-one-error', graphics(transmit + ',q=1', '!!!!'), invalid) + check('quiet-two-error', graphics(transmit + ',q=2', '!!!!'), '') + check('quiet-continuation', graphics(transmit + ',q=2,m=1', '/wAA') + + graphics('m=0', '/w=='), '') + check('quiet-control-error', graphics('a=t,q=2,i=7,s=bad'), '') + check('anonymous', graphics('a=t,f=32,s=1,v=1', pixel), '') + check('anonymous-error', graphics('a=t,f=32,s=1,v=1', '!!!!'), '') + check('query', graphics('a=q,f=32,s=1,v=1', pixel), + graphics('i=0', 'OK')) + check('query-chunks', graphics('a=q,i=7,f=32,s=1,v=1,m=1', '/wAA') + + graphics('m=0', '/w=='), ok) + check('replace', graphics(transmit, pixel) + + graphics(transmit, 'AP8A/w=='), ok + ok) + check('transmit-and-place', + graphics('a=T,i=7,f=32,s=1,v=1', pixel), ok, cursor='6,2') + check('transmit-and-place-sized', + graphics('a=T,i=7,f=32,s=1,v=1,c=3,r=2', pixel), + ok, cursor='8,3') + check('place-stored', graphics(transmit + ',q=2', pixel) + + graphics('a=p,i=7'), ok, cursor='6,2') + check('place-no-cursor', graphics(transmit + ',q=2', pixel) + + graphics('a=p,i=7,C=1,c=3,r=2'), ok) + check('place-named', graphics(transmit + ',q=2', pixel) + + graphics('a=p,i=7,p=9,C=1'), graphics('i=7,p=9', 'OK')) + check('place-missing', graphics('a=p,i=7'), + graphics('i=7', 'ENOENT')) + check('query-does-not-store', + graphics('a=q,q=2,i=7,f=32,s=1,v=1', pixel) + + graphics('a=p,i=7'), graphics('i=7', 'ENOENT')) + check('place-chunks', graphics('a=T,i=7,f=32,s=1,v=1,' + 'c=3,r=2,C=1,m=1', '/wAA') + graphics('m=0', '/w=='), ok) + for n, geometry in enumerate(['x=1', 'y=1', 'c=65536', 'r=65536', + 'c=65534,r=32767']): + check('invalid-placement-' + str(n), + graphics(transmit + ',q=2', pixel) + + graphics('a=p,i=7,C=1,' + geometry), invalid) + check('failed-transmit-keeps-source', + graphics(transmit + ',q=2', pixel) + + graphics('a=T,i=7,f=32,s=1,v=1,x=1,C=1', pixel) + + graphics('a=p,i=7,C=1'), invalid + ok) + check('place-at-bottom', '\033[12;6H' + + graphics('a=T,i=7,f=32,s=1,v=1,c=3,r=2', pixel), + ok, cursor='8,11') + check('stationary-at-bottom', '\033[12;6H' + + graphics('a=T,i=7,f=32,s=1,v=1,c=3,r=2,C=1', pixel), + ok, cursor='5,11') + for n, payload in enumerate(['', ' ', '!!!!', '====', '/w', + '/wAA/wAA']): + check('invalid-payload-' + str(n), + graphics(transmit, payload), invalid) + for control in ['f=12,s=1,v=1', 'f=32,s=0,v=1', 'f=32,s=1,v=0', + 'f=32,s=bad,v=1', 'f=32,s=4294967296,v=1', + 'f=32,s=1,v=1,o=x']: + check('invalid-control-' + control, + graphics('a=t,i=7,' + control, pixel), invalid) + for action in ['f', 'a', 'c']: + check('unsupported-action-' + action, + graphics('a=%s,i=7' % action), graphics('i=7', 'ENOTSUP')) + for medium in ['f', 't', 's']: + check('unsupported-medium-' + medium, + graphics('a=t,i=7,t=' + medium), graphics('i=7', 'ENOTSUP')) + check('unsupported-number', graphics('a=t,I=7,f=32,s=1,v=1', pixel), + graphics('I=7', 'ENOTSUP')) + check('id-and-number', graphics('a=t,i=7,I=8,f=32,s=1,v=1', pixel), + graphics('i=7,I=8', 'EINVAL')) + check('unknown-extension', graphics(transmit + ',k=9', pixel), ok) + check('unknown-extension-string', + graphics(transmit + ',k=unused', pixel), ok) + check('trailing-comma', graphics(transmit + ',', pixel), ok) + check('maximum-ids', + graphics('a=t,i=4294967295,p=4294967295,f=32,s=1,v=1', pixel), + graphics('i=4294967295,p=4294967295', 'OK')) + unsupported = graphics('i=7', 'ENOTSUP') + check('default-layer', graphics(transmit + ',z=0', pixel), ok) + for z in [-2147483648, -1073741824, -1, 1, 2147483647]: + check('unsupported-layer-' + str(z), + graphics(transmit + ',z=' + str(z), pixel), unsupported) + for action in ['t', 'T', 'p', 'q', 'd']: + check('unsupported-action-layer-' + action, + graphics('a=%s,i=7,p=9,f=32,s=1,v=1,z=-1' % action, + pixel), graphics('i=7,p=9', 'ENOTSUP')) + for quiet in [1, 2]: + check('unsupported-layer-quiet-' + str(quiet), + graphics(transmit + ',q=%d,z=1' % quiet, pixel), + unsupported if quiet == 1 else '') + check('zero-layer-chunks', graphics(transmit + ',z=0,m=1', '/wAA') + + graphics('m=0,z=0', '/w=='), ok) + check('unsupported-layer-aborts-chunks', + graphics(transmit + ',m=1', '/wAA') + + graphics('m=0,z=1', '/w==') + graphics(transmit, pixel), + unsupported + ok) + check('unsupported-layer-does-not-start-chunks', + graphics(transmit + ',m=1,z=1', '/wAA') + + graphics('m=0', '/w==') + graphics(transmit, pixel), + unsupported + ok) + # Reply controls must be read even after an unsupported field. + check('quiet-after-layer', + graphics(transmit + ',z=1,q=2', pixel), '') + check('id-after-layer', + graphics('a=q,z=1,i=7,f=32,s=1,v=1', pixel), unsupported) + check('placement-after-layer', + graphics('z=-1,a=q,i=7,p=9,q=1', pixel), + graphics('i=7,p=9', 'ENOTSUP')) + check('quiet-after-invalid-control', + graphics('a=t,i=7,s=bad,q=2', pixel), '') + check('id-after-invalid-control', + graphics('s=bad,a=t,i=7,p=9', pixel), + graphics('i=7,p=9', 'EINVAL')) + check('id-after-malformed-control', + graphics('broken,a=q,i=7,p=9', pixel), + graphics('i=7,p=9', 'EINVAL')) + check('quiet-after-malformed-control', + graphics('broken,a=t,i=7,q=2', pixel), '') + check('first-error-invalid', + graphics('s=bad,z=1,a=q,i=7,p=9', pixel), + graphics('i=7,p=9', 'EINVAL')) + check('first-error-unsupported', + graphics('z=1,s=bad,a=q,i=7,p=9', pixel), + graphics('i=7,p=9', 'ENOTSUP')) + for control in ['U=1', 'P=99', 'Q=9', 'H=1', 'H=-1', 'V=1', 'V=-1']: + check('unsupported-placement-' + control, + graphics('a=T,i=7,f=32,s=1,v=1,' + control, pixel), + unsupported) + check('quiet-unsupported-placement-' + control, + graphics('a=T,i=7,' + control + ',q=2', pixel), '') + check('zero-placement-extensions', graphics(transmit + + ',U=0,P=0,Q=0,H=0,V=0', pixel), ok) + for z in ['-2147483649', '2147483648', '1x', '', 'bad']: + check('invalid-layer-' + z, + graphics(transmit + ',z=' + z, pixel), invalid) + for n, control in enumerate(['s=', 's=-1', 's=1x', 'a=tt', 't=dd', + 'o=zz', '=1', 'ss=1', ',s=1', + 's=1,,v=1']): + check('malformed-control-' + str(n), + graphics('i=7,' + control, pixel), invalid) + check('failed-control-resets-transfer', + graphics(transmit + ',m=1', '/wAA') + + graphics('m=0,s=bad') + graphics(transmit, pixel), invalid + ok) + check('quiet-resets', graphics(transmit + ',q=2', pixel) + + graphics(transmit, pixel), ok) + check('oversized-dimensions', + graphics('a=t,i=7,f=32,s=4294967295,v=4294967295', pixel), + invalid) + check('delete-aborts-upload', graphics(transmit + ',q=2,m=1', '/wAA') + + graphics('a=d,d=I,q=2') + graphics(transmit, pixel), ok) + + # Successful deletion sends no reply and never moves the cursor. + stored = graphics(transmit + ',q=2', pixel) + put = 'a=p,q=2,i=7,C=1' + first = graphics(put + ',p=1') + second = graphics(put + ',p=2') + missing = graphics('i=7', 'ENOENT') + check('delete-soft-image', stored + first + graphics('a=d,d=i,i=7') + + graphics('a=p,i=7,C=1'), ok) + check('delete-hard-image', stored + first + graphics('a=d,d=I,i=7') + + graphics('a=p,i=7,C=1'), missing) + check('delete-one-placement', stored + first + second + + graphics('a=d,d=I,i=7,p=1') + graphics('a=p,i=7,p=2,C=1'), + graphics('i=7,p=2', 'OK')) + check('delete-last-placement', stored + first + + graphics('a=d,d=I,i=7,p=1') + graphics('a=p,i=7,C=1'), missing) + transparent = graphics('a=T,i=7,p=1,q=2,C=1,f=32,s=1,v=1', + '/wAAAA==') + for selector in ['a', 'i', 'A', 'I']: + check('delete-transparent-' + selector, transparent + + graphics('a=d,d=%s,i=7,p=1' % selector) + + graphics('a=p,i=7,C=1'), + missing if selector.isupper() else ok) + check('delete-transparent-retains-other-placement', transparent + + second + graphics('a=d,d=I,i=7,p=1') + + graphics('a=p,i=7,C=1'), ok) + check('delete-retain-unnumbered', stored + first + graphics(put) + + graphics('a=d,d=I,i=7,p=1') + graphics('a=p,i=7,C=1'), ok) + check('delete-missing-placement', stored + first + + graphics('a=d,d=I,i=7,p=99') + graphics('a=p,i=7,C=1'), ok) + check('delete-upload-only', stored + graphics('a=d,d=I,i=7') + + graphics('a=p,i=7,C=1'), missing) + check('delete-soft-all', stored + first + graphics('a=d,d=a') + + graphics('a=p,i=7,C=1'), ok) + check('delete-hard-all', stored + first + graphics('a=d,d=A') + + graphics('a=p,i=7,C=1'), missing) + check('delete-all-retains-unplaced', stored + graphics('a=d,d=A') + + graphics('a=p,i=7,C=1'), ok) + check('delete-history-retains-data', stored + first + '\033[12S' + + graphics('a=d,d=A') + graphics('a=p,i=7,C=1'), ok) + check('delete-history-by-id', stored + first + '\033[12S' + + graphics('a=d,d=I,i=7') + graphics('a=p,i=7,C=1'), missing) + for selector in ['A', 'I']: + check('delete-saved-screen-' + selector, stored + first + + '\033[?1049h' + graphics('a=d,d=' + selector + ',i=7') + + '\033[?1049l' + graphics('a=p,i=7,C=1'), ok) + check('delete-abort-does-not-inherit-id', stored + + graphics(transmit + ',q=2,m=1', '/wAA') + + graphics('a=d,d=I') + graphics('a=p,i=7,C=1'), ok) + check('unsupported-delete', stored + graphics('a=d,d=?,i=7'), + graphics('i=7', 'ENOTSUP')) + check('malformed-delete', stored + graphics('a=d,i=7,d=ii'), invalid) + + # Unsupported selectors leave uploaded data and placements intact. + for selector in 'cCpPqQrRxXyYzZ': + control = 'a=d,d=%s,i=7,x=6,y=3,z=0' % selector + check('unsupported-delete-' + selector, stored + first + + graphics(control) + graphics('a=p,i=7,C=1'), + unsupported + ok) + for quiet in [1, 2]: + check('unsupported-delete-quiet-' + str(quiet), stored + first + + graphics('a=d,d=P,i=7,q=%d,x=6,y=3' % quiet) + + graphics('a=p,i=7,C=1'), + (unsupported if quiet == 1 else '') + ok) + check('unsupported-delete-named', stored + first + + graphics('a=d,d=Z,i=7,p=1') + graphics('a=p,i=7,C=1'), + graphics('i=7,p=1', 'ENOTSUP') + ok) + check('unsupported-delete-aborts-upload', stored + + graphics(transmit + ',q=2,m=1', '/wAA') + + graphics('a=d,d=P,i=7') + graphics('a=p,i=7,C=1'), + unsupported + ok) + check('unsupported-delete-does-not-inherit-id', stored + + graphics(transmit + ',m=1', '/wAA') + graphics('a=d,d=P') + + graphics('a=p,i=7,C=1'), ok) + check('unsupported-layer-delete-aborts-upload', stored + + graphics(transmit + ',q=2,m=1', '/wAA') + + graphics('a=d,d=I,i=7,z=1') + graphics('a=p,i=7,C=1'), + unsupported + ok) + check('unsupported-layer-delete-does-not-inherit-id', stored + + graphics(transmit + ',m=1', '/wAA') + + graphics('a=d,d=I,z=-1') + graphics('a=p,i=7,C=1'), ok) + for action in ['t', 'T', 'q']: + check('unsupported-layer-keeps-source-' + action, stored + + graphics('a=%s,i=7,f=32,s=1,v=1,z=1' % action, pixel) + + graphics('a=p,i=7,C=1'), unsupported + ok) + check('unsupported-layer-query-does-not-store', + graphics('a=q,i=7,f=32,s=1,v=1,z=1', pixel) + + graphics('a=p,i=7,C=1'), unsupported + missing) + + # Abort immediately when chunked data exceeds 64 MiB, then recover. + block = base64.b64encode(b'\0' * (512 * 1024)).decode() + commands = [graphics(transmit + ',q=1,m=1', block)] + commands.extend([graphics('m=1', block)] * 127) + commands.extend([graphics('m=1', 'AAAAAA=='), + graphics('a=t,i=8,f=32,s=1,v=1', pixel)]) + check('accumulated-size-limit', commands, + invalid + graphics('i=8', 'OK'), timeout=30) + + # Compressed uploads require a test build with zlib. + compressed = base64.b64encode(zlib.compress(b'\xff\0\0\xff')).decode() + control = transmit + ',o=z' + for format, raw in [(24, b'\xff\0\0'), (32, b'\xff\0\0\xff')]: + payload = base64.b64encode(zlib.compress(raw)).decode() + check('compressed-' + str(format), + graphics('a=t,i=7,f=%d,s=1,v=1,o=z' % format, payload), + ok) + split = len(compressed) // 8 * 4 + check('compressed-chunks', graphics(control + ',m=1', + compressed[:split]) + graphics('m=0', compressed[split:]), ok) + check('compressed-truncated', graphics(control, compressed[:-4]), + invalid) + check('compressed-wrong-size', + graphics(control.replace('s=1', 's=2'), compressed), invalid) + large = base64.b64encode(zlib.compress( + b'\0' * (4096 * 4097 * 3))).decode() + check('oversized-rgb', + graphics('a=t,i=7,f=24,s=4096,v=4097,o=z', large), invalid) + + # PNG uploads require a test build with libpng. + png = b'\x89PNG\r\n\x1a\n' + for kind, data in [ + (b'IHDR', struct.pack('>2I5B', 8, 16, 8, 6, 0, 0, 0)), + (b'IDAT', zlib.compress((b'\0' + b'\xff\0\0\xff' * 8) * 16)), + (b'IEND', b'')]: + png += struct.pack('>I', len(data)) + kind + data + \ + struct.pack('>I', zlib.crc32(kind + data)) + payload = base64.b64encode(png).decode() + control = 'a=t,i=7,f=100' + check('png', graphics(control, payload), ok) + truncated = base64.b64encode(png[:40]).decode() + check('png-truncated', graphics(control, truncated), invalid) + damaged = bytearray(png) + damaged[29] ^= 1 + check('png-bad-header-crc', + graphics(control, base64.b64encode(damaged).decode()), invalid) + split = len(payload) // 8 * 4 + check('png-chunks', graphics(control + ',m=1', payload[:split]) + + graphics('m=0', payload[split:]), ok) + payload = base64.b64encode(zlib.compress(png)).decode() + control += ',o=z' + sized = control + ',S=' + str(len(png)) + check('compressed-png', graphics(sized, payload), ok) + split = len(payload) // 8 * 4 + check('compressed-png-chunks', + graphics(sized + ',m=1', payload[:split]) + + graphics('m=0', payload[split:]), ok) + check('compressed-png-missing-size', graphics(control, payload), + invalid) + check('compressed-png-wrong-size', + graphics(control + ',S=' + str(len(png) + 1), payload), + invalid) + check('compressed-png-oversized', + graphics(control + ',S=67108865', payload), invalid) + check('compressed-png-truncated', graphics(sized, payload[:-4]), + invalid) + check('decoder-controls-reset', graphics(sized + ',q=2', payload) + + graphics('a=T,i=7,s=1,v=1', pixel), ok, cursor='6,2') + finally: + subprocess.run(tmux + ['kill-server'], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) +PY diff --git a/regress/image-kitty-transfers.sh b/regress/image-kitty-transfers.sh new file mode 100755 index 000000000..aaed5c298 --- /dev/null +++ b/regress/image-kitty-transfers.sh @@ -0,0 +1,102 @@ +#!/bin/sh + +# Kitty transfer state, fragmented APCs, interleaved text and failed uploads. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, kitty +from image_terminal import Terminal + + +def check(test, client, expected): + """Check immediate output and independently redrawn image pixels.""" + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + + +red, blue = (255, 0, 0), (0, 0, 255) +with image_test(sys.argv[1]) as test: + test.run('set-option', '-g', 'scroll-on-clear', 'off') + client = test.attach() + + # Many separately encoded chunks reconstruct exactly the original pixels. + w, h = 64, 32 + colours = [red if (x // 8 + y // 8) % 2 else blue + for y in range(h) for x in range(w)] + raw = bytes(c for pixel in colours for c in (*pixel, 255)) + command = kitty('a=T,i=7,p=9,C=1,f=32,s=64,v=32,m=1', raw[:128]) + for offset in range(128, len(raw), 128): + more = int(offset + 128 < len(raw)) + command += kitty('m=%d' % more, raw[offset:offset + 128]) + test.emit(command) + check(test, client, {(x, y): colours[y * w + x] + for y in range(h) for x in range(w)}) + + # A query must neither replace the existing source nor draw its own pixels. + test.emit(kitty('a=q,i=7,f=32,s=1,v=1', b'\0\xff\0\xff') + + b'\033[2J\033[H' + kitty('a=p,i=7,p=9,C=1')) + expected = {(x, y): colours[y * w + x] + for y in range(h) for x in range(w)} + check(test, client, expected) + + # Failed decoding must leave the earlier source and its placement intact. + for control, data in [('f=24,s=1,v=1', b'\xff\0'), + ('f=32,s=1,v=1,o=z', b'not zlib'), + ('f=100', b'not PNG')]: + test.emit(kitty('a=T,i=7,p=9,C=1,' + control, data)) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + test.emit(b'\033[2J\033[H' + kitty('a=p,i=7,p=9,C=1')) + check(test, client, expected) + + # Text and cursor movement between chunks do not reset the upload. + test.emit(b'\033[2J\033[H' + kitty( + 'a=T,i=7,p=9,f=32,s=1,v=1,c=2,r=2,m=1', b'\xff\0\0')) + assert not client.images(), 'incomplete upload drew an image' + assert test.run('display-message', '-p', '#{cursor_x},#{cursor_y}') == '0,0' + test.emit(b'between\033[3;5H') + test.emit(kitty('m=0', b'\xff')) + check(test, client, {(x, y): red + for y in range(32, 64) for x in range(32, 48)}) + assert test.run('display-message', '-p', '#{cursor_x},#{cursor_y}') == '6,3' + assert test.run('capture-pane', '-p') == 'between' + + # Split the introducer, controls, payload and terminator across writes. + test.emit(b'\033[2J\033[H') + wire = kitty('a=T,i=7,p=9,C=1,f=32,s=1,v=1,c=1,r=1', b'\0\0\xff\xff') + cuts = [1, 2, 3, wire.index(b';'), wire.index(b';') + 3, len(wire) - 1] + offset = 0 + for end in cuts: + test.send(wire[offset:end]) + test.wait(lambda: test.run('capture-pane', '-p', '-P') == + wire[:end].decode()) + if end < len(wire) - 1: + assert not client.images(), 'unfinished APC drew an image' + offset = end + # The input parser can dispatch the APC when it receives the ST's ESC. + client.read(0.2) + terminal = Terminal() + terminal.feed(client.data) + test.emit(wire[offset:]) + terminal.feed(client.data) + expected = {(x, y): blue for y in range(16) for x in range(8)} + terminal.check(expected) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + assert test.run('capture-pane', '-p') == '', 'APC bytes appeared as text' +PY diff --git a/regress/image-limits.sh b/regress/image-limits.sh new file mode 100644 index 000000000..b1782997c --- /dev/null +++ b/regress/image-limits.sh @@ -0,0 +1,141 @@ +#!/bin/sh + +# Image quotas evict uploads and placements across protocols and saved grids. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, kitty, sixel +from image_terminal import Terminal + + +def upload(image_id, colour): + """Upload one solid source pixel without placing it.""" + return kitty('a=t,f=32,s=1,v=1,i=%d' % image_id, bytes((*colour, 255))) + + +def place(image_id, placement_id=0): + """Place one image at the cursor without moving the cursor.""" + return kitty('a=p,i=%d,p=%d,c=1,r=1,C=1' % (image_id, placement_id)) + + +def cell(colour, x=0, y=0): + """Expand a solid image cell into positioned pixels.""" + return {(px, py): colour for py in range(y * 16, (y + 1) * 16) + for px in range(x * 8, (x + 1) * 8)} + + +def snapshot(test, client, expected): + """Check retained images after a full redraw.""" + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + + +red, green, blue = (255, 0, 0), (0, 255, 0), (0, 0, 255) +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(upload(1, red) + + b''.join(upload(i, green) for i in range(2, 1001))) + test.emit(upload(1001, blue)) + # The oldest upload is evicted and its application ID no longer resolves. + test.emit(place(1)) + snapshot(test, client, {}) + test.emit(place(1001)) + snapshot(test, client, cell(blue)) + test.emit(b'\033[3;1H' + place(2)) + snapshot(test, client, cell(blue)) + test.emit(place(1000)) + snapshot(test, client, cell(blue) | cell(green, y=2)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(upload(1, red) + place(1, 1)) + test.emit(upload(2, green)) + test.emit(kitty('a=d,d=i,i=1,p=1')) + # Losing the last placement keeps the source's original last use order. + test.emit(b''.join(upload(i, blue) for i in range(3, 1002))) + test.emit(place(2)) + snapshot(test, client, cell(green)) + test.emit(b'\033[3;1H' + place(1)) + snapshot(test, client, cell(green)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(upload(1, red) + b'\033[7;1H' + place(1, 1) + + b'\033[H' + place(1, 2) + b'\033[3;1H' + place(1, 3) + + b'\033[5;1H' + b''.join(place(1, p) for p in range(4, 1000))) + snapshot(test, client, + cell(red) | cell(red, y=2) | cell(red, y=4) | cell(red, y=6)) + # Redraw order differs from creation order: the top view is oldest. + test.emit(upload(2, green)) + snapshot(test, client, cell(red, y=2) | cell(red, y=4) | cell(red, y=6)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(upload(1, red) + b''.join(place(1, p) for p in range(1, 1000))) + test.emit(upload(2, green) + place(2, 1)) + snapshot(test, client, cell(green)) + # The shared source survives; only its two oldest views were evicted. + test.emit(kitty('a=d,d=I,i=2')) + snapshot(test, client, cell(red)) + test.emit(b''.join(kitty('a=d,d=i,i=1,p=%d' % p) + for p in range(3, 1000))) + snapshot(test, client, {}) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(sixel(8, 16, [(100, 0, 0)], [0] * (8 * 16))) + # Kitty views share the same limit as the older SIXEL source. + test.emit(b'\033[3;1H' + upload(1, green) + + b''.join(place(1, p) for p in range(1, 1000))) + snapshot(test, client, cell(green, y=2)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(upload(1, red) + place(1)) + test.emit(b'\033[3;1H' + upload(2, green) + + b''.join(place(2, p) for p in range(1, 998))) + snapshot(test, client, cell(red) | cell(green, y=2)) + # SIXEL input also evicts Kitty placements and their uploaded image IDs. + test.emit(b'\033[5;1H' + + sixel(8, 16, [(0, 0, 100)], [0] * (8 * 16))) + snapshot(test, client, cell(green, y=2) | cell(blue, y=4)) + test.emit(b'\033[7;1H' + + sixel(8, 16, [(0, 0, 100)], [0] * (8 * 16))) + snapshot(test, client, cell(green, y=2) | cell(blue, y=4) | cell(blue, y=6)) + test.emit(b'\033[H' + place(1)) + snapshot(test, client, cell(green, y=2) | cell(blue, y=4) | cell(blue, y=6)) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(upload(1, red) + place(1)) + test.emit(b'\033[?1049h\033[H' + upload(2, green) + + b''.join(place(2, p) for p in range(1, 1000))) + # Eviction reaches the saved primary grid and removes its Kitty ID. + test.emit(b'\033[?1049l') + snapshot(test, client, {}) + test.emit(place(1)) + snapshot(test, client, {}) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(sixel(8, 16, [(100, 0, 0)], [0] * (8 * 16))) + test.run('copy-mode') + # Keep the copied grid alive in a hidden window while exceeding the quota. + test.run('new-window', '-n', 'quota', '/bin/sh', '-c', + "stty raw -echo; printf '\033]2;QUOTA\007'; exec cat") + test.wait(lambda: test.run('display-message', '-p', + '#{pane_title}') == 'QUOTA') + test.emit(upload(1, green) + b''.join(place(1, p) for p in range(1, 1000))) + test.run('select-window', '-t', 'image:0') + snapshot(test, client, {}) + test.run('send-keys', '-X', 'cancel') + snapshot(test, client, {}) +PY diff --git a/regress/image-resize-height.sh b/regress/image-resize-height.sh new file mode 100644 index 000000000..b0729df0b --- /dev/null +++ b/regress/image-resize-height.sh @@ -0,0 +1,55 @@ +#!/bin/sh + +# Height reduction discards image rows without leaving spans behind. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +command -v python3 >/dev/null || exit 101 + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, kitty, sixel +from image_terminal import Terminal + + +def snapshot(test, client, expected, sy): + """Check image pixels after a full redraw at the current height.""" + test.redraw() + terminal = Terminal(sy=sy) + terminal.feed(client.data) + terminal.check(expected) + + +red, green = (255, 0, 0), (0, 255, 0) +for protocol in ('kitty', 'sixel'): + if protocol == 'kitty': + image = kitty('a=T,f=32,s=1,v=1,c=2,r=5,C=1', bytes((*red, 255))) + lower = kitty('a=T,f=32,s=1,v=1,c=2,r=1,C=1', bytes((*green, 255))) + else: + image = sixel(16, 80, [(100, 0, 0)], [0] * (16 * 80)) + lower = sixel(16, 16, [(0, 100, 0)], [0] * (16 * 16)) + for alternate in (False, True): + with image_test(sys.argv[1]) as test: + client = test.attach() + if alternate: + test.emit(b'\033[?1049h') + test.emit(b'\033[9;3H' + image + b'\033[14;3H' + lower + + b'\033[H') + expected = {(x, y): red for y in range(8 * 16, 13 * 16) + for x in range(16, 32)} + expected.update({(x, y): green for y in range(13 * 16, 14 * 16) + for x in range(16, 32)}) + snapshot(test, client, expected, 16) + + # One placement is clipped and the other is discarded entirely. + test.resize(client, 40, 10) + snapshot(test, client, {(x, y): red for y in range(8 * 16, 10 * 16) + for x in range(16, 32)}, 10) + + # Discarded rows must not keep the remaining placement fixed. + test.emit(b'\033[8;10r\033[S\033[r') + snapshot(test, client, {(x, y): red for y in range(7 * 16, 9 * 16) + for x in range(16, 32)}, 10) +PY diff --git a/regress/image-sixel-palette.sh b/regress/image-sixel-palette.sh index 121a40719..c2a47c47f 100755 --- a/regress/image-sixel-palette.sh +++ b/regress/image-sixel-palette.sh @@ -10,25 +10,31 @@ export TERM python3 -B - "$TEST_TMUX" <<'PY' import sys -from image_test import image_test, sixel +from image_test import image_test, kitty, sixel levels = [0, 14, 29, 43, 57, 71, 86, 100] palette = [(r, g, b) for r in levels for g in levels for b in levels] expected = [tuple((c * 255 + 50) // 100 for c in p) for p in palette] -with image_test(sys.argv[1]) as test: - client = test.attach() - test.emit(sixel(32, 16, palette, list(range(512)))) - images = client.images() - assert len(images) == 1 and images[0][:2] == (32, 16) - actual = images[0][2] - assert all(c is not None for c in actual), 'dithering introduced holes' - assert 128 <= len(set(actual)) <= 256, len(set(actual)) - errors = [abs(a - e) for p, q in zip(actual, expected) for a, e in zip(p, q)] - assert max(errors) <= 80, max(errors) - assert sum(errors) / len(errors) <= 16, sum(errors) / len(errors) - assert max(actual[0]) <= 30 and min(actual[-1]) >= 225, ( - 'black/white extremes lost', actual[0], actual[-1]) - for _ in range(3): - test.redraw() - assert client.images() == images, 'palette or dithering changed on redraw' +raw = bytes(c for rgb in expected for c in (*rgb, 255)) +commands = [sixel(32, 16, palette, list(range(512))), + kitty('a=T,i=7,f=32,s=32,v=16,C=1', raw)] +for command in commands: + with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(command) + images = client.images() + assert len(images) == 1 and images[0][:2] == (32, 16) + actual = images[0][2] + assert all(c is not None for c in actual), 'dithering introduced holes' + assert 128 <= len(set(actual)) <= 256, len(set(actual)) + errors = [abs(a - e) for p, q in zip(actual, expected) + for a, e in zip(p, q)] + assert max(errors) <= 80, max(errors) + assert sum(errors) / len(errors) <= 16, sum(errors) / len(errors) + assert max(actual[0]) <= 30 and min(actual[-1]) >= 225, ( + 'black/white extremes lost', actual[0], actual[-1]) + for _ in range(3): + test.redraw() + assert client.images() == images, \ + 'palette or dithering changed on redraw' PY diff --git a/regress/image_terminal.py b/regress/image_terminal.py index c5ef50462..e4ba07231 100644 --- a/regress/image_terminal.py +++ b/regress/image_terminal.py @@ -68,6 +68,20 @@ class Terminal: self.pixels.update(moved) self.cells.update(cells) + def shift(self, x, y, n): + """Move pixels and text when characters are inserted or deleted.""" + moved = {} + for (px, py), colour in list(self.pixels.items()): + if x * self.cw <= px < self.sx * self.cw and \ + y * self.ch <= py < (y + 1) * self.ch: + if x * self.cw <= px - n * self.cw < self.sx * self.cw: + moved[px - n * self.cw, py] = colour + cells = {(cx - n, cy): cell for (cx, cy), cell in self.cells.items() + if cy == y and x <= cx < self.sx and x <= cx - n < self.sx} + self.erase(x, y, self.sx - x, 1) + self.pixels.update(moved) + self.cells.update(cells) + def csi(self, params, command): """Apply cursor movement and erases; ignore terminal mode controls.""" if params.startswith((b'?', b'>', b'=')) or b' ' in params: @@ -88,6 +102,8 @@ class Terminal: self.y = max(0, self.y + dy) elif command == ord('X'): self.erase(self.x, self.y, n, 1) + elif command in b'@P': + self.shift(self.x, self.y, n if command == ord('P') else -n) elif command == ord('K'): start = self.x if args[0] == 0 else 0 end = self.x + 1 if args[0] == 1 else self.sx diff --git a/regress/image_test.py b/regress/image_test.py index c04470ce3..be2cc834b 100644 --- a/regress/image_test.py +++ b/regress/image_test.py @@ -1,5 +1,6 @@ """Helpers for image tests using an attached client on a PTY.""" +import base64 import contextlib import colorsys import errno @@ -14,6 +15,7 @@ import subprocess import sys import termios import time +import zlib class Client: @@ -212,6 +214,26 @@ def image_test(tmux, sx=40, sy=16): test.close() +def kitty(control, data=b''): + """Build a Kitty graphics APC with replies suppressed.""" + return b'\033_Gq=2,' + control.encode() + b';' + \ + base64.b64encode(data) + b'\033\\' + + +def png(w, h, rows, colour_type=6, chunks=()): + """Build an eight-bit PNG from unfiltered rows and optional chunks.""" + assert len(rows) == h, (len(rows), h) + parts = [(b'IHDR', struct.pack('>2I5B', w, h, 8, colour_type, 0, 0, 0))] + parts.extend(chunks) + parts.append((b'IDAT', zlib.compress(b''.join(b'\0' + row for row in rows)))) + parts.append((b'IEND', b'')) + out = b'\x89PNG\r\n\x1a\n' + for kind, data in parts: + out += struct.pack('>I', len(data)) + kind + data + \ + struct.pack('>I', zlib.crc32(kind + data)) + return out + + def sixel(w, h, palette, pixels, p2=1): """Build SIXEL input from a palette and row-major palette indexes.""" assert len(pixels) == w * h diff --git a/screen-write.c b/screen-write.c index c48a8da03..65c2b6501 100644 --- a/screen-write.c +++ b/screen-write.c @@ -1648,6 +1648,11 @@ screen_write_insertline(struct screen_write_ctx *ctx, u_int ny, u_int bg) screen_write_initctx(ctx, &ttyctx, 1, 1); ttyctx.bg = bg; +#ifdef ENABLE_IMAGES + image_redraw_area(ctx, 0, s->cy, screen_size_x(s), + s->rlower + 1 - s->cy); +#endif + grid_view_insert_lines_region(gd, s->rlower, s->cy, ny, bg); screen_write_collect_flush(ctx, 0, __func__); @@ -1687,6 +1692,10 @@ screen_write_deleteline(struct screen_write_ctx *ctx, u_int ny, u_int bg) screen_write_initctx(ctx, &ttyctx, 1, 1); ttyctx.bg = bg; +#ifdef ENABLE_IMAGES + image_redraw_area(ctx, 0, s->cy, screen_size_x(s), ry); +#endif + grid_view_delete_lines_region(gd, s->rlower, s->cy, ny, bg); screen_write_collect_flush(ctx, 0, __func__); @@ -2182,8 +2191,15 @@ screen_write_clearscreen(struct screen_write_ctx *ctx, u_int bg) ctx->wp != NULL && options_get_number(ctx->wp->options, "scroll-on-clear")) grid_view_clear_history(s->grid, bg); - else + else { +#ifdef ENABLE_IMAGES + for (y = 0; y < sy; y++) { + image_grid_free_line(s->grid, + &s->grid->linedata[s->grid->hsize + y]); + } +#endif grid_view_clear(s->grid, 0, 0, sx, sy, bg); + } screen_write_collect_clear(ctx, 0, sy); diff --git a/screen.c b/screen.c index 2a433aaf0..3e3dcbee1 100644 --- a/screen.c +++ b/screen.c @@ -120,6 +120,7 @@ screen_reinit(struct screen *s) s->saved_cy = UINT_MAX; screen_reset_tabs(s); + grid_free_lines(s->grid, s->grid->hsize, s->grid->sy); grid_clear_lines(s->grid, s->grid->hsize, s->grid->sy, 8); screen_clear_selection(s); @@ -406,6 +407,12 @@ screen_resize_y(struct screen *s, u_int sy, int eat_empty, u_int *cy) if (available > 0) { if (available > needed) available = needed; +#ifdef ENABLE_IMAGES + for (i = oldy - available; i < oldy; i++) { + image_grid_free_line(gd, + &gd->linedata[gd->hsize + i]); + } +#endif grid_view_delete_lines(gd, oldy - available, available, 8); } @@ -674,6 +681,7 @@ screen_alternate_on(struct screen *s, struct grid_cell *gc, int cursor) } memcpy(&s->saved_cell, gc, sizeof s->saved_cell); + grid_free_lines(s->grid, screen_hsize(s), sy); grid_view_clear(s->grid, 0, 0, sx, sy, 8); s->saved_flags = s->grid->flags; diff --git a/tmux-image.h b/tmux-image.h index 60d62069b..a5d9cb4b3 100644 --- a/tmux-image.h +++ b/tmux-image.h @@ -28,11 +28,14 @@ struct grid_line; struct image; struct image_backend; struct image_line; +struct image_saved; struct image_store; +struct input_ctx; struct screen; struct screen_write_ctx; struct sixel_image; struct tty; +struct window_pane; /* Maximum image size in bytes. */ #define IMAGE_SIZE_LIMIT (64 * 1024 * 1024) @@ -43,6 +46,12 @@ struct tty; /* Backend images move with terminal scrolling. */ #define IMAGE_BACKEND_SCROLLS 0x2 +/* Type of image. */ +enum image_type { + IMAGE_TYPE_SIXEL, + IMAGE_TYPE_KITTY +}; + /* Source image and placement geometry. */ struct image_geom { /* Position and size within the source RGBA pixels. */ @@ -95,11 +104,14 @@ struct image_rect { }; /* image.c */ -struct image *image_create(struct image_geom *, u_char *); -struct image *image_create_with_parent(struct image *, struct image_geom *, - size_t, u_char *); +struct image *image_create(enum image_type, struct image_geom *, u_char *); +struct image *image_create_with_parent(enum image_type, struct image *, + struct image_geom *, size_t, u_char *); struct image *image_find(u_int); u_int image_get_id(struct image *); +u_int image_get_order_id(struct image *); +void image_set_order_id(struct image *, u_int); +enum image_type image_get_type(struct image *); void image_ref(struct image *); void image_free(struct image *); void image_touch(struct image *); @@ -113,21 +125,35 @@ void image_size_in_cells(u_int, u_int, u_int, u_int, u_int *, void image_get_pixel_area(struct image *, u_int, u_int, u_int, u_int, u_int *, u_int *, u_int *, u_int *); +/* image-decode.c */ +u_char *image_base64_decode(const char *, size_t, size_t, size_t *); +u_char *image_png_decode(const u_char *, size_t, size_t, int, size_t, + u_int *, u_int *); +u_char *image_raw_decode(const u_char *, size_t, size_t, u_int, u_int, + u_int, int); + /* image-grid.c */ void image_grid_add(struct grid *, struct image *, u_int, u_int, u_int, u_int, u_int, u_int); -void image_grid_remove(struct grid *, u_int, u_int, u_int, u_int); +void image_grid_overwrite(struct grid *, u_int, u_int, u_int, + u_int); void image_grid_free_line(struct grid *, struct grid_line *); void image_grid_free(struct grid *); void image_grid_remove_all(struct image *); void image_grid_move_cells(struct grid *, u_int, u_int, u_int, u_int); +struct image_saved *image_grid_save_lines(struct grid *, u_int, u_int, u_int, + int); +void image_grid_restore_lines(struct grid *, struct image_saved *); void image_grid_duplicate_lines(struct grid *, u_int, struct grid *, u_int, u_int); -void image_grid_copy_area(struct grid *, u_int, u_int, struct grid *, - u_int, u_int, u_int, u_int); +void image_grid_copy_area(struct grid *, u_int, u_int, + struct grid *, u_int, u_int, u_int, u_int); void image_grid_resize_width(struct grid *, u_int); int image_grid_line_has_images(struct grid_line *); +int image_grid_has_image(struct grid *, u_int); +u_int image_grid_clear(struct grid *, u_int); +u_int image_grid_clear_visible(struct grid *, enum image_type); int image_grid_area_has_images(struct grid *, u_int, u_int, u_int, u_int); void image_redraw_area(struct screen_write_ctx *, u_int, u_int, @@ -136,7 +162,9 @@ void image_redraw_all(struct screen_write_ctx *); void image_redraw_scroll(struct screen_write_ctx *); void image_damage_screen(struct screen_write_ctx *, u_int, u_int, u_int, u_int); -void image_write(struct screen_write_ctx *, struct image *, u_int); +void image_write(struct screen_write_ctx *, struct image *, u_int, + int); +void image_clear(struct screen_write_ctx *, u_int); int image_grid_get_at(struct grid *, u_int, u_int, struct image **, u_int *, u_int *); const struct grid_cell *image_map_cell(struct tty *, struct grid *, u_int, @@ -168,6 +196,14 @@ void sixel_start_draw(struct tty *, u_int, u_int, u_int, u_int); void sixel_free_pending(struct tty *); void sixel_flush_pending(struct tty *); +/* image-kitty.c */ +int kitty_select(struct tty *); +void kitty_parse_input(struct input_ctx *, struct window_pane *, + struct screen_write_ctx *, u_int, void **, const u_char *, + size_t); +void kitty_free_state(void *); +void kitty_remove_image(struct image *); + #endif /* ENABLE_IMAGES */ #endif /* TMUX_IMAGE_H */ diff --git a/tmux.h b/tmux.h index 09192675c..0c48c9ecf 100644 --- a/tmux.h +++ b/tmux.h @@ -3464,6 +3464,8 @@ void input_parse_pane(struct window_pane *); void input_parse_buffer(struct window_pane *, const u_char *, size_t); void input_parse_screen(struct input_ctx *, struct screen *, screen_write_init_ctx_cb, void *, const u_char *, size_t); +void input_reply(struct input_ctx *, int, const char *, + ...) printflike(3, 4); void input_reply_clipboard(struct bufferevent *, const char *, size_t, const char *, char); void input_set_buffer_size(size_t);