diff --git a/image-sixel.c b/image-sixel.c index 984e42b38..0eb2f5eb5 100644 --- a/image-sixel.c +++ b/image-sixel.c @@ -40,6 +40,9 @@ #define SIXEL_HGRAM_SIZE \ (SIXEL_HGRAM_LEVELS * SIXEL_HGRAM_LEVELS * SIXEL_HGRAM_LEVELS) +/* Maximum number of bytes in the SIXEL cache. */ +#define SIXEL_CACHE_SIZE_LIMIT IMAGE_SIZE_LIMIT + /* Line of a SIXEL image. */ struct sixel_line { /* Number of pixels in this row. */ @@ -115,6 +118,25 @@ struct sixel_pending { u_int dst_y; }; +/* Item in the cache. */ +struct sixel_item { + u_int id; + struct sixel_image *si; + size_t size; + + u_int cw; + u_int ch; + + RB_ENTRY(sixel_item) tree_entry; + TAILQ_ENTRY(sixel_item) entry; +}; + +RB_HEAD(sixel_tree, sixel_item); +TAILQ_HEAD(sixel_items, sixel_item); +static struct sixel_tree sixel_tree = RB_INITIALIZER(&sixel_tree); +static struct sixel_items sixel_cache = TAILQ_HEAD_INITIALIZER(sixel_cache); +static size_t sixel_cache_size; + /* RGB histogram entry. */ struct sixel_hgram { u_int count; @@ -168,6 +190,26 @@ struct sixel_source { static void sixel_free(struct sixel_image *); +/* SIXEL cache item comparison. */ +static int +sixel_cache_cmp(struct sixel_item *a, struct sixel_item *b) +{ + if (a->id < b->id) + return (-1); + if (a->id > b->id) + return (1); + if (a->cw < b->cw) + return (-1); + if (a->cw > b->cw) + return (1); + if (a->ch < b->ch) + return (-1); + if (a->ch > b->ch) + return (1); + return (0); +} +RB_GENERATE_STATIC(sixel_tree, sixel_item, tree_entry, sixel_cache_cmp); + /* Grow a SIXEL image to contain a line. */ static int sixel_parse_expand_lines(struct sixel_image *si, u_int y) @@ -1235,15 +1277,144 @@ fail: return (NULL); } -/* Encode part of an image as SIXEL for a terminal cell size. */ +/* Return the number of bytes allocated for an indexed SIXEL image. */ +static size_t +sixel_image_size(struct sixel_image *si) +{ + return (sizeof *si + si->y * sizeof *si->lines + si->allocated + + si->ncolours * sizeof *si->colours); +} + +/* Remove an image from the global SIXEL output cache. */ +static void +sixel_remove_cache(struct sixel_item *cache) +{ + RB_REMOVE(sixel_tree, &sixel_tree, cache); + TAILQ_REMOVE(&sixel_cache, cache, entry); + sixel_cache_size -= cache->size; + sixel_free(cache->si); + free(cache); +} + +/* Drop SIXEL cache entries whose source images have gone away. */ +static void +sixel_collect_images(void) +{ + struct sixel_item *cache, *next; + + TAILQ_FOREACH_SAFE(cache, &sixel_cache, entry, next) { + if (image_find(cache->id) == NULL) + sixel_remove_cache(cache); + } +} + +/* Get a cached image at the given cell size. */ +static struct sixel_image * +sixel_get_image(struct image *im, u_int cw, u_int ch) +{ + struct sixel_image *si; + size_t size; + u_int id = image_get_id(im), sx, sy; + struct sixel_item *cache, find = { .id = id, .cw = cw, .ch = ch }; + + cache = RB_FIND(sixel_tree, &sixel_tree, &find); + if (cache != NULL) { + TAILQ_REMOVE(&sixel_cache, cache, entry); + TAILQ_INSERT_TAIL(&sixel_cache, cache, entry); + return (cache->si); + } + image_get_size_in_cells(im, &sx, &sy); + + si = sixel_make_image(im, 0, 0, sx, sy, cw, ch); + if (si == NULL) + return (NULL); + + size = (sizeof *cache) + sixel_image_size(si); + if (size > SIXEL_CACHE_SIZE_LIMIT) { + sixel_free(si); + return (NULL); + } + if (sixel_cache_size > SIXEL_CACHE_SIZE_LIMIT - size) + sixel_collect_images(); + while (sixel_cache_size > SIXEL_CACHE_SIZE_LIMIT - size) + sixel_remove_cache(TAILQ_FIRST(&sixel_cache)); + + cache = xcalloc(1, sizeof *cache); + cache->id = id; + cache->cw = cw; + cache->ch = ch; + cache->size = size; + cache->si = si; + RB_INSERT(sixel_tree, &sixel_tree, cache); + TAILQ_INSERT_TAIL(&sixel_cache, cache, entry); + + sixel_cache_size += size; + return (si); +} + +/* Copy a rectangle from a SIXEL image. */ +static struct sixel_image * +sixel_crop(struct sixel_image *si, u_int cx, u_int cy, u_int csx, u_int csy, + u_int cw, u_int ch) +{ + struct sixel_image *new; + struct sixel_line *src, *dst; + uint64_t left, right, top, bottom; + u_int x, y, w, h, i; + + left = (uint64_t)cx * cw; + top = (uint64_t)cy * ch; + right = ((uint64_t)cx + csx) * cw; + bottom = ((uint64_t)cy + csy) * ch; + if (right > si->ra_x) + right = si->ra_x; + if (bottom > si->ra_y) + bottom = si->ra_y; + if (right <= left || bottom <= top) + return (NULL); + x = left; + y = top; + w = right - left; + h = bottom - top; + + new = xcalloc(1, sizeof *new); + new->x = new->ra_x = w; + new->y = new->ra_y = h; + new->lines = xcalloc(h, sizeof *new->lines); + for (i = 0; i < h; i++) { + if (y + i >= si->y) + break; + src = &si->lines[y + i]; + if (x >= src->x) + continue; + dst = &new->lines[i]; + dst->x = src->x - x; + if (dst->x > w) + dst->x = w; + dst->data = xmalloc(dst->x * sizeof *dst->data); + memcpy(dst->data, src->data + x, dst->x * sizeof *dst->data); + new->allocated += dst->x * sizeof *dst->data; + } + new->used_colours = si->used_colours; + new->ncolours = si->ncolours; + new->colours = xmalloc(new->ncolours * sizeof *new->colours); + memcpy(new->colours, si->colours, new->ncolours * sizeof *new->colours); + return (new); +} + +/* Encode part of an image as SIXEL. */ static char * sixel_from_image(struct image *im, u_int x, u_int y, u_int sx, u_int sy, u_int cw, u_int ch, size_t *size) { - struct sixel_image *si; + struct sixel_image *si, *cached; char *data; - si = sixel_make_image(im, x, y, sx, sy, cw, ch); + cached = sixel_get_image(im, cw, ch); + if (cached != NULL) + si = sixel_crop(cached, x, y, sx, sy, cw, ch); + else + si = sixel_make_image(im, x, y, sx, sy, cw, ch); if (si == NULL) return (NULL); data = sixel_print(si, size); diff --git a/regress/image-sixel-cache.sh b/regress/image-sixel-cache.sh new file mode 100644 index 000000000..6eb8a3123 --- /dev/null +++ b/regress/image-sixel-cache.sh @@ -0,0 +1,78 @@ +#!/bin/sh + +# Check that cached colours and dithering survive clipping and client changes, +# and that a visible crop is drawn when the whole scaled image is too large. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, sixel, check_image + +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] +wire = sixel(32, 16, palette, list(range(512))) + +with image_test(sys.argv[1]) as test: + first = test.attach() + test.emit(wire) + original = first.images() + assert len(original) == 1 and original[0][:2] == (32, 16) + + # Clients with the same cell size draw identical indexed pixels. + second = test.attach() + test.redraw() + assert first.images() == second.images() == original + + # A different cell size must not replace the first clients' result. + small = test.attach(cw=4, ch=8) + test.redraw() + scaled = small.images() + assert len(scaled) == 1 and scaled[0][:2] == (16, 8) + assert first.images() == second.images() == original + + # Overwrite the first cell. The remaining pixels retain the full palette + # and dithering, rather than converting the smaller rectangle again. + test.emit(b'\033[HX') + expected = [original[0][2][y * 32 + x] + for y in range(16) for x in range(8, 32)] + for client in (first, second): + images = client.images() + assert len(images) == 1 + check_image(images[0], 24, 16, expected) + small_expected = [scaled[0][2][y * 16 + x] + for y in range(8) for x in range(4, 16)] + images = small.images() + assert len(images) == 1 + check_image(images[0], 12, 8, small_expected) + + # Changing back to an earlier cell size reuses the same indexed pixels. + test.resize(small, 40, 16) + test.redraw() + for client in (first, second, small): + images = client.images() + assert len(images) == 1 + check_image(images[0], 24, 16, expected) + + # Replacing the source must not draw pixels from the deleted image. + test.emit(b'\033[2J\033[H' + + sixel(8, 16, [(100, 0, 0)], [0] * (8 * 16))) + for client in (first, second, small): + images = client.images() + assert len(images) == 1 + check_image(images[0], 8, 16, [(255, 0, 0)] * (8 * 16)) + +with image_test(sys.argv[1], sx=1) as test: + client = test.attach() + test.emit(sixel(16, 16, [(100, 0, 0)], [0] * (16 * 16))) + # The whole image would be 12000 pixels wide, beyond the SIXEL limit. + # Its visible first cell is still valid and must be converted directly. + test.resize(client, 1, 16, cw=6000, ch=16) + test.redraw() + images = client.images() + assert len(images) == 1 + check_image(images[0], 6000, 16, [(255, 0, 0)] * (6000 * 16)) +PY