Add SIXEL cache.

This commit is contained in:
Nicholas Marriott
2026-10-14 15:31:25 +01:00
parent 77449c0e5c
commit 5213cbf644
2 changed files with 252 additions and 3 deletions

View File

@@ -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,14 +1277,143 @@ 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;
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);

View File

@@ -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