diff --git a/.github/workflows/regress.yml b/.github/workflows/regress.yml index ded64b7f9..17504b1e1 100644 --- a/.github/workflows/regress.yml +++ b/.github/workflows/regress.yml @@ -79,7 +79,7 @@ jobs: - name: build run: | sh autogen.sh - ./configure ${{ matrix.configure }} + ./configure --enable-images ${{ matrix.configure }} ${{ matrix.make }} -j"$(getconf _NPROCESSORS_ONLN)" - name: test diff --git a/image-fallback.c b/image-fallback.c index 428fb899a..d8888903b 100644 --- a/image-fallback.c +++ b/image-fallback.c @@ -18,490 +18,161 @@ #include -#include -#include #include #include "tmux.h" -/* Character-cell fallback for clients without a graphical image protocol. */ - -enum image_glyph_detail { - IMAGE_GLYPH_ASCII, - IMAGE_GLYPH_SHADE5, - IMAGE_GLYPH_SHADE8, - IMAGE_GLYPH_HALF, - IMAGE_GLYPH_QUADRANT, - IMAGE_GLYPH_SEXTANT +/* UTF-8 characters used for image fallback. */ +static const struct utf8_data fallback_shades[5] = { + { " ", 1, 1, 1 }, + { "\342\226\221", 3, 3, 1 }, + { "\342\226\222", 3, 3, 1 }, + { "\342\226\223", 3, 3, 1 }, + { "\342\226\210", 3, 3, 1 } +}; +static const struct utf8_data fallback_half_lower = { + "\342\226\204", 3, 3, 1 }; -enum image_glyph_palette { - IMAGE_GLYPH_PALETTE_8, - IMAGE_GLYPH_PALETTE_16, - IMAGE_GLYPH_PALETTE_256, - IMAGE_GLYPH_PALETTE_RGB -}; - -struct image_rgb { - u_char r; - u_char g; - u_char b; -}; - -/* Shading levels cached for the lifetime of an image. */ -struct image_glyph_data { - u_char *shade5; /* Cached levels for five-shade output. */ - u_char *shade8; /* Cached levels for eight-shade output. */ -}; - -static const struct image_rgb image_ansi_colours[16] = { - { 0x00, 0x00, 0x00 }, { 0x80, 0x00, 0x00 }, - { 0x00, 0x80, 0x00 }, { 0x80, 0x80, 0x00 }, - { 0x00, 0x00, 0x80 }, { 0x80, 0x00, 0x80 }, - { 0x00, 0x80, 0x80 }, { 0xc0, 0xc0, 0xc0 }, - { 0x80, 0x80, 0x80 }, { 0xff, 0x00, 0x00 }, - { 0x00, 0xff, 0x00 }, { 0xff, 0xff, 0x00 }, - { 0x00, 0x00, 0xff }, { 0xff, 0x00, 0xff }, - { 0x00, 0xff, 0xff }, { 0xff, 0xff, 0xff } -}; - -/* Return the squared RGB distance between two colours. */ -static u_int -fallback_distance(struct image_rgb a, struct image_rgb b) +/* Fallback output is available on every terminal. */ +int +fallback_select(__unused struct tty *tty) { - int r = a.r - b.r, g = a.g - b.g, bl = a.b - b.b; - - return (r * r + g * g + bl * bl); -} - -/* Return whether a colour is close enough to grey to use neutral colours. */ -static int -fallback_low_saturation(struct image_rgb colour) -{ - u_int minimum, maximum; - - minimum = maximum = colour.r; - if (colour.g < minimum) - minimum = colour.g; - if (colour.b < minimum) - minimum = colour.b; - if (colour.g > maximum) - maximum = colour.g; - if (colour.b > maximum) - maximum = colour.b; - return (maximum == 0 || (maximum - minimum) * 4 <= maximum); -} - -/* Find the closest ANSI colour, keeping greys on the neutral ramp. */ -static u_int -fallback_nearest_ansi(struct image_rgb colour, u_int colours) -{ - u_int i, distance, best_distance = UINT_MAX, best = 0; - int neutral = fallback_low_saturation(colour); - - for (i = 0; i < colours; i++) { - if (neutral && i != 0 && i != 7 && - (colours != 16 || (i != 8 && i != 15))) { - continue; - } - distance = fallback_distance(colour, image_ansi_colours[i]); - if (distance < best_distance) { - best_distance = distance; - best = i; - } - } - return (best); -} - -/* Map an RGB colour to the terminal palette and its output colour code. */ -static struct image_rgb -fallback_quantize(struct image_rgb colour, enum image_glyph_palette palette, - int *output) -{ - struct image_rgb result; - int value; - u_int index, ncolours; - - if (palette == IMAGE_GLYPH_PALETTE_RGB) { - *output = colour_join_rgb(colour.r, colour.g, colour.b); - return (colour); - } - if (palette == IMAGE_GLYPH_PALETTE_256) { - *output = colour_find_rgb(colour.r, colour.g, colour.b); - value = colour_256toRGB(*output); - colour_split_rgb(value, &result.r, &result.g, &result.b); - return (result); - } - ncolours = 16; - if (palette == IMAGE_GLYPH_PALETTE_8) - ncolours = 8; - index = fallback_nearest_ansi(colour, ncolours); - result = image_ansi_colours[index]; - if (index < 8) - *output = index; - else - *output = 90 + index - 8; - return (result); -} - -/* Fit two colours to a block glyph's pixel samples. */ -static void -fallback_fit_colours(const struct image_rgb *samples, u_int count, - struct image_rgb centres[2]) -{ - u_int sum[2][3], counts[2], group, i, j, iteration, distance; - u_int maximum = 0, first = 0, second = 0; - - /* Start with the two most widely separated samples. */ - for (i = 0; i < count; i++) { - for (j = i + 1; j < count; j++) { - distance = fallback_distance(samples[i], samples[j]); - if (distance > maximum) { - maximum = distance; - first = i; - second = j; - } - } - } - centres[0] = samples[first]; - centres[1] = samples[second]; - - /* Refine the foreground and background by averaging each cluster. */ - for (iteration = 0; iteration < 4; iteration++) { - memset(sum, 0, sizeof sum); - memset(counts, 0, sizeof counts); - for (i = 0; i < count; i++) { - group = fallback_distance(samples[i], centres[1]) < - fallback_distance(samples[i], centres[0]); - sum[group][0] += samples[i].r; - sum[group][1] += samples[i].g; - sum[group][2] += samples[i].b; - counts[group]++; - } - for (i = 0; i < 2; i++) { - if (counts[i] == 0) - continue; - centres[i].r = (sum[i][0] + counts[i] / 2) / counts[i]; - centres[i].g = (sum[i][1] + counts[i] / 2) / counts[i]; - centres[i].b = (sum[i][2] + counts[i] / 2) / counts[i]; - } - } -} - -/* Copy the UTF-8 character for an ACS key into a fallback cell. */ -static int -fallback_set_acs(struct tty *tty, struct utf8_data *data, u_char key) -{ - struct utf8_data *ud; - const char *s = tty_acs_get(tty, key); - - if (s == NULL) - return (0); - ud = utf8_fromcstr(s); - if (ud[0].size == 0) { - free(ud); - return (0); - } - memcpy(data, &ud[0], sizeof *data); - free(ud); return (1); } -/* Map a block's foreground mask to a half, quadrant or sextant glyph. */ -static u_char -fallback_block_key(enum image_glyph_detail detail, u_int mask) +/* Text images move with terminal scrolling. */ +int +fallback_get_flags(__unused struct tty *tty) { - static const u_char half[4] = { - 0, TTY_ACS_IMAGE_HALF_UPPER, TTY_ACS_IMAGE_HALF_LOWER, - TTY_ACS_IMAGE_BLOCK - }; - static const u_char quadrant[16] = { - 0, TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT, - TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT, TTY_ACS_IMAGE_HALF_UPPER, - TTY_ACS_IMAGE_QUADRANT_LOWER_LEFT, TTY_ACS_IMAGE_HALF_LEFT, - TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT_LOWER_LEFT, - TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_UPPER_RIGHT_LOWER_LEFT, - TTY_ACS_IMAGE_QUADRANT_LOWER_RIGHT, - TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_LOWER_RIGHT, - TTY_ACS_IMAGE_HALF_RIGHT, - TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_UPPER_RIGHT_LOWER_RIGHT, - TTY_ACS_IMAGE_HALF_LOWER, - TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_LOWER_LEFT_LOWER_RIGHT, - TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT_LOWER_LEFT_LOWER_RIGHT, - TTY_ACS_IMAGE_BLOCK - }; - u_char key; - - if (detail == IMAGE_GLYPH_HALF) - return (half[mask]); - if (detail == IMAGE_GLYPH_QUADRANT) - return (quadrant[mask]); - if (mask == 0) - return (0); - if (mask == 21) - return (TTY_ACS_IMAGE_HALF_LEFT); - if (mask == 42) - return (TTY_ACS_IMAGE_HALF_RIGHT); - if (mask == 63) - return (TTY_ACS_IMAGE_BLOCK); - key = tty_acs_image_sextant(mask); - return (key); + return (IMAGE_BACKEND_SCROLLS); } -/* Build and cache dithered brightness levels for a shading palette. */ -static u_char * -fallback_make_shades(struct image *im, u_int levels) +/* Get the average colour and brightness of an area. */ +static int +fallback_sample(struct image *im, uint64_t px, uint64_t py, uint64_t pw, + uint64_t ph, u_char *brightness) { - struct image_glyph_data *data = image_get_fallback_data(im); - const struct image_cell *cell; - int *values, value, represented, error; - size_t cells, index; - u_int x, y, scan, level, minimum = 255, maximum = 0; - u_int sx, sy; - int reverse; - u_char *result; + const u_char *pixels, *p; + uint64_t red = 0, green = 0, blue = 0, sum = 0, count; + u_int w, h, cw, ch, x0, x1, y0, y1, x, y; + size_t stride; - if (data == NULL) { - data = xcalloc(1, sizeof *data); - image_set_fallback_data(im, data); + image_get_size(im, &w, &h); + image_get_canvas_size(im, &cw, &ch); + pixels = image_get_pixels(im, &stride, NULL); + + x0 = px * cw / pw; + x1 = ((px + 1) * cw + pw - 1) / pw; + y0 = py * ch / ph; + y1 = ((py + 1) * ch + ph - 1) / ph; + if (x1 <= x0) + x1 = x0 + 1; + if (y1 <= y0) + y1 = y0 + 1; + if (x0 >= w || y0 >= h) { + if (brightness != NULL) + *brightness = 0; + return (colour_join_rgb(0, 0, 0)); } - image_get_size_in_cells(im, &sx, &sy); - result = (levels == 5 ? data->shade5 : data->shade8); - if (result != NULL) - return (result); + count = (uint64_t)(x1 - x0) * (y1 - y0); - /* Collect cell brightness and the range used by this image. */ - cells = (size_t)sx * sy; - values = xcalloc(cells, sizeof *values); - result = xcalloc(cells, 1); - for (y = 0; y < sy; y++) { - for (x = 0; x < sx; x++) { - index = (size_t)y * sx + x; - cell = image_get_cell(im, x, y); - value = cell->whole.brightness; - if ((u_int)value < minimum) - minimum = value; - if ((u_int)value > maximum) - maximum = value; - values[index] = value; + if (x1 > w) + x1 = w; + if (y1 > h) + y1 = h; + for (y = y0; y < y1; y++) { + for (x = x0; x < x1; x++) { + p = pixels + y * stride + x * 4; + + red += p[0] * p[3] / 255; + green += p[1] * p[3] / 255; + blue += p[2] * p[3] / 255; + + sum += ((2126ULL * p[0] + 7152ULL * p[1] + + 722ULL * p[2]) / 10000) * p[3] / 255; } } - /* Stretch the brightness range to make all shading levels available. */ - if (maximum > minimum) { - for (index = 0; index < cells; index++) { - values[index] = (values[index] - minimum) * 255 / - (maximum - minimum); - } - } - - /* Dither each row, alternating direction to distribute the error. */ - for (y = 0; y < sy; y++) { - reverse = (y & 1); - for (scan = 0; scan < sx; scan++) { - x = (reverse ? sx - scan - 1 : scan); - index = (size_t)y * sx + x; - value = values[index]; - if (value < 0) - value = 0; - if (value > 255) - value = 255; - level = (value * (levels - 1) + 127) / 255; - result[index] = level; - represented = level * 255 / (levels - 1); - error = value - represented; - if (!reverse && x + 1 < sx) - values[index + 1] += error * 7 / 16; - if (reverse && x != 0) - values[index - 1] += error * 7 / 16; - if (y + 1 == sy) - continue; - if (!reverse && x != 0) - values[index + sx - 1] += error * 3 / 16; - if (reverse && x + 1 < sx) - values[index + sx + 1] += error * 3 / 16; - values[index + sx] += error * 5 / 16; - if (!reverse && x + 1 < sx) - values[index + sx + 1] += error / 16; - if (reverse && x != 0) - values[index + sx - 1] += error / 16; - } - } - free(values); - - /* Keep the result for subsequent redraws at the same shading level. */ - if (levels == 5) - data->shade5 = result; - else - data->shade8 = result; - return (result); + if (brightness != NULL) + *brightness = sum / count; + return (colour_join_rgb(red / count, green / count, blue / count)); } -/* Choose the richest colour palette supported by this terminal. */ -static enum image_glyph_palette -fallback_get_palette(struct tty *tty) -{ - int colours; - - if (tty->term->flags & TERM_RGBCOLOURS) - return (IMAGE_GLYPH_PALETTE_RGB); - if (tty->term->flags & TERM_256COLOURS) - return (IMAGE_GLYPH_PALETTE_256); - colours = tty_term_number(tty->term, TTYC_COLORS); - if (colours >= 256) - return (IMAGE_GLYPH_PALETTE_256); - if (colours >= 16) - return (IMAGE_GLYPH_PALETTE_16); - return (IMAGE_GLYPH_PALETTE_8); -} - -/* Choose the fallback glyph detail supported by this terminal. */ -static enum image_glyph_detail -fallback_get_detail(struct tty *tty, enum image_glyph_palette palette) -{ - if (tty_acs_needed(tty)) - return (IMAGE_GLYPH_ASCII); - if (tty->term->flags & TERM_IMAGE_SEXTANTS) - return (IMAGE_GLYPH_SEXTANT); - if (tty->term->flags & TERM_IMAGE_QUADRANTS) - return (IMAGE_GLYPH_QUADRANT); - if (palette != IMAGE_GLYPH_PALETTE_8) - return (IMAGE_GLYPH_HALF); - if (tty_term_has(tty->term, TTYC_NOBR)) - return (IMAGE_GLYPH_SHADE5); - return (IMAGE_GLYPH_SHADE8); -} - -/* Render one image cell using a two-colour block glyph. */ +/* Set a cell to a character representing part of an image. */ static void -fallback_block(struct tty *tty, struct image *im, u_int x, u_int y, - enum image_glyph_detail detail, enum image_glyph_palette palette, - struct grid_cell *out) -{ - const struct image_cell *cell = image_get_cell(im, x, y); - struct image_rgb samples[6], centres[2], quantized[2]; - u_int columns, rows, sx, sy, ix, iy, i, n, mask; - u_int x0, x1, y0, y1, red, green, blue, count; - int colours[2]; - u_char key; - - /* Average the stored samples into the chosen glyph's subcells. */ - columns = (detail == IMAGE_GLYPH_HALF ? 1 : 2); - rows = (detail == IMAGE_GLYPH_SEXTANT ? 3 : 2); - i = 0; - for (sy = 0; sy < rows; sy++) { - for (sx = 0; sx < columns; sx++) { - x0 = sx * IMAGE_SAMPLE_COLUMNS / columns; - x1 = (sx + 1) * IMAGE_SAMPLE_COLUMNS / columns; - y0 = sy * IMAGE_SAMPLE_ROWS / rows; - y1 = (sy + 1) * IMAGE_SAMPLE_ROWS / rows; - red = green = blue = count = 0; - for (iy = y0; iy < y1; iy++) { - for (ix = x0; ix < x1; ix++) { - red += cell->samples[iy][ix].red; - green += cell->samples[iy][ix].green; - blue += cell->samples[iy][ix].blue; - count++; - } - } - samples[i].r = red / count; - samples[i].g = green / count; - samples[i].b = blue / count; - i++; - } - } - - /* Fit the two colours and choose which subcells use the foreground. */ - n = columns * rows; - fallback_fit_colours(samples, n, centres); - quantized[0] = fallback_quantize(centres[0], palette, &colours[0]); - quantized[1] = fallback_quantize(centres[1], palette, &colours[1]); - mask = 0; - for (i = 0; i < n; i++) { - if (fallback_distance(samples[i], quantized[1]) < - fallback_distance(samples[i], quantized[0])) { - mask |= (1U << i); - } - } - key = fallback_block_key(detail, mask); - - /* Use a blank cell if the terminal has no matching glyph. */ - if (key == 0) - utf8_set(&out->data, ' '); - else if (!fallback_set_acs(tty, &out->data, key)) - utf8_set(&out->data, ' '); - out->fg = colours[1]; - out->bg = colours[0]; -} - -/* Render an image cell using the terminal's text and colour capabilities. */ -void fallback_get_cell(struct tty *tty, struct image *im, u_int x, u_int y, const struct grid_cell *gc, struct grid_cell *out) { - static const char ascii[] = " .:-=+*#%@"; - static const u_char shades[5] = { 0, TTY_ACS_IMAGE_SHADE_LIGHT, - TTY_ACS_IMAGE_SHADE_MEDIUM, TTY_ACS_IMAGE_SHADE_DARK, - TTY_ACS_IMAGE_BLOCK }; - static const u_char bold_shades[8] = { 0, - TTY_ACS_IMAGE_SHADE_LIGHT, TTY_ACS_IMAGE_SHADE_LIGHT, - TTY_ACS_IMAGE_SHADE_MEDIUM, TTY_ACS_IMAGE_SHADE_MEDIUM, - TTY_ACS_IMAGE_SHADE_DARK, TTY_ACS_IMAGE_BLOCK, - TTY_ACS_IMAGE_BLOCK }; - const struct image_cell *cell; - enum image_glyph_palette palette; - enum image_glyph_detail detail; - u_char *levels, key; - u_int level = 0, nlevels, sx, sy; + static const char a[] = " .:-=+*#%@"; + static const u_char bold_shades[8] = { 0, 1, 1, 2, 2, 3, 4, 4 }; + u_int ch, level, nlevels, sx, sy, colours; + u_char brightness; - memcpy(out, gc, sizeof *out); - cell = image_get_cell(im, x, y); - if (cell == NULL) { + if (out != gc) + memcpy(out, gc, sizeof *out); + out->flags &= GRID_FLAG_SELECTED; + out->attr = 0; + out->link = 0; + image_get_size_in_cells(im, &sx, &sy); + if (x >= sx || y >= sy) { utf8_set(&out->data, ' '); return; } - /* Select the palette and glyph detail for this terminal. */ - palette = fallback_get_palette(tty); - detail = fallback_get_detail(tty, palette); - if (detail == IMAGE_GLYPH_ASCII) { - level = cell->whole.brightness * (sizeof ascii - 2) / 255; - utf8_set(&out->data, ascii[level]); + if (tty_acs_needed(tty)) { + fallback_sample(im, x, y, sx, sy, &brightness); + utf8_set(&out->data, a[brightness * (sizeof a - 2) / 255]); return; } - if (detail == IMAGE_GLYPH_SHADE5 || detail == IMAGE_GLYPH_SHADE8) { + + if (tty->term->flags & (TERM_RGBCOLOURS|TERM_256COLOURS)) + colours = 256; + else + colours = tty_term_number(tty->term, TTYC_COLORS); + if (colours >= 16) { + out->bg = fallback_sample(im, x, y * 2, sx, sy * 2, NULL); + out->fg = fallback_sample(im, x, y * 2 + 1, sx, sy * 2, NULL); + utf8_copy(&out->data, &fallback_half_lower); + return; + } + + if (tty_term_has(tty->term, TTYC_NOBR)) + nlevels = 5; + else nlevels = 8; - if (detail == IMAGE_GLYPH_SHADE5) - nlevels = 5; - levels = fallback_make_shades(im, nlevels); - image_get_size_in_cells(im, &sx, &sy); - level = levels[(size_t)y * sx + x]; - key = (detail == IMAGE_GLYPH_SHADE5 ? shades[level] : - bold_shades[level]); - if (key == 0) - utf8_set(&out->data, ' '); - else if (!fallback_set_acs(tty, &out->data, key)) - utf8_set(&out->data, ' '); - out->fg = 7; - out->bg = 0; - out->attr &= ~GRID_ATTR_BRIGHT; - if (detail == IMAGE_GLYPH_SHADE8 && - (level == 2 || level == 4 || level == 7)) { - out->attr |= GRID_ATTR_BRIGHT; - } - return; - } - fallback_block(tty, im, x, y, detail, palette, out); + fallback_sample(im, x, y, sx, sy, &brightness); + level = (brightness * (nlevels - 1) + 127) / 255; + + if (nlevels == 8) + ch = bold_shades[level]; + else + ch = level; + utf8_copy(&out->data, &fallback_shades[ch]); + + out->fg = 7; + out->bg = 0; + out->attr &= ~GRID_ATTR_BRIGHT; + if (nlevels == 8 && (level == 2 || level == 4 || level == 7)) + out->attr |= GRID_ATTR_BRIGHT; } -/* Free the brightness levels cached for text image rendering. */ -void -fallback_free(struct image *im) +/* Get a text cell for an image grid cell, if there is one. */ +const struct grid_cell * +fallback_map_cell(struct tty *tty, struct grid *gd, u_int x, u_int y, + const struct grid_cell *gc, struct grid_cell *out, int *empty) { - struct image_glyph_data *data = image_get_fallback_data(im); + struct image *im; + u_int src_x, src_y; + int status; - if (data == NULL) - return; - free(data->shade5); - free(data->shade8); - free(data); + status = image_grid_get_at(gd, x, y, &im, &src_x, &src_y); + if (status == 1) { + fallback_get_cell(tty, im, src_x, src_y, gc, out); + if (empty != NULL) + *empty = 0; + return (out); + } + if (status == -1 && empty != NULL) + *empty = 1; + return (gc); } diff --git a/image-grid.c b/image-grid.c index d67bbac4d..c604e1ca3 100644 --- a/image-grid.c +++ b/image-grid.c @@ -27,18 +27,6 @@ #define IMAGE_Z_BELOW_BACKGROUND (INT32_MIN / 2) -/* A cell-aligned part of an image to draw at a terminal position. */ -struct image_rect { - struct image *image; - int64_t z; - u_int source_x; /* Origin in image cells. */ - u_int source_y; - u_int sx; /* Size in cells. */ - u_int sy; - u_int destination_x; /* Origin in terminal cells. */ - u_int destination_y; -}; - /* One contiguous row of a placement in the grid. */ struct image_span { u_int x; /* Owning grid column. */ @@ -74,11 +62,19 @@ struct image_placement { u_int app_placement_id; int32_t z; uint64_t serial; /* Layer order. */ + u_int x; + u_int recover_from; struct image_spans spans; TAILQ_ENTRY(image_placement) entry; }; TAILQ_HEAD(image_placements, image_placement); +/* One image row to extend after a pane grows. */ +struct image_recover_row { + struct image_line *line; + u_int source_y; +}; + /* Placements belonging to one grid. */ struct image_store { uint64_t next_serial; @@ -104,49 +100,80 @@ struct image_placement_map { struct image_copy_ctx { struct grid *destination; struct image_placement_map *maps; - size_t count; /* Placement mapping count. */ + u_int count; /* Placement mapping count. */ }; +/* A backend to draw an image. */ struct image_backend { const char *name; - int flags; + int (*get_flags)(struct tty *); + int (*select)(struct tty *); + const struct grid_cell *(*map_cell)(struct tty *, struct grid *, u_int, + u_int, const struct grid_cell *, struct grid_cell *, + int *); void (*draw_rect)(struct tty *, const struct image_rect *); + void (*start_draw)(struct tty *, u_int, u_int, u_int, u_int); + void (*flush)(struct tty *); + void (*finish_draw)(struct tty *); void (*free)(struct tty *, int); }; -static const struct image_backend image_backend_fallback = { - "fallback", IMAGE_BACKEND_SCROLLS, NULL, NULL +/* Backend definitions. */ +static struct image_backend image_backend_fallback = { + "fallback", + fallback_get_flags, + fallback_select, + fallback_map_cell, + NULL, + NULL, + NULL, + NULL, + NULL }; -static const struct image_backend image_backend_kitty = { +static struct image_backend image_backend_sixel = { + "sixel", + sixel_get_flags, + sixel_select, + NULL, + sixel_draw_rect, + sixel_redraw_start, + sixel_flush_output, + NULL, + sixel_free_output +}; + +/* Kitty backend. */ +static struct image_backend image_backend_kitty = { "kitty", - IMAGE_BACKEND_GRAPHICAL|IMAGE_BACKEND_CLIPPED, - kitty_draw_rect, kitty_free_output_state -}; -static const struct image_backend image_backend_sixel = { - "sixel", IMAGE_BACKEND_GRAPHICAL, - sixel_draw_rect, sixel_free_output + kitty_get_flags, + kitty_select, + NULL, + kitty_draw_rect, + kitty_redraw_start, + NULL, + kitty_redraw_finish, + kitty_free_output_state }; -/* Find the image backend supported by a terminal. */ -static const struct image_backend * -image_tty_find_backend(struct tty *tty) -{ - if (tty->term != NULL && tty->term->flags & TERM_KITTYGRAPHICS) - return (&image_backend_kitty); - if (tty->term != NULL && tty->term->flags & TERM_SIXEL && - tty->xpixel != 0 && tty->ypixel != 0) { - return (&image_backend_sixel); - } - return (&image_backend_fallback); -} +/* Backends in order of preference. */ +static struct image_backend *image_backends[] = { + &image_backend_kitty, + &image_backend_sixel, + &image_backend_fallback +}; -/* Update the image backend and return whether it changed. */ +/* Update the image backend. */ int image_tty_update(struct tty *tty) { - const struct image_backend *backend; + struct image_backend *backend; + u_int i; - backend = image_tty_find_backend(tty); + for (i = 0; i < nitems(image_backends); i++) { + backend = image_backends[i]; + if (backend->select(tty)) + break; + } if (tty->image_backend == backend) return (0); @@ -154,76 +181,74 @@ image_tty_update(struct tty *tty) tty->image_backend->free(tty, !!(tty->flags & TTY_OPENED)); tty->image_data = NULL; tty->image_backend = backend; - log_debug("%s: %s image backend is %s", __func__, - tty->client->name, backend->name); return (1); } -/* Prepare the image backend to replace a redraw area. */ -void -image_redraw_start(struct tty *tty, u_int x, u_int y, u_int width, - u_int height) +/* Get the flags for a terminal's image backend. */ +int +image_backend_get_flags(struct tty *tty) { image_tty_update(tty); - if (tty->image_backend == &image_backend_kitty) - kitty_redraw_start(tty, x, y, width, height); - else if (tty->image_backend == &image_backend_sixel) - sixel_redraw_start(tty, x, y, width, height); + return (tty->image_backend->get_flags(tty)); } -/* Delete stale Kitty placements after their replacements have been drawn. */ -void -image_redraw_finish(struct tty *tty) +/* Map an image grid cell to text for the selected backend. */ +const struct grid_cell * +image_map_cell(struct tty *tty, struct grid *gd, u_int x, u_int y, + const struct grid_cell *gc, struct grid_cell *out, int *empty) { - if (tty->image_backend == &image_backend_kitty) - kitty_redraw_finish(tty); + image_tty_update(tty); + if (tty->image_backend->map_cell == NULL) + return (gc); + return (tty->image_backend->map_cell(tty, gd, x, y, gc, out, empty)); } -/* Write out any image output the backend is still holding back. */ +/* Clear old image pixels before redrawing a terminal area. */ +void +image_redraw_start(struct tty *tty, u_int x, u_int y, u_int w, u_int h) +{ + image_tty_update(tty); + if (tty->image_backend->start_draw != NULL) + tty->image_backend->start_draw(tty, x, y, w, h); +} + +/* Write pending image output to the terminal. */ void image_draw_flush(struct tty *tty) { image_tty_update(tty); - if (tty->image_backend == &image_backend_sixel) - sixel_flush_output(tty); + if (tty->image_backend->flush != NULL) + tty->image_backend->flush(tty); } -/* Return the flags for a terminal's image backend. */ -int -image_backend_flags(struct tty *tty) -{ - int flags; - - image_tty_update(tty); - flags = tty->image_backend->flags; - - /* Image scrolling is granted separately from text margin support. */ - if ((tty->image_backend == &image_backend_sixel || - tty->image_backend == &image_backend_kitty) && - (tty->term->flags & TERM_IMAGESCROLL)) { - flags |= IMAGE_BACKEND_SCROLLS; - } - - return (flags); -} - -/* Discard image backend state after a terminal geometry change. */ +/* Discard image backend state after a terminal size change. */ void -image_tty_geometry_changed(struct tty *tty) +image_tty_size_changed(struct tty *tty) { - image_tty_update(tty); - if (tty->image_backend->free != NULL) - tty->image_backend->free(tty, !!(tty->flags & TTY_OPENED)); + if (!image_tty_update(tty)) { + if (tty->image_backend->free != NULL) + tty->image_backend->free(tty, !!(tty->flags & TTY_OPENED)); + } } /* Free image backend state for a terminal. */ void image_tty_free(struct tty *tty, int send) { - if (tty->image_backend != NULL && tty->image_backend->free != NULL) - tty->image_backend->free(tty, send); - tty->image_backend = NULL; - tty->image_data = NULL; + if (tty->image_backend != NULL) { + if (tty->image_backend->free != NULL) + tty->image_backend->free(tty, send); + tty->image_backend = NULL; + tty->image_data = NULL; + } +} + +/* Finish drawing image placements for this terminal. */ +void +image_redraw_finish(struct tty *tty) +{ + if (tty->image_backend->finish_draw != NULL) + tty->image_backend->finish_draw(tty); } /* Return the ordering band for a placement. */ @@ -362,6 +387,34 @@ image_span_free(struct image_span *span) free(span); } +/* Remove the covered part of one span. */ +static void +image_span_remove(struct image_span *span, u_int x, u_int end) +{ + u_int span_end = span->x + span->sx; + + if (span_end <= x || span->x >= end) + return; + if (span->x < x && span_end > end) { + span->sx = x - span->x; + image_span_add(span->line, span->placement, end, span_end - end, + span->source_x + end - span->x, span->source_y); + return; + } + if (span->x < x) { + span->sx = x - span->x; + return; + } + if (span_end > end) { + span->source_x += end - span->x; + span->sx = span_end - end; + span->x = end; + return; + } + image_span_free(span); +} + + /* Remove a cell range from one placement or the selected input type. */ static void image_line_remove(struct image_line *line, u_int x, u_int width, @@ -369,7 +422,7 @@ image_line_remove(struct image_line *line, u_int x, u_int width, struct image_placement *only) { struct image_span *span, *next; - u_int end, span_end, right; + u_int end; if (line == NULL || width == 0) return; @@ -389,32 +442,9 @@ image_line_remove(struct image_line *line, u_int x, u_int width, if (span->placement->input != input) continue; } - span_end = span->x + span->sx; - if (span_end <= x || span->x >= end) - continue; - - /* Preserve pieces on either side of the removed cell range. */ - if (span->x < x && span_end > end) { - right = span_end - end; - span->sx = x - span->x; - image_span_add(line, span->placement, end, right, - span->source_x + end - span->x, span->source_y); - continue; - } - if (span->x < x) { - span->sx = x - span->x; - continue; - } - if (span_end > end) { - span->source_x += end - span->x; - span->sx = span_end - end; - span->x = end; - continue; - } - image_span_free(span); + image_span_remove(span, x, end); } } - /* Remove placement records which have no remaining spans. */ static void image_store_prune(struct image_store *store) @@ -512,6 +542,7 @@ image_grid_move_cells(struct grid *gd, u_int dx, u_int px, u_int py, moves[count].sx = span_end - start; moves[count].source_x = span->source_x + start - span->x; moves[count].source_y = span->source_y; + span->placement->recover_from = 0; count++; } @@ -532,7 +563,7 @@ image_copy_placement(struct image_copy_ctx *ctx, struct image_placement *source) { struct image_placement *placement; - size_t i; + u_int i; for (i = 0; i < ctx->count; i++) { if (ctx->maps[i].source == source) @@ -542,6 +573,8 @@ image_copy_placement(struct image_copy_ctx *ctx, source->input, source->app_image_id, source->app_placement_id, source->z); placement->placeholder = source->placeholder; + placement->x = source->x; + placement->recover_from = source->recover_from; ctx->maps = xreallocarray(ctx->maps, ctx->count + 1, sizeof *ctx->maps); ctx->maps[ctx->count].source = source; @@ -617,6 +650,7 @@ image_grid_copy_area(struct grid *dst, u_int destination_x, span_end = end; placement = image_copy_placement(&ctx, span->placement); + placement->recover_from = 0; image_span_add(destination_line, placement, destination_x + start - source_x, span_end - start, span->source_x + start - span->x, span->source_y); @@ -752,12 +786,12 @@ image_rect_get_coords(const struct image_rect *rectangle, u_int *source_x, u_int *source_y, u_int *width, u_int *height, u_int *destination_x, u_int *destination_y) { - *source_x = rectangle->source_x; - *source_y = rectangle->source_y; + *source_x = rectangle->src_x; + *source_y = rectangle->src_y; *width = rectangle->sx; *height = rectangle->sy; - *destination_x = rectangle->destination_x; - *destination_y = rectangle->destination_y; + *destination_x = rectangle->dst_x; + *destination_y = rectangle->dst_y; } /* Return the output z-index for a drawing rectangle. */ @@ -767,20 +801,19 @@ image_rect_get_z(const struct image_rect *rectangle) return (rectangle->z); } -/* Return one for a fallback cell, minus one to continue along an image line. */ +/* Get the image covering a grid cell and its position in that image. */ int -image_get_fallback_at(struct tty *tty, struct screen *s, u_int x, u_int y, - const struct grid_cell *gc, struct grid_cell *out) +image_grid_get_at(struct grid *gd, u_int x, u_int y, struct image **im, + u_int *src_x, u_int *src_y) { struct image_line *line; struct image_span *span, *found = NULL; struct image_placement *placement; + struct grid_cell gc; - if (image_backend_flags(tty) & IMAGE_BACKEND_GRAPHICAL || - y >= s->grid->sy) { + if (y >= gd->hsize + gd->sy) return (0); - } - line = s->grid->linedata[s->grid->hsize + y].images; + line = gd->linedata[y].images; if (line == NULL) return (0); TAILQ_FOREACH(span, &line->spans, line_entry) { @@ -791,22 +824,20 @@ image_get_fallback_at(struct tty *tty, struct screen *s, u_int x, u_int y, return (-1); placement = found->placement; if (placement->input == IMAGE_INPUT_KITTY && placement->z < 0) { - if (!kitty_cell_is_placeholder(gc)) { - if (gc->data.size != 1) - return (-1); - if (gc->data.data[0] != ' ') + grid_get_cell(gd, x, y, &gc); + if (!kitty_cell_is_placeholder(&gc)) { + if (gc.data.size != 1 || gc.data.data[0] != ' ') return (-1); } if (placement->z < IMAGE_Z_BELOW_BACKGROUND && - !COLOUR_DEFAULT(gc->bg)) { + !COLOUR_DEFAULT(gc.bg)) return (-1); - } } - fallback_get_cell(tty, placement->image, - found->source_x + x - found->x, found->source_y, gc, out); + *im = placement->image; + *src_x = found->source_x + x - found->x; + *src_y = found->source_y; return (1); } - /* Remove one placement and all of its spans. */ static void image_remove_placement(struct image_placement *placement) @@ -1007,22 +1038,22 @@ image_redraw_all(struct screen_write_ctx *ctx) screen_size_y(ctx->s)); } -/* Record image damage within the current scroll region. */ +/* Record window damage for images in the scroll region. */ void image_redraw_scroll(struct screen_write_ctx *ctx) { struct screen *s = ctx->s; struct window_pane *wp = ctx->wp; + u_int sx = screen_size_x(s); + u_int sy = s->rlower - s->rupper + 1; if (wp == NULL) return; - if (!image_grid_area_has_images(s->grid, 0, s->grid->hsize + s->rupper, - screen_size_x(s), s->rlower - s->rupper + 1)) { + if (!image_grid_area_has_images(s->grid, 0, + s->grid->hsize + s->rupper, sx, sy)) return; - } - redraw_damage_window_scroll(wp->window, wp->xoff, - wp->yoff + s->rupper, screen_size_x(s), - s->rlower - s->rupper + 1); + redraw_damage_scroll(wp->window, wp->xoff, + wp->yoff + s->rupper, sx, sy); } /* Draw a clipped part of one image span. */ @@ -1041,12 +1072,12 @@ image_draw_span(const struct image_backend *backend, struct tty *tty, rectangle.z = (int64_t)placement->z + 1; else rectangle.z = placement->z; - rectangle.source_x = span->source_x + start - span->x; - rectangle.source_y = span->source_y; + rectangle.src_x = span->source_x + start - span->x; + rectangle.src_y = span->source_y; rectangle.sx = end - start; rectangle.sy = 1; - rectangle.destination_x = atx + start - px; - rectangle.destination_y = aty; + rectangle.dst_x = atx + start - px; + rectangle.dst_y = aty; backend->draw_rect(tty, &rectangle); } @@ -1078,7 +1109,7 @@ image_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, image_tty_update(tty); backend = tty->image_backend; - if (~backend->flags & IMAGE_BACKEND_GRAPHICAL) + if (~backend->get_flags(tty) & IMAGE_BACKEND_GRAPHICAL) return; if (py >= s->grid->sy) return; @@ -1131,30 +1162,28 @@ image_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, } } -/* Return whether an image cell contains at least one nontransparent pixel. */ +/* Check whether an image cell has any visible pixels. */ static int -image_cell_has_alpha(struct image *im, u_int x, u_int y) +image_cell_has_visible_pixels(struct image *im, u_int x, u_int y) { u_int px, py, sx, sy, xx, yy; const u_char *pixels; size_t stride; - image_get_pixel_rect(im, x, y, 1, 1, &px, &py, &sx, &sy); + image_get_pixel_area(im, x, y, 1, 1, &px, &py, &sx, &sy); if (sx == 0 || sy == 0) return (0); pixels = image_get_pixels(im, &stride, NULL); for (yy = py; yy < py + sy; yy++) { for (xx = px; xx < px + sx; xx++) { - if (pixels[(size_t)yy * stride + - (size_t)xx * 4 + 3] != 0) { + if (pixels[yy * stride + xx * 4 + 3] != 0) return (1); - } } } return (0); } -/* Return whether a new cell's pixels completely cover the old cell. */ +/* Return whether a new cell's pixels completely cover an old cell. */ static int image_cell_covers(struct image *new, u_int nx, u_int ny, struct image *old, u_int ox, u_int oy) @@ -1168,20 +1197,17 @@ image_cell_covers(struct image *new, u_int nx, u_int ny, struct image *old, image_get_canvas_size(old, &ocw, &och); image_get_size_in_cells(new, &ncx, &ncy); image_get_size_in_cells(old, &ocx, &ocy); - if (ncw / ncx != ocw / ocx || nch / ncy != och / ocy) { + if (ncw / ncx != ocw / ocx || nch / ncy != och / ocy) return (0); - } - image_get_pixel_rect(new, nx, ny, 1, 1, &npx, &npy, &nsx, &nsy); - image_get_pixel_rect(old, ox, oy, 1, 1, &opx, &opy, &osx, &osy); + image_get_pixel_area(new, nx, ny, 1, 1, &npx, &npy, &nsx, &nsy); + image_get_pixel_area(old, ox, oy, 1, 1, &opx, &opy, &osx, &osy); npixels = image_get_pixels(new, &nstride, NULL); opixels = image_get_pixels(old, &ostride, NULL); for (yy = 0; yy < osy; yy++) { - op = opixels + (size_t)(opy + yy) * ostride + - (size_t)opx * 4; + op = opixels + (opy + yy) * ostride + opx * 4; if (yy < nsy) { - np = npixels + (size_t)(npy + yy) * nstride + - (size_t)npx * 4; + np = npixels + (npy + yy) * nstride + npx * 4; } for (xx = 0; xx < osx; xx++) { if (op[xx * 4 + 3] == 0) @@ -1224,91 +1250,63 @@ image_extend_row(struct image_line *line, struct image_placement *placement, u_int x, run; for (x = old_end; x < new_end; x += run) { - if (!image_cell_has_alpha(im, x, source_y)) { + if (!image_cell_has_visible_pixels(im, x, source_y)) { run = 1; continue; } for (run = 1; x + run < new_end; run++) { - if (!image_cell_has_alpha(im, x + run, source_y)) + if (!image_cell_has_visible_pixels(im, x + run, source_y)) break; } image_span_add(line, placement, cx + x, run, x, source_y); } } -/* Extend image placements to reveal clipped columns when a pane grows. */ +/* Extend image rows when grid is resized. */ void image_grid_resize_width(struct grid *gd, u_int new_sx) { - struct grid_line *gl; - struct image_line *line; + struct image_placement *plmt; struct image_span *span; - struct image_placement *placement; - struct image_placement **seen = NULL; - size_t nseen, i; - u_int row, cx, end_x, avail, image_sx, image_sy; - u_int source_y; - int found; + struct image_recover_row *rows; + u_int image_sx, image_sy, from, to; + u_int nrows, i; if (gd->images == NULL) return; - for (row = 0; row < gd->hsize + gd->sy; row++) { - gl = &gd->linedata[row]; - line = gl->images; - if (line == NULL) + TAILQ_FOREACH(plmt, &gd->images->placements, entry) { + from = plmt->recover_from; + if (from == 0 || plmt->x >= new_sx) + continue; + image_get_size_in_cells(plmt->image, &image_sx, &image_sy); + + to = new_sx - plmt->x; + if (to > image_sx) + to = image_sx; + if (to <= from) continue; - /* Collect placements before modifying their spans. */ - nseen = 0; - TAILQ_FOREACH(span, &line->spans, line_entry) { - found = 0; - for (i = 0; i < nseen; i++) { - if (seen[i] == span->placement) { - found = 1; + rows = NULL; + nrows = 0; + TAILQ_FOREACH(span, &plmt->spans, placement_entry) { + for (i = 0; i < nrows; i++) { + if (rows[i].line == span->line) break; - } - } - if (!found) { - seen = xreallocarray(seen, nseen + 1, - sizeof *seen); - seen[nseen++] = span->placement; } + if (i != nrows) + continue; + rows = xreallocarray(rows, nrows + 1, sizeof *rows); + rows[nrows].line = span->line; + rows[nrows].source_y = span->source_y; + nrows++; } - - for (i = 0; i < nseen; i++) { - placement = seen[i]; - if (placement->placeholder) - continue; - - /* Recover the origin and last visible source column. */ - cx = end_x = source_y = 0; - found = 0; - TAILQ_FOREACH(span, &line->spans, line_entry) { - if (span->placement != placement) - continue; - if (!found) { - cx = span->x - span->source_x; - source_y = span->source_y; - found = 1; - } - if (span->source_x + span->sx > end_x) - end_x = span->source_x + span->sx; - } - if (!found || cx >= new_sx) - continue; - - avail = new_sx - cx; - image_get_size_in_cells(placement->image, &image_sx, - &image_sy); - if (avail > image_sx) - avail = image_sx; - if (avail <= end_x) - continue; - image_extend_row(line, placement, cx, source_y, end_x, - avail); + for (i = 0; i < nrows; i++) { + image_extend_row(rows[i].line, plmt, plmt->x, + rows[i].source_y, from, to); } + free(rows); + plmt->recover_from = (to == image_sx ? 0 : to); } - free(seen); } /* Place an image at the cursor using the supplied input semantics. */ @@ -1324,9 +1322,10 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg, u_int cx = s->cx, cy = s->cy; u_int x, y, i, run, sx, sy, lines, origin_y = 0; u_int hist_origin_y, region_height, remaining, chunk; - u_int cursor_x = 0, cursor_rows; + u_int cursor_x = 0, cursor_rows, image_sx; image_get_size_in_cells(im, &sx, &sy); + image_sx = sx; if (sx > screen_size_x(s) - cx) sx = screen_size_x(s) - cx; if (sx == 0) @@ -1368,6 +1367,10 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg, placement = image_placement_create(gd, im, input, app_image_id, app_placement_id, z); + if (sx < image_sx) { + placement->x = cx; + placement->recover_from = sx; + } /* Attach scrolled-off image rows to the available history lines. */ hist_origin_y = origin_y; @@ -1384,12 +1387,12 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg, for (y = 0; y < sy; y++) { line = image_line_get(&gd->linedata[gd->hsize + cy + y]); for (x = 0; x < sx; x += run) { - if (!image_cell_has_alpha(im, x, origin_y + y)) { + if (!image_cell_has_visible_pixels(im, x, origin_y + y)) { run = 1; continue; } for (run = 1; x + run < sx; run++) { - if (!image_cell_has_alpha(im, x + run, + if (!image_cell_has_visible_pixels(im, x + run, origin_y + y)) { break; } diff --git a/image-kitty.c b/image-kitty.c index b942bf49e..77f4d2f33 100644 --- a/image-kitty.c +++ b/image-kitty.c @@ -26,6 +26,25 @@ #include "tmux.h" +/* Return whether the terminal supports Kitty graphics. */ +int +kitty_select(struct tty *tty) +{ + return (tty->term != NULL && + (tty->term->flags & TERM_KITTYGRAPHICS)); +} + +/* Get Kitty backend flags. */ +int +kitty_get_flags(struct tty *tty) +{ + int flags = IMAGE_BACKEND_GRAPHICAL|IMAGE_BACKEND_CLIPPED; + + if (tty_term_flag(tty->term, TTYC_ISCRL)) + flags |= IMAGE_BACKEND_SCROLLS; + return (flags); +} + #define KITTY_CHUNK_SIZE 3072 static const uint32_t kitty_diacritics[] = { diff --git a/image-sixel.c b/image-sixel.c index b0d73ff09..c82ea12fb 100644 --- a/image-sixel.c +++ b/image-sixel.c @@ -26,43 +26,54 @@ #include "tmux.h" +/* Maximum SIXEL width and height. */ #define SIXEL_WIDTH_LIMIT 10000 #define SIXEL_HEIGHT_LIMIT 10000 -#define SIXEL_PALETTE_SIZE 256 -#define SIXEL_HISTOGRAM_LEVELS 32 -#define SIXEL_HISTOGRAM_SIZE (SIXEL_HISTOGRAM_LEVELS * \ - SIXEL_HISTOGRAM_LEVELS * SIXEL_HISTOGRAM_LEVELS) +/* Number of colours in a generated SIXEL palette. */ +#define SIXEL_PALETTE_SIZE 256 + +/* Number of histogram levels for each RGB component. */ +#define SIXEL_HGRAM_LEVELS 32 + +/* Number of bins in the RGB histogram. */ +#define SIXEL_HGRAM_SIZE \ + (SIXEL_HGRAM_LEVELS * SIXEL_HGRAM_LEVELS * SIXEL_HGRAM_LEVELS) + +/* Line of a SIXEL image. */ struct sixel_line { - /* Number of pixel indexes allocated in this row. */ - u_int sx; + /* Number of pixels in this row. */ + u_int x; + /* Palette index for each pixel in the row. */ - uint16_t *pixels; + uint16_t *data; }; +/* SIXEL image. */ struct sixel_image { /* Decoded image dimensions in pixels. */ - u_int sx; - u_int sy; - /* Raster extent inside a padded output canvas, if different. */ - u_int raster_sx; - u_int raster_sy; + u_int x; + u_int y; + + /* Visible pixel size inside a padded output canvas. */ + u_int raster_x; + u_int raster_y; /* Terminal cell pixel dimensions used for scaling. */ - u_int cell_w; - u_int cell_h; + u_int xpixel; + u_int ypixel; - /* SIXEL raster attributes, if present. */ - u_int set_ra; - u_int ra_x; - u_int ra_y; - - /* SIXEL palette and the number of entries used by the image. */ + /* SIXEL palette. */ u_int *colours; u_int ncolours; u_int used_colours; - /* DCS parameters preserved when the image is emitted again. */ + /* SIXEL raster attributes. */ + u_int set_ra; + u_int ra_x; + u_int ra_y; + + /* DCS parameters. */ u_int p1; u_int p2; @@ -71,11 +82,12 @@ struct sixel_image { u_int dy; u_int dc; - /* Decoded rows of palette indexes. */ + /* Lines of palette indexes and the bytes allocated for them. */ struct sixel_line *lines; + size_t allocated; }; -/* One colour's encoded columns and controls in the current six-row band. */ +/* One colour's encoded columns in the current six-row band. */ struct sixel_plane { /* Position of the next encoded colour plane. */ u_int next_x; @@ -86,12 +98,30 @@ struct sixel_plane { char pattern; char next_pattern; - /* Output buffer and its allocation/used lengths. */ + /* Output buffer and its allocation and used lengths. */ size_t stream_len; size_t stream_used; char *stream; }; +/* Adjacent placement rows buffered for one SIXEL output sequence. */ +struct sixel_pending { + struct image *image; + + /* Source in image in cells. */ + u_int src_x; + u_int src_y; + + /* Size in cells. */ + u_int w; + u_int h; + + /* Destination on terminal in cells. */ + u_int dst_x; + u_int dst_y; +}; + +/* An indexed image cached for one terminal cell size. */ struct sixel_image_cache { /* Image and terminal geometry associated with this entry. */ u_int server_id; @@ -105,17 +135,7 @@ struct sixel_image_cache { struct sixel_image_cache *next; }; -/* Adjacent placement rows buffered for one SIXEL output sequence. */ -struct sixel_pending { - struct image *image; - u_int source_x; /* Origin in image cells. */ - u_int source_y; - u_int width; /* Size in cells. */ - u_int height; - u_int destination_x; /* Origin in terminal cells. */ - u_int destination_y; -}; - +/* Cached images and rows waiting to be sent to a terminal. */ struct sixel_output { /* Per-terminal cached images and aggregate cache state. */ struct sixel_image_cache *images; @@ -125,55 +145,73 @@ struct sixel_output { struct sixel_pending pending; }; +/* RGB histogram entry. */ struct sixel_hgram { - /* Number of pixels and accumulated RGB values in a colour bin. */ - u_int count; - uint64_t red; - uint64_t green; - uint64_t blue; + u_int count; + uint64_t r; + uint64_t g; + uint64_t b; }; +/* Bounds and population of one palette region. */ struct sixel_box { - /* RGB bounds and population of a quantization region. */ - u_int red_min; - u_int red_max; - u_int green_min; - u_int green_max; - u_int blue_min; - u_int blue_max; - u_int count; + u_int rmin; + u_int rmax; + u_int gmin; + u_int gmax; + u_int bmin; + u_int bmax; + u_int count; }; +/* One generated palette colour. */ struct sixel_rgb { - /* One RGB colour in the generated palette. */ - u_char red; - u_char green; - u_char blue; + u_char r; + u_char g; + u_char b; }; +/* Pixels and dimensions of the image being encoded. */ struct sixel_source { - /* Source pixel buffer and row stride. */ + /* Source pixels and number of bytes between rows. */ const u_char *pixels; size_t stride; - /* Source image and logical canvas dimensions. */ + + /* Source image and canvas sizes in pixels and size in cells. */ u_int width; u_int height; u_int canvas_width; u_int canvas_height; u_int sx; u_int sy; + + /* Destination canvas size in pixels. */ + u_int dst_w; + u_int dst_h; + + /* Output position and size in the destination canvas in pixels. */ + u_int dst_x; + u_int dst_y; + u_int out_w; + u_int out_h; }; /* Grow a SIXEL image to contain a line. */ static int sixel_parse_expand_lines(struct sixel_image *si, u_int y) { - if (y <= si->sy) - return (0); - if (y > SIXEL_HEIGHT_LIMIT) + u_int height = si->y; + + if (y > height) + height = y; + if (height > SIXEL_HEIGHT_LIMIT) return (1); - si->lines = xrecallocarray(si->lines, si->sy, y, sizeof *si->lines); - si->sy = y; + if (si->x * height > IMAGE_SIZE_LIMIT / 4) + return (1); + if (y <= si->y) + return (0); + si->lines = xrecallocarray(si->lines, si->y, y, sizeof *si->lines); + si->y = y; return (0); } @@ -181,15 +219,24 @@ sixel_parse_expand_lines(struct sixel_image *si, u_int y) static int sixel_parse_expand_line(struct sixel_image *si, struct sixel_line *sl, u_int x) { - if (x <= sl->sx) - return (0); - if (x > SIXEL_WIDTH_LIMIT) + u_int width = si->x; + size_t added; + + if (x > width) + width = x; + if (width > SIXEL_WIDTH_LIMIT) return (1); - if (x > si->sx) - si->sx = x; - sl->pixels = xrecallocarray(sl->pixels, sl->sx, si->sx, - sizeof *sl->pixels); - sl->sx = si->sx; + if (width * si->y > IMAGE_SIZE_LIMIT / 4) + return (1); + if (x <= sl->x) + return (0); + added = (width - sl->x) * sizeof *sl->data; + if (added > IMAGE_SIZE_LIMIT / 2 - si->allocated) + return (1); + si->x = width; + sl->data = xrecallocarray(sl->data, sl->x, si->x, sizeof *sl->data); + sl->x = si->x; + si->allocated += added; return (0); } @@ -199,12 +246,12 @@ sixel_get_pixel(struct sixel_image *si, u_int x, u_int y) { struct sixel_line *sl; - if (y >= si->sy) + if (y >= si->y) return (0); sl = &si->lines[y]; - if (x >= sl->sx) + if (x >= sl->x) return (0); - return (sl->pixels[x]); + return (sl->data[x]); } /* Set a SIXEL palette index at a pixel. */ @@ -218,11 +265,11 @@ sixel_set_pixel(struct sixel_image *si, u_int x, u_int y, u_int c) sl = &si->lines[y]; if (sixel_parse_expand_line(si, sl, x + 1) != 0) return (1); - sl->pixels[x] = c; + sl->data[x] = c; return (0); } -/* Write a SIXEL six-pixel column. */ +/* Write a SIXEL column. */ static int sixel_parse_write(struct sixel_image *si, u_int ch) { @@ -237,6 +284,7 @@ sixel_parse_write(struct sixel_image *si, u_int ch) return (0); } +/* Parse a number. */ static int sixel_parse_number(const char *cp, char **endptr, u_int *value) { @@ -252,6 +300,7 @@ sixel_parse_number(const char *cp, char **endptr, u_int *value) return (0); } +/* Parse SIXEL attributes. */ static const char * sixel_parse_attributes(struct sixel_image *si, const char *cp, const char *end) { @@ -259,7 +308,6 @@ sixel_parse_attributes(struct sixel_image *si, const char *cp, const char *end) char *endptr; u_int x, y; - /* Skip the aspect ratio, then read the optional raster dimensions. */ last = cp; while (last != end) { if (*last != ';' && (*last < '0' || *last > '9')) @@ -302,8 +350,11 @@ sixel_parse_attributes(struct sixel_image *si, const char *cp, const char *end) return (NULL); } - si->sx = x; - sixel_parse_expand_lines(si, y); + si->x = x; + if (sixel_parse_expand_lines(si, y) != 0) { + log_debug("%s: size limit reached", __func__); + return (NULL); + } si->set_ra = 1; si->ra_x = x; @@ -320,7 +371,6 @@ sixel_parse_colour(struct sixel_image *si, const char *cp, const char *end) char *endptr; u_int c, type, c1, c2, c3; - /* Select the register before reading an optional colour definition. */ last = cp; while (last != end) { if (*last != ';' && (*last < '0' || *last > '9')) @@ -375,7 +425,6 @@ sixel_parse_colour(struct sixel_image *si, const char *cp, const char *end) return (NULL); } - /* Validate HLS or RGB components before extending the palette. */ if ((type != 1 && type != 2) || (type == 1 && (c1 > 360 || c2 > 100 || c3 > 100)) || (type == 2 && (c1 > 100 || c2 > 100 || c3 > 100))) { @@ -427,7 +476,7 @@ sixel_parse_repeat(struct sixel_image *si, const char *cp, const char *end) ch = (*last++) - 0x3f; for (i = 0; i < n; i++) { if (sixel_parse_write(si, ch) != 0) { - log_debug("%s: width limit reached", __func__); + log_debug("%s: size limit reached", __func__); return (NULL); } si->dx++; @@ -437,8 +486,8 @@ sixel_parse_repeat(struct sixel_image *si, const char *cp, const char *end) /* Parse SIXEL data into an indexed image. */ struct sixel_image * -sixel_parse(const char *buf, size_t len, u_int p1, u_int p2, u_int cell_w, - u_int cell_h) +sixel_parse(const char *buf, size_t len, u_int p1, u_int p2, u_int xpixel, + u_int ypixel) { struct sixel_image *si; const char *cp = buf, *end = buf + len; @@ -450,12 +499,11 @@ sixel_parse(const char *buf, size_t len, u_int p1, u_int p2, u_int cell_w, } si = xcalloc(1, sizeof *si); - si->cell_w = cell_w; - si->cell_h = cell_h; + si->xpixel = xpixel; + si->ypixel = ypixel; si->p1 = p1; si->p2 = p2; - /* Decode controls and six-pixel columns into indexed rows. */ while (cp != end) { ch = *cp++; switch (ch) { @@ -485,7 +533,7 @@ sixel_parse(const char *buf, size_t len, u_int p1, u_int p2, u_int cell_w, if (ch < 0x3f || ch > 0x7e) break; if (sixel_parse_write(si, ch - 0x3f) != 0) { - log_debug("%s: width limit reached", __func__); + log_debug("%s: size limit reached", __func__); goto bad; } si->dx++; @@ -493,7 +541,7 @@ sixel_parse(const char *buf, size_t len, u_int p1, u_int p2, u_int cell_w, } } - if (si->sx == 0 || si->sy == 0) + if (si->x == 0 || si->y == 0) goto bad; return (si); @@ -502,32 +550,32 @@ bad: return (NULL); } -/* Free an indexed SIXEL image. */ +/* Free a SIXEL image. */ void sixel_free(struct sixel_image *si) { u_int y; - for (y = 0; y < si->sy; y++) - free(si->lines[y].pixels); + for (y = 0; y < si->y; y++) + free(si->lines[y].data); free(si->lines); free(si->colours); free(si); } -/* Return the cell dimensions occupied by a SIXEL image. */ +/* Get the size of a SIXEL image in cells. */ static void sixel_size_in_cells(struct sixel_image *si, u_int *x, u_int *y) { - if (si->cell_w == 0) - si->cell_w = 8; - if (si->cell_h == 0) - si->cell_h = 16; - image_size_in_cells(si->sx, si->sy, si->cell_w, si->cell_h, x, y); + if (si->xpixel == 0) + si->xpixel = 8; + if (si->ypixel == 0) + si->ypixel = 16; + image_size_in_cells(si->x, si->y, si->xpixel, si->ypixel, x, y); } -/* Write a SIXEL image to the debug log. */ +/* Log a SIXEL image. */ void sixel_log(struct sixel_image *si) { @@ -536,16 +584,16 @@ sixel_log(struct sixel_image *si) u_int i, x, y, cx, cy; sixel_size_in_cells(si, &cx, &cy); - log_debug("%s: image %ux%u (%ux%u)", __func__, si->sx, si->sy, cx, cy); + log_debug("%s: image %ux%u (%ux%u)", __func__, si->x, si->y, cx, cy); for (i = 0; i < si->ncolours; i++) log_debug("%s: colour %u is %07x", __func__, i, si->colours[i]); - for (y = 0; y < si->sy; y++) { + for (y = 0; y < si->y; y++) { sl = &si->lines[y]; - for (x = 0; x < si->sx; x++) { - if (x >= sl->sx) + for (x = 0; x < si->x; x++) { + if (x >= sl->x) s[x] = '_'; - else if (sl->pixels[x] != 0) - s[x] = '0' + (sl->pixels[x] - 1) % 10; + else if (sl->data[x] != 0) + s[x] = '0' + (sl->data[x] - 1) % 10; else s[x] = '.'; } @@ -562,26 +610,26 @@ sixel_hue(double p, double q, double t) t += 1; if (t > 1) t -= 1; - if (t < 1.0 / 6) + if (t < 1. / 6) return (p + (q - p) * 6 * t); - if (t < 1.0 / 2) + if (t < 1. / 2) return (q); - if (t < 2.0 / 3) - return (p + (q - p) * (2.0 / 3 - t) * 6); + if (t < 2. / 3) + return (p + (q - p) * (2. / 3 - t) * 6); return (p); } /* Convert a SIXEL colour register to RGB. */ static void -sixel_colour_to_rgb(u_int colour, u_char *r, u_char *g, u_char *b) +sixel_colour_to_rgb(u_int c, u_char *r, u_char *g, u_char *b) { - u_int type = colour >> 25; + u_int type = (c >> 25); double h, l, s, p, q; if (type == 2) { - *r = (((colour >> 16) & 0xff) * 255 + 50) / 100; - *g = (((colour >> 8) & 0xff) * 255 + 50) / 100; - *b = ((colour & 0xff) * 255 + 50) / 100; + *r = (((c >> 16) & 0xff) * 255 + 50) / 100; + *g = (((c >> 8) & 0xff) * 255 + 50) / 100; + *b = ((c & 0xff) * 255 + 50) / 100; return; } if (type != 1) { @@ -589,22 +637,23 @@ sixel_colour_to_rgb(u_int colour, u_char *r, u_char *g, u_char *b) return; } - h = ((colour >> 16) & 0x1ff) / 360.0; - l = ((colour >> 8) & 0xff) / 100.0; - s = (colour & 0xff) / 100.0; + h = ((c >> 16) & 0x1ff) / 360.; + l = ((c >> 8) & 0xff) / 100.; + s = (c & 0xff) / 100.; if (s == 0) { *r = *g = *b = l * 255 + 0.5; return; } q = l < 0.5 ? l * (1 + s) : l + s - l * s; p = 2 * l - q; + /* SIXEL HLS has blue at 0, red at 120 and green at 240 degrees. */ *r = sixel_hue(p, q, h) * 255 + 0.5; - *g = sixel_hue(p, q, h - 1.0 / 3) * 255 + 0.5; - *b = sixel_hue(p, q, h + 1.0 / 3) * 255 + 0.5; + *g = sixel_hue(p, q, h - 1. / 3) * 255 + 0.5; + *b = sixel_hue(p, q, h + 1. / 3) * 255 + 0.5; } -/* Convert decoded SIXEL data into the immutable image. */ +/* Convert a SIXEL image into the common RGBA format. */ struct image * sixel_to_image(struct sixel_image *si) { @@ -612,42 +661,40 @@ sixel_to_image(struct sixel_image *si) u_char *pixels, *pixel, r, g, b; u_int x, y, c, sx, sy; - if ((uint64_t)si->sx * si->sy > IMAGE_SIZE_LIMIT / 4) + if ((uint64_t)si->x * si->y > IMAGE_SIZE_LIMIT / 4) return (NULL); - /* Convert palette indexes to shared RGBA pixels. */ - pixels = xcalloc((size_t)si->sx * si->sy, 4); - for (y = 0; y < si->sy; y++) { - for (x = 0; x < si->sx; x++) { + sixel_size_in_cells(si, &sx, &sy); + if ((uint64_t)sx * si->xpixel > UINT_MAX || + (uint64_t)sy * si->ypixel > UINT_MAX) + return (NULL); + + pixels = xcalloc(si->x * si->y, 4); + for (y = 0; y < si->y; y++) { + for (x = 0; x < si->x; x++) { + pixel = pixels + (y * si->x + x) * 4; + c = sixel_get_pixel(si, x, y); - pixel = pixels + ((size_t)y * si->sx + x) * 4; if (c == 0) { - pixel[3] = si->p2 == 1 ? 0 : 255; + pixel[3] = (si->p2 == 1 ? 0 : 255); continue; } - c--; - if (c < si->ncolours) + if (--c < si->ncolours) sixel_colour_to_rgb(si->colours[c], &r, &g, &b); else r = g = b = 0; + pixel[0] = r; pixel[1] = g; pixel[2] = b; pixel[3] = 255; } } - /* Keep transparent cell padding in the logical canvas. */ - sixel_size_in_cells(si, &sx, &sy); - if ((uint64_t)sx * si->cell_w > UINT_MAX || - (uint64_t)sy * si->cell_h > UINT_MAX) { - free(pixels); - return (NULL); - } - im = image_create(si->sx, si->sy, sx * si->cell_w, sy * si->cell_h, - sx, sy, pixels); + im = image_create(si->x, si->y, sx * si->xpixel, sy * si->ypixel, sx, + sy, pixels); if (im == NULL) free(pixels); - else + if (im != NULL) image_set_sixel(im, si); return (im); } @@ -676,21 +723,21 @@ sixel_scale(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int ox, sy = cy - oy; if (cell_w == 0) - cell_w = si->cell_w; + cell_w = si->xpixel; if (cell_h == 0) - cell_h = si->cell_h; - raster_sx = (si->raster_sx != 0 ? si->raster_sx : si->sx); - raster_sy = (si->raster_sy != 0 ? si->raster_sy : si->sy); - if (raster_sx > si->sx) - raster_sx = si->sx; - if (raster_sy > si->sy) - raster_sy = si->sy; + cell_h = si->ypixel; + raster_sx = (si->raster_x != 0 ? si->raster_x : si->x); + raster_sy = (si->raster_y != 0 ? si->raster_y : si->y); + if (raster_sx > si->x) + raster_sx = si->x; + if (raster_sy > si->y) + raster_sy = si->y; /* Map whole cells at pixel boundaries and clip the final cell. */ - source_left = (uint64_t)ox * si->cell_w; - source_right = (uint64_t)(ox + sx) * si->cell_w; - source_top = (uint64_t)oy * si->cell_h; - source_bottom = (uint64_t)(oy + sy) * si->cell_h; + source_left = (uint64_t)ox * si->xpixel; + source_right = (uint64_t)(ox + sx) * si->xpixel; + source_top = (uint64_t)oy * si->ypixel; + source_bottom = (uint64_t)(oy + sy) * si->ypixel; if (source_right > raster_sx) source_right = raster_sx; if (source_bottom > raster_sy) @@ -703,10 +750,10 @@ sixel_scale(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int ox, psy = source_bottom - source_top; /* Preserve the pixel extent of a partial final cell. */ - target_right = ((uint64_t)raster_sx * cell_w + si->cell_w - 1) / - si->cell_w; - target_bottom = ((uint64_t)raster_sy * cell_h + si->cell_h - 1) / - si->cell_h; + target_right = ((uint64_t)raster_sx * cell_w + si->xpixel - 1) / + si->xpixel; + target_bottom = ((uint64_t)raster_sy * cell_h + si->ypixel - 1) / + si->ypixel; if (target_right > UINT_MAX || target_bottom > UINT_MAX) return (NULL); target_left = (uint64_t)ox * cell_w; @@ -722,10 +769,13 @@ sixel_scale(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int ox, if (tsx == 0 || tsy == 0) return (NULL); + if ((uint64_t)tsx * tsy > IMAGE_SIZE_LIMIT / 4) + return (NULL); + /* Resample the crop and preserve its palette and raster attributes. */ new = xcalloc(1, sizeof *si); - new->cell_w = cell_w; - new->cell_h = cell_h; + new->xpixel = cell_w; + new->ypixel = cell_h; new->p1 = si->p1; new->p2 = si->p2; @@ -739,7 +789,10 @@ sixel_scale(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int ox, py = poy + ((double)y * psy / tsy); for (x = 0; x < tsx; x++) { px = pox + ((double)x * psx / tsx); - sixel_set_pixel(new, x, y, sixel_get_pixel(si, px, py)); + if (sixel_set_pixel(new, x, y, sixel_get_pixel(si, px, py)) != 0) { + sixel_free(new); + return (NULL); + } } } /* Keep transparent edges in the scaled raster canvas as well. */ @@ -747,7 +800,7 @@ sixel_scale(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int ox, sixel_free(new); return (NULL); } - new->sx = tsx; + new->x = tsx; if (colours && si->ncolours != 0) { new->colours = xmalloc(si->ncolours * sizeof *new->colours); @@ -767,36 +820,26 @@ sixel_fit(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int cells_x, uint64_t canvas_width, canvas_height; u_int width, height, x, y, px, py, i; - if (si->cell_w == 0) - si->cell_w = 8; - if (si->cell_h == 0) - si->cell_h = 16; + if (si->xpixel == 0) + si->xpixel = 8; + if (si->ypixel == 0) + si->ypixel = 16; if (cell_w == 0) - cell_w = si->cell_w; + cell_w = si->xpixel; if (cell_h == 0) - cell_h = si->cell_h; + cell_h = si->ypixel; canvas_width = (uint64_t)cells_x * cell_w; canvas_height = (uint64_t)cells_y * cell_h; - if (si->sx == 0 || si->sy == 0 || canvas_width == 0 || + if (si->x == 0 || si->y == 0 || canvas_width == 0 || canvas_height == 0 || canvas_width > SIXEL_WIDTH_LIMIT || canvas_height > SIXEL_HEIGHT_LIMIT) { return (NULL); } - /* Use one scale factor to preserve the raster's aspect ratio. */ - if ((uint64_t)cell_w * si->cell_h <= - (uint64_t)cell_h * si->cell_w) { - width = ((uint64_t)si->sx * cell_w + si->cell_w / 2) / - si->cell_w; - height = ((uint64_t)si->sy * cell_w + si->cell_w / 2) / - si->cell_w; - } else { - width = ((uint64_t)si->sx * cell_h + si->cell_h / 2) / - si->cell_h; - height = ((uint64_t)si->sy * cell_h + si->cell_h / 2) / - si->cell_h; - } + /* Keep the same cell footprint as the RGBA output path. */ + width = ((uint64_t)si->x * cell_w + si->xpixel - 1) / si->xpixel; + height = ((uint64_t)si->y * cell_h + si->ypixel - 1) / si->ypixel; if (width == 0) width = 1; if (height == 0) @@ -806,24 +849,30 @@ sixel_fit(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int cells_x, if (height > canvas_height) height = canvas_height; + if (canvas_width * canvas_height > IMAGE_SIZE_LIMIT / 4) + return (NULL); + /* Resample the raster and pad the unused cell canvas. */ new = xcalloc(1, sizeof *new); - new->cell_w = cell_w; - new->cell_h = cell_h; + new->xpixel = cell_w; + new->ypixel = cell_h; new->p1 = si->p1; new->p2 = si->p2; new->set_ra = 1; new->ra_x = width; new->ra_y = height; - new->raster_sx = width; - new->raster_sy = height; + new->raster_x = width; + new->raster_y = height; new->used_colours = si->used_colours; for (y = 0; y < height; y++) { - py = (uint64_t)y * si->sy / height; + py = (uint64_t)y * si->y / height; for (x = 0; x < width; x++) { - px = (uint64_t)x * si->sx / width; - sixel_set_pixel(new, x, y, sixel_get_pixel(si, px, py)); + px = (uint64_t)x * si->x / width; + if (sixel_set_pixel(new, x, y, sixel_get_pixel(si, px, py)) != 0) { + sixel_free(new); + return (NULL); + } } } /* Keep the unused part of the cell canvas as blank padding. */ @@ -831,7 +880,7 @@ sixel_fit(struct sixel_image *si, u_int cell_w, u_int cell_h, u_int cells_x, sixel_free(new); return (NULL); } - new->sx = canvas_width; + new->x = canvas_width; if (si->ncolours != 0) { new->colours = xmalloc(si->ncolours * sizeof *new->colours); @@ -886,15 +935,15 @@ sixel_print_compress_colors(struct sixel_image *si, struct sixel_plane *planes, struct sixel_plane *plane = NULL; struct sixel_line *sl; - for (x = 0; x < si->sx; x++) { + for (x = 0; x < si->x; x++) { /* Collect this column's six-bit pattern for each colour. */ for (i = 0; i < 6; i++) { pixels[i] = 0; - if (y + i < si->sy) { + if (y + i < si->y) { sl = &si->lines[y + i]; - if (x < sl->sx && sl->pixels[x] != 0) { - pixels[i] = sl->pixels[x]; - c = sl->pixels[x] - 1; + if (x < sl->x && sl->data[x] != 0) { + pixels[i] = sl->data[x]; + c = sl->data[x] - 1; planes[c].next_pattern |= 1 << i; } } @@ -919,8 +968,7 @@ sixel_print_compress_colors(struct sixel_image *si, struct sixel_plane *planes, if (plane->pattern != plane->next_pattern || dx != 0) { sixel_print_repeat(&plane->stream, &plane->stream_len, &plane->stream_used, - plane->count, - plane->pattern + 0x3f); + plane->count, plane->pattern + 0x3f); sixel_print_repeat(&plane->stream, &plane->stream_len, &plane->stream_used, dx, '?'); @@ -936,22 +984,14 @@ sixel_print_compress_colors(struct sixel_image *si, struct sixel_plane *planes, /* Encode an indexed SIXEL image for terminal output. */ static char * -sixel_print(struct sixel_image *si, struct sixel_image *map, size_t *size) +sixel_print(struct sixel_image *si, size_t *size) { char *buf, tmp[64]; size_t len, used = 0, tmplen; - u_int *colours, ncolours, used_colours, i, c, y; + u_int used_colours, i, c, y; u_int *active, nactive; struct sixel_plane *planes, *plane; - if (map != NULL) { - colours = map->colours; - ncolours = map->ncolours; - } else { - colours = si->colours; - ncolours = si->ncolours; - } - used_colours = si->used_colours; if (used_colours == 0) return (NULL); @@ -973,8 +1013,12 @@ sixel_print(struct sixel_image *si, struct sixel_image *map, size_t *size) active = xcalloc(used_colours, sizeof *active); /* Emit the palette before the encoded colour planes. */ - for (i = 0; i < ncolours; i++) { - c = colours[i]; + for (i = 0; i < used_colours; i++) { + c = 0; + if (i < si->ncolours) + c = si->colours[i]; + if (c >> 25 == 0) + c = 2U << 25; tmplen = xsnprintf(tmp, sizeof tmp, "#%u;%u;%u;%u;%u", i, c >> 25, (c >> 16) & 0x1ff, (c >> 8) & 0xff, c & 0xff); sixel_print_add(&buf, &len, &used, tmp, tmplen); @@ -987,7 +1031,7 @@ sixel_print(struct sixel_image *si, struct sixel_image *map, size_t *size) } /* Encode each six-row band using only its active colours. */ - for (y = 0; y < si->sy; y += 6) { + for (y = 0; y < si->y; y += 6) { nactive = 0; sixel_print_compress_colors(si, planes, y, active, &nactive); @@ -1030,46 +1074,43 @@ static void sixel_box_update(struct sixel_box *box, struct sixel_hgram *hg) { struct sixel_hgram *entry; - u_int red, green, blue, index; - u_int red_min = SIXEL_HISTOGRAM_LEVELS; - u_int green_min = SIXEL_HISTOGRAM_LEVELS; - u_int blue_min = SIXEL_HISTOGRAM_LEVELS; - u_int red_max = 0, green_max = 0, blue_max = 0; + u_int r, g, b, rmax = 0, gmax = 0, bmax = 0; + u_int rmin = SIXEL_HGRAM_LEVELS; + u_int gmin = SIXEL_HGRAM_LEVELS; + u_int bmin = SIXEL_HGRAM_LEVELS; u_int count = 0; - for (red = box->red_min; red <= box->red_max; red++) { - for (green = box->green_min; green <= box->green_max; green++) { - for (blue = box->blue_min; blue <= box->blue_max; - blue++) { - index = (red << 10)|(green << 5)|blue; - entry = &hg[index]; - if (entry->count == 0) - continue; - if (red < red_min) - red_min = red; - if (red > red_max) - red_max = red; - if (green < green_min) - green_min = green; - if (green > green_max) - green_max = green; - if (blue < blue_min) - blue_min = blue; - if (blue > blue_max) - blue_max = blue; - count += entry->count; + for (r = box->rmin; r <= box->rmax; r++) { + for (g = box->gmin; g <= box->gmax; g++) { + for (b = box->bmin; b <= box->bmax; b++) { + entry = &hg[(r << 10)|(g << 5)|b]; + if (entry->count != 0) { + if (r < rmin) + rmin = r; + if (r > rmax) + rmax = r; + if (g < gmin) + gmin = g; + if (g > gmax) + gmax = g; + if (b < bmin) + bmin = b; + if (b > bmax) + bmax = b; + count += entry->count; + } } } } box->count = count; - if (count == 0) - return; - box->red_min = red_min; - box->red_max = red_max; - box->green_min = green_min; - box->green_max = green_max; - box->blue_min = blue_min; - box->blue_max = blue_max; + if (count != 0) { + box->rmin = rmin; + box->rmax = rmax; + box->gmin = gmin; + box->gmax = gmax; + box->bmin = bmin; + box->bmax = bmax; + } } /* Split a histogram box at its weighted median. */ @@ -1077,66 +1118,66 @@ static int sixel_box_split(struct sixel_box *box, struct sixel_box *new, struct sixel_hgram *hg) { - u_int levels[SIXEL_HISTOGRAM_LEVELS] = { 0 }; - u_int red, green, blue, index, channel, first, last, level; - u_int red_range, green_range, blue_range, count = 0; + u_int levels[SIXEL_HGRAM_LEVELS] = { 0 }; + u_int r, g, b, index, channel, first, last, level; + u_int rrange, grange, brange, count = 0; /* Split along the channel with the widest occupied range. */ - red_range = box->red_max - box->red_min; - green_range = box->green_max - box->green_min; - blue_range = box->blue_max - box->blue_min; - if (red_range == 0 && green_range == 0 && blue_range == 0) + rrange = box->rmax - box->rmin; + grange = box->gmax - box->gmin; + brange = box->bmax - box->bmin; + if (rrange == 0 && grange == 0 && brange == 0) return (0); - if (green_range >= red_range && green_range >= blue_range) + if (grange >= rrange && grange >= brange) channel = 1; - else if (red_range >= blue_range) + else if (rrange >= brange) channel = 0; else channel = 2; - - for (red = box->red_min; red <= box->red_max; red++) { - for (green = box->green_min; green <= box->green_max; green++) { - for (blue = box->blue_min; blue <= box->blue_max; - blue++) { - index = (red << 10)|(green << 5)|blue; + for (r = box->rmin; r <= box->rmax; r++) { + for (g = box->gmin; g <= box->gmax; g++) { + for (b = box->bmin; b <= box->bmax; b++) { + index = (r << 10)|(g << 5)|b; if (channel == 0) - levels[red] += hg[index].count; + levels[r] += hg[index].count; else if (channel == 1) - levels[green] += hg[index].count; + levels[g] += hg[index].count; else - levels[blue] += hg[index].count; + levels[b] += hg[index].count; } } } if (channel == 0) { - first = box->red_min; - last = box->red_max; + first = box->rmin; + last = box->rmax; } else if (channel == 1) { - first = box->green_min; - last = box->green_max; + first = box->gmin; + last = box->gmax; } else { - first = box->blue_min; - last = box->blue_max; + first = box->bmin; + last = box->bmax; } + /* Find the weighted median without emptying either resulting box. */ for (level = first; level < last; level++) { count += levels[level]; if (count >= box->count / 2) break; } + /* Keep the maximum occupied level in the new box. */ if (level == last) level--; memcpy(new, box, sizeof *new); if (channel == 0) { - box->red_max = level; - new->red_min = level + 1; + box->rmax = level; + new->rmin = level + 1; } else if (channel == 1) { - box->green_max = level; - new->green_min = level + 1; + box->gmax = level; + new->gmin = level + 1; } else { - box->blue_max = level; - new->blue_min = level + 1; + box->bmax = level; + new->bmin = level + 1; } sixel_box_update(box, hg); sixel_box_update(new, hg); @@ -1145,85 +1186,82 @@ sixel_box_split(struct sixel_box *box, struct sixel_box *new, /* Build an adaptive palette from an RGB histogram. */ static u_int -sixel_make_palette(struct sixel_hgram *hg, - struct sixel_rgb *palette) +sixel_make_palette(struct sixel_hgram *hg, struct sixel_rgb *palette) { struct sixel_box boxes[SIXEL_PALETTE_SIZE], new; struct sixel_box *box; - uint64_t best_score, score, red, green, blue, count; - u_int i, nboxes = 1, best, r, g, b, index; - u_int red_range, green_range, blue_range; + uint64_t best_score, score, rsum, gsum, bsum, count; + u_int i, nbox = 1, best, r, g, b, index; + u_int rrange, grange, brange; + /* Initialize the first box. */ memset(&boxes[0], 0, sizeof boxes[0]); - boxes[0].red_max = boxes[0].green_max = boxes[0].blue_max = - SIXEL_HISTOGRAM_LEVELS - 1; + boxes[0].rmax = boxes[0].gmax = boxes[0].bmax = SIXEL_HGRAM_LEVELS - 1; sixel_box_update(&boxes[0], hg); if (boxes[0].count == 0) return (0); /* Repeatedly split the most populated and varied colour region. */ - while (nboxes < SIXEL_PALETTE_SIZE) { - best = nboxes; + while (nbox < SIXEL_PALETTE_SIZE) { + best = nbox; best_score = 0; - for (i = 0; i < nboxes; i++) { + for (i = 0; i < nbox; i++) { box = &boxes[i]; - red_range = box->red_max - box->red_min; - green_range = box->green_max - box->green_min; - blue_range = box->blue_max - box->blue_min; - score = (uint64_t)box->count * - (red_range * red_range + green_range * green_range + - blue_range * blue_range); + rrange = box->rmax - box->rmin; + grange = box->gmax - box->gmin; + brange = box->bmax - box->bmin; + score = (uint64_t)box->count * (rrange * rrange + + grange * grange + brange * brange); if (score > best_score) { best = i; best_score = score; } } - if (best == nboxes || - !sixel_box_split(&boxes[best], &new, hg)) { + if (best == nbox || !sixel_box_split(&boxes[best], &new, hg)) break; - } - memcpy(&boxes[nboxes++], &new, sizeof new); + memcpy(&boxes[nbox++], &new, sizeof new); } /* Average each region's colours into one palette entry. */ - for (i = 0; i < nboxes; i++) { + for (i = 0; i < nbox; i++) { box = &boxes[i]; - red = green = blue = count = 0; - for (r = box->red_min; r <= box->red_max; r++) { - for (g = box->green_min; g <= box->green_max; g++) { - for (b = box->blue_min; b <= box->blue_max; - b++) { + + rsum = gsum = bsum = count = 0; + for (r = box->rmin; r <= box->rmax; r++) { + for (g = box->gmin; g <= box->gmax; g++) { + for (b = box->bmin; b <= box->bmax; b++) { index = (r << 10)|(g << 5)|b; - red += hg[index].red; - green += hg[index].green; - blue += hg[index].blue; + rsum += hg[index].r; + gsum += hg[index].g; + bsum += hg[index].b; count += hg[index].count; } } } - palette[i].red = (red + count / 2) / count; - palette[i].green = (green + count / 2) / count; - palette[i].blue = (blue + count / 2) / count; + palette[i].r = (rsum + count / 2) / count; + palette[i].g = (gsum + count / 2) / count; + palette[i].b = (bsum + count / 2) / count; } - return (nboxes); + + return (nbox); } /* Find the closest adaptive palette entry for an RGB colour. */ static u_int sixel_nearest_colour(struct sixel_rgb *palette, u_int ncolours, - uint16_t *cache, u_int red, u_int green, u_int blue) + uint16_t *cache, u_int r, u_int g, u_int b) { uint64_t distance, best_distance = UINT64_MAX; int dr, dg, db; u_int i, best = 0, index; - index = ((red >> 3) << 10)|((green >> 3) << 5)|(blue >> 3); + index = ((r >> 3) << 10)|((g >> 3) << 5)|(b >> 3); if (cache[index] != UINT16_MAX) return (cache[index]); for (i = 0; i < ncolours; i++) { - dr = (int)red - palette[i].red; - dg = (int)green - palette[i].green; - db = (int)blue - palette[i].blue; + dr = (int)r - palette[i].r; + dg = (int)g - palette[i].g; + db = (int)b - palette[i].b; distance = 3ULL * dr * dr + 6ULL * dg * dg + db * db; if (distance < best_distance) { best = i; @@ -1247,63 +1285,126 @@ sixel_clamp_colour(int colour) /* Return a source pixel mapped to an output SIXEL pixel. */ static const u_char * -sixel_from_image_pixel(const struct sixel_source *source, u_int source_x, - u_int source_y, u_int source_width, u_int source_height, u_int output_sx, - u_int output_sy, u_int x, u_int y) +sixel_from_image_pixel(const struct sixel_source *src, u_int x, u_int y) { - u_int sourcex, sourcey; + u_int px, py; - sourcex = source_x + (uint64_t)x * source_width / output_sx; - sourcey = source_y + (uint64_t)y * source_height / output_sy; - if (sourcex >= source->width) - sourcex = source->width - 1; - if (sourcey >= source->height) - sourcey = source->height - 1; - return (source->pixels + sourcey * source->stride + sourcex * 4); + px = ((uint64_t)src->dst_x + x) * src->canvas_width / src->dst_w; + py = ((uint64_t)src->dst_y + y) * src->canvas_height / src->dst_h; + return (src->pixels + py * src->stride + px * 4); } -/* Render an image rectangle as an indexed SIXEL image. */ +/* Make a palette from the visible pixels in the output area. */ +static u_int +sixel_make_image_palette(struct sixel_source *src, struct sixel_rgb *palette) +{ + struct sixel_hgram *hg, *entry; + const u_char *pixel; + u_int x, y, index, ncolours; + + hg = xcalloc(SIXEL_HGRAM_SIZE, sizeof *hg); + for (y = 0; y < src->out_h; y++) { + for (x = 0; x < src->out_w; x++) { + pixel = sixel_from_image_pixel(src, x, y); + if (pixel[3] == 0) + continue; + + index = ((pixel[0] >> 3) << 10)| ((pixel[1] >> 3) << 5)| + (pixel[2] >> 3); + entry = &hg[index]; + entry->count++; + entry->r += pixel[0]; + entry->g += pixel[1]; + entry->b += pixel[2]; + } + } + ncolours = sixel_make_palette(hg, palette); + free(hg); + return (ncolours); +} + +/* Choose an indexed colour for a pixel and diffuse it. */ +static u_int +sixel_dither_pixel(struct sixel_rgb *palette, u_int ncolours, uint16_t *cache, + const u_char *pixel, int *current, int *next) +{ + int alphaerr, rerr, gerr, berr; + u_int alpha, r, g, b, c; + + /* Dither alpha to SIXEL's binary transparency. */ + alpha = sixel_clamp_colour((int)pixel[3] + current[3] / 16); + alphaerr = alpha; + if (alpha >= 128) + alphaerr -= 255; + current[7] += alphaerr * 7; + next[-1] += alphaerr * 3; + next[3] += alphaerr * 5; + next[7] += alphaerr; + if (alpha < 128) + return (0); + + /* Get the RGB colours. */ + r = sixel_clamp_colour((int)pixel[0] + current[0] / 16); + g = sixel_clamp_colour((int)pixel[1] + current[1] / 16); + b = sixel_clamp_colour((int)pixel[2] + current[2] / 16); + c = sixel_nearest_colour(palette, ncolours, cache, r, g, b); + + /* Find the colour error. */ + rerr = (int)r - palette[c].r; + gerr = (int)g - palette[c].g; + berr = (int)b - palette[c].b; + + /* Diffuse error with the 7:3:5:1 kernel. */ + current[4] += rerr * 7; + current[5] += gerr * 7; + current[6] += berr * 7; + next[-4] += rerr * 3; + next[-3] += gerr * 3; + next[-2] += berr * 3; + next[0] += rerr * 5; + next[1] += gerr * 5; + next[2] += berr * 5; + next[4] += rerr; + next[5] += gerr; + next[6] += berr; + return (c + 1); +} + +/* Make an indexed SIXEL image from part of an image. */ static struct sixel_image * -sixel_from_image(struct image *im, u_int cell_x, u_int cell_y, u_int cells_x, - u_int cells_y, u_int cell_w, u_int cell_h) +sixel_make_image(struct image *im, u_int cx, u_int cy, u_int csx, u_int csy, + u_int cw, u_int ch) { struct sixel_image *si; - struct sixel_hgram *hg, *entry; struct sixel_rgb palette[SIXEL_PALETTE_SIZE]; - struct sixel_source source; + struct sixel_source src; const u_char *pixel; uint16_t *cache; int *current, *next, *tmp; - int red_error, green_error, blue_error; - int alpha_error; - u_int x, y, sx, sy, index, error_index; - u_int source_x, source_y; - u_int source_width, source_height; - u_int red, green, blue, alpha, colour, i, ncolours; - uint64_t destination_width, destination_height; - uint64_t content_width, content_height; + u_int x, y, sx, sy, error_index; + u_int r, g, b, c, i, nc; + uint64_t dst_w, dst_h, content_width, content_height; uint64_t left, right, top, bottom; /* Work out the requested cell crop in destination pixel coordinates. */ - source.pixels = image_get_pixels(im, &source.stride, NULL); - image_get_size(im, &source.width, &source.height); - image_get_canvas_size(im, &source.canvas_width, - &source.canvas_height); - image_get_size_in_cells(im, &source.sx, &source.sy); - destination_width = (uint64_t)source.sx * cell_w; - destination_height = (uint64_t)source.sy * cell_h; - if (destination_width > UINT_MAX || destination_height > UINT_MAX) + src.pixels = image_get_pixels(im, &src.stride, NULL); + image_get_size(im, &src.width, &src.height); + image_get_canvas_size(im, &src.canvas_width, &src.canvas_height); + image_get_size_in_cells(im, &src.sx, &src.sy); + dst_w = (uint64_t)src.sx * cw; + dst_h = (uint64_t)src.sy * ch; + if (dst_w > UINT_MAX || dst_h > UINT_MAX) return (NULL); - content_width = ((uint64_t)source.width * destination_width + - source.canvas_width - 1) / source.canvas_width; - content_height = ((uint64_t)source.height * destination_height + - source.canvas_height - 1) / source.canvas_height; + content_width = ((uint64_t)src.width * dst_w + + src.canvas_width - 1) / src.canvas_width; + content_height = ((uint64_t)src.height * dst_h + + src.canvas_height - 1) / src.canvas_height; /* Clip the requested cell rectangle to output pixel bounds. */ - left = (uint64_t)cell_x * cell_w; - top = (uint64_t)cell_y * cell_h; - right = ((uint64_t)cell_x + cells_x) * cell_w; - bottom = ((uint64_t)cell_y + cells_y) * cell_h; + left = (uint64_t)cx * cw; + top = (uint64_t)cy * ch; + right = ((uint64_t)cx + csx) * cw; + bottom = ((uint64_t)cy + csy) * ch; if (right > content_width) right = content_width; if (bottom > content_height) @@ -1314,121 +1415,65 @@ sixel_from_image(struct image *im, u_int cell_x, u_int cell_y, u_int cells_x, /* The clipped output bounds determine the SIXEL image dimensions. */ sx = right - left; sy = bottom - top; - if (sx == 0 || sy == 0 || sx > SIXEL_WIDTH_LIMIT || + if (sx == 0 || + sy == 0 || + sx > SIXEL_WIDTH_LIMIT || sy > SIXEL_HEIGHT_LIMIT) { return (NULL); } - - /* Map the requested cell crop to the source image's pixel rectangle. */ - image_get_pixel_rect(im, cell_x, cell_y, cells_x, cells_y, &source_x, - &source_y, &source_width, &source_height); - if (source_width == 0 || source_height == 0) + if (sx * sy > IMAGE_SIZE_LIMIT / 4) return (NULL); - /* Build an adaptive palette from the visible nontransparent pixels. */ - hg = xcalloc(SIXEL_HISTOGRAM_SIZE, sizeof *hg); - for (y = 0; y < sy; y++) { - for (x = 0; x < sx; x++) { - pixel = sixel_from_image_pixel(&source, source_x, - source_y, source_width, source_height, sx, sy, - x, y); - if (pixel[3] == 0) - continue; + /* Map output pixels using their position in the full canvas. */ + src.dst_w = dst_w; + src.dst_h = dst_h; + src.dst_x = left; + src.dst_y = top; + src.out_w = sx; + src.out_h = sy; - /* Count visible pixels in their RGB histogram bin. */ - index = ((pixel[0] >> 3) << 10)| - ((pixel[1] >> 3) << 5)|(pixel[2] >> 3); - entry = &hg[index]; - entry->count++; - entry->red += pixel[0]; - entry->green += pixel[1]; - entry->blue += pixel[2]; - } - } - ncolours = sixel_make_palette(hg, palette); - free(hg); - if (ncolours == 0) + /* Build an adaptive palette from the visible nontransparent pixels. */ + nc = sixel_make_image_palette(&src, palette); + if (nc == 0) return (NULL); /* Create the indexed image with a SIXEL RGB palette. */ si = xcalloc(1, sizeof *si); - si->cell_w = cell_w; - si->cell_h = cell_h; + si->xpixel = cw; + si->ypixel = ch; si->p1 = 9; si->p2 = 1; si->set_ra = 1; si->ra_x = sx; si->ra_y = sy; - si->ncolours = si->used_colours = ncolours; + si->ncolours = si->used_colours = nc; si->colours = xcalloc(si->ncolours, sizeof *si->colours); for (i = 0; i < si->ncolours; i++) { - red = (palette[i].red * 100 + 127) / 255; - green = (palette[i].green * 100 + 127) / 255; - blue = (palette[i].blue * 100 + 127) / 255; - si->colours[i] = (2U << 25)|(red << 16)|(green << 8)|blue; + r = (palette[i].r * 100 + 127) / 255; + g = (palette[i].g * 100 + 127) / 255; + b = (palette[i].b * 100 + 127) / 255; + si->colours[i] = (2U << 25)|(r << 16)|(g << 8)|b; } - /* Floyd-Steinberg dither colour and alpha into the indexed image. */ - cache = xmalloc(SIXEL_HISTOGRAM_SIZE * sizeof *cache); - memset(cache, 0xff, SIXEL_HISTOGRAM_SIZE * sizeof *cache); + /* Dither colour and alpha into the indexed image. */ + cache = xmalloc(SIXEL_HGRAM_SIZE * sizeof *cache); + memset(cache, 0xff, SIXEL_HGRAM_SIZE * sizeof *cache); current = xcalloc(((size_t)sx + 2) * 4, sizeof *current); next = xcalloc(((size_t)sx + 2) * 4, sizeof *next); for (y = 0; y < sy; y++) { for (x = 0; x < sx; x++) { - pixel = sixel_from_image_pixel(&source, source_x, - source_y, source_width, source_height, sx, sy, - x, y); + pixel = sixel_from_image_pixel(&src, x, y); error_index = (x + 1) * 4; - - /* Dither alpha to SIXEL's binary transparency. */ - alpha = sixel_clamp_colour((int)pixel[3] + - current[error_index + 3] / 16); - alpha_error = (int)alpha; - if (alpha >= 128) { - alpha_error -= 255; - red = sixel_clamp_colour((int)pixel[0] + - current[error_index] / 16); - green = sixel_clamp_colour((int)pixel[1] + - current[error_index + 1] / 16); - blue = sixel_clamp_colour((int)pixel[2] + - current[error_index + 2] / 16); - colour = sixel_nearest_colour(palette, ncolours, - cache, red, green, blue); - if (sixel_set_pixel(si, x, y, colour + 1) != 0) - goto fail; - - /* Find the colour error. */ - red_error = (int)red - palette[colour].red; - green_error = (int)green - - palette[colour].green; - blue_error = (int)blue - palette[colour].blue; - - /* Diffuse error with the 7:3:5:1 kernel. */ - current[error_index + 4] += red_error * 7; - current[error_index + 5] += green_error * 7; - current[error_index + 6] += blue_error * 7; - next[error_index - 4] += red_error * 3; - next[error_index - 3] += green_error * 3; - next[error_index - 2] += blue_error * 3; - next[error_index] += red_error * 5; - next[error_index + 1] += green_error * 5; - next[error_index + 2] += blue_error * 5; - next[error_index + 4] += red_error; - next[error_index + 5] += green_error; - next[error_index + 6] += blue_error; - } - /* Diffuse alpha independently using the same kernel. */ - current[error_index + 7] += alpha_error * 7; - next[error_index - 1] += alpha_error * 3; - next[error_index + 3] += alpha_error * 5; - next[error_index + 7] += alpha_error; + c = sixel_dither_pixel(palette, nc, cache, pixel, + current + error_index, next + error_index); + if (c != 0 && sixel_set_pixel(si, x, y, c) != 0) + goto fail; } + /* Advance to the next output row's accumulated error. */ tmp = current; current = next; next = tmp; - - /* Reuse the old row buffer to accumulate the row after that. */ memset(next, 0, ((size_t)sx + 2) * 4 * sizeof *next); } @@ -1438,7 +1483,6 @@ sixel_from_image(struct image *im, u_int cell_x, u_int cell_y, u_int cells_x, return (si); fail: - /* Discard the partially built image after a size failure. */ free(current); free(next); free(cache); @@ -1446,6 +1490,29 @@ fail: return (NULL); } +/* Return whether the terminal supports SIXEL. */ +int +sixel_select(struct tty *tty) +{ + if (tty->term != NULL && + (tty->term->flags & TERM_SIXEL) && + tty->xpixel != 0 && + tty->ypixel != 0) + return (1); + return (0); +} + +/* Get SIXEL backend flags. */ +int +sixel_get_flags(struct tty *tty) +{ + int flags = IMAGE_BACKEND_GRAPHICAL; + + if (tty_term_flag(tty->term, TTYC_ISCRL)) + flags |= IMAGE_BACKEND_SCROLLS; + return (flags); +} + /* Return the SIXEL output cache for a terminal. */ static struct sixel_output * sixel_get_output(struct tty *tty) @@ -1465,9 +1532,9 @@ sixel_image_size(struct sixel_image *si) { uint64_t size; - if ((uint64_t)si->sx * si->sy > SIZE_MAX / sizeof(uint16_t)) + if ((uint64_t)si->x * si->y > SIZE_MAX / sizeof(uint16_t)) return (0); - size = (uint64_t)si->sx * si->sy * sizeof(uint16_t); + size = (uint64_t)si->x * si->y * sizeof(uint16_t); if ((uint64_t)si->ncolours * sizeof *si->colours > SIZE_MAX - size) return (0); size += (uint64_t)si->ncolours * sizeof *si->colours; @@ -1518,6 +1585,8 @@ sixel_free_output(struct tty *tty, __unused int send) sixel_free(cache->si); free(cache); } + if (so->pending.image != NULL) + image_free(so->pending.image); free(so); tty->image_data = NULL; } @@ -1535,7 +1604,7 @@ sixel_render_image(struct image *im, u_int cell_w, u_int cell_h) if (original != NULL) si = sixel_fit(original, cell_w, cell_h, sx, sy); else - si = sixel_from_image(im, 0, 0, sx, sy, cell_w, cell_h); + si = sixel_make_image(im, 0, 0, sx, sy, cell_w, cell_h); return (si); } @@ -1614,6 +1683,7 @@ sixel_flush_output(struct tty *tty) struct sixel_output *so = tty->image_data; struct sixel_pending *sp; struct sixel_image *si, *crop; + struct image *im; char *data; size_t size; @@ -1621,23 +1691,25 @@ sixel_flush_output(struct tty *tty) return; sp = &so->pending; - si = sixel_get_image(tty, sp->image); + im = sp->image; + si = sixel_get_image(tty, im); sp->image = NULL; + image_free(im); if (si == NULL) return; - crop = sixel_scale(si, tty->xpixel, tty->ypixel, sp->source_x, - sp->source_y, sp->width, sp->height, 1); + crop = sixel_scale(si, tty->xpixel, tty->ypixel, sp->src_x, + sp->src_y, sp->w, sp->h, 1); if (!sixel_image_is_cached(tty, si)) sixel_free(si); if (crop == NULL) return; - data = sixel_print(crop, NULL, &size); + data = sixel_print(crop, &size); sixel_free(crop); if (data == NULL) return; tty_region_off(tty); tty_margin_off(tty); - tty_cursor(tty, sp->destination_x, sp->destination_y); + tty_cursor(tty, sp->dst_x, sp->dst_y); tty->flags |= TTY_NOBLOCK; tty_putn(tty, data, size, 0); /* SIXEL moves the cursor, but does not change terminal attributes. */ @@ -1659,24 +1731,25 @@ sixel_draw_rect(struct tty *tty, const struct image_rect *rectangle) &height, &destination_x, &destination_y); if (sp->image == im && - sp->source_x == source_x && - sp->width == width && - sp->destination_x == destination_x && - sp->source_y + sp->height == source_y && - sp->destination_y + sp->height == destination_y) { - sp->height += height; + sp->src_x == source_x && + sp->w == width && + sp->dst_x == destination_x && + sp->src_y + sp->h == source_y && + sp->dst_y + sp->h == destination_y) { + sp->h += height; return; } /* Flush a completed run before queuing a different rectangle. */ + image_ref(im); sixel_flush_output(tty); sp->image = im; - sp->source_x = source_x; - sp->source_y = source_y; - sp->width = width; - sp->height = height; - sp->destination_x = destination_x; - sp->destination_y = destination_y; + sp->src_x = source_x; + sp->src_y = source_y; + sp->w = width; + sp->h = height; + sp->dst_x = destination_x; + sp->dst_y = destination_y; } /* Remove old SIXEL pixels before replaying a dirty image area. */ diff --git a/image.c b/image.c index b8d6eb68f..6e93c42fb 100644 --- a/image.c +++ b/image.c @@ -61,8 +61,6 @@ struct image { size_t size; u_char *pixels; struct sixel_image *sixel; - struct image_cell *cells; - void *fallback_data; RB_ENTRY(image) entry; }; @@ -92,7 +90,7 @@ image_find(u_int id) return (RB_FIND(images, &images, &find)); } -/* Return the ID of an image. */ +/* Get the ID of an image. */ u_int image_get_id(struct image *im) { @@ -145,7 +143,7 @@ image_create(u_int w, u_int h, u_int cw, u_int ch, u_int sx, u_int sy, return (NULL); if (sx > USHRT_MAX || sy > USHRT_MAX) return (NULL); - if ((uint64_t)sx * sy > IMAGE_SIZE_LIMIT / sizeof(struct image_cell)) + if ((uint64_t)sx * sy > IMAGE_SIZE_LIMIT) return (NULL); im = image_alloc(w, h, cw, ch, sx, sy, w * 4, pixels); @@ -204,8 +202,7 @@ image_create_view(struct image *src, struct image_view *view) view->height > src->height - view->y || view->sx > USHRT_MAX || view->sy > USHRT_MAX || - (uint64_t)view->sx * view->sy > - IMAGE_SIZE_LIMIT / sizeof(struct image_cell) || + (uint64_t)view->sx * view->sy > IMAGE_SIZE_LIMIT || view->canvas_xoff > view->canvas_width || view->scaled_width > view->canvas_width - view->canvas_xoff || view->canvas_yoff > view->canvas_height || @@ -260,13 +257,11 @@ image_free(struct image *im) image_free(im->parent); if (im->sixel != NULL) sixel_free(im->sixel); - fallback_free(im); - free(im->cells); free(im); } } -/* Return image size in pixels. */ +/* Get image size in pixels. */ void image_get_size(struct image *im, u_int *w, u_int *h) { @@ -274,7 +269,7 @@ image_get_size(struct image *im, u_int *w, u_int *h) *h = im->height; } -/* Return canvas size in pixels. */ +/* Get canvas size in pixels. */ void image_get_canvas_size(struct image *im, u_int *w, u_int *h) { @@ -282,7 +277,7 @@ image_get_canvas_size(struct image *im, u_int *w, u_int *h) *h = im->canvas_height; } -/* Return image size in cells. */ +/* Get image size in cells. */ void image_get_size_in_cells(struct image *im, u_int *sx, u_int *sy) { @@ -290,7 +285,7 @@ image_get_size_in_cells(struct image *im, u_int *sx, u_int *sy) *sy = im->sy; } -/* Return image pixels. */ +/* Get image pixels. */ const u_char * image_get_pixels(struct image *im, size_t *stride, size_t *size) { @@ -314,85 +309,42 @@ image_size_in_cells(u_int w, u_int h, u_int xpixel, u_int ypixel, u_int *sx, *sy = h / ypixel + (h % ypixel != 0); } -/* Average a rectangle of image pixels into one fallback sample. */ -static void -image_sample(struct image *im, uint64_t sample_x, uint64_t sample_y, - uint64_t sample_columns, uint64_t sample_rows, struct image_sample *sample) +/* Find area covered by a rectangle of cells in pixels. */ +void +image_get_pixel_area(struct image *im, u_int x, u_int y, u_int w, u_int h, + u_int *px, u_int *py, u_int *pw, u_int *ph) { - const u_char *pixel; - uint64_t red = 0, green = 0, blue = 0, alpha = 0; - uint64_t brightness = 0, count = 0; - u_int x, y, x0, x1, y0, y1; + u_int left, top, right, bottom; - /* Map sample bounds to pixels, counting transparent canvas padding. */ - x0 = sample_x * im->canvas_width / sample_columns; - x1 = ((sample_x + 1) * im->canvas_width + sample_columns - 1) / - sample_columns; - y0 = sample_y * im->canvas_height / sample_rows; - y1 = ((sample_y + 1) * im->canvas_height + sample_rows - 1) / - sample_rows; - if (x1 <= x0) - x1 = x0 + 1; - if (y1 <= y0) - y1 = y0 + 1; - count = (uint64_t)(x1 - x0) * (y1 - y0); - if (x0 >= im->width || y0 >= im->height) + *px = *py = *pw = *ph = 0; + + if (im == NULL || x >= im->sx || y >= im->sy || w == 0 || h == 0) return; - if (x1 > im->width) - x1 = im->width; - if (y1 > im->height) - y1 = im->height; + if (w > im->sx - x) + w = im->sx - x; + if (h > im->sy - y) + h = im->sy - y; - /* Average premultiplied colour and brightness over the sample area. */ - for (y = y0; y < y1; y++) { - for (x = x0; x < x1; x++) { - pixel = im->pixels + y * im->stride + x * 4; - red += pixel[0] * pixel[3] / 255; - green += pixel[1] * pixel[3] / 255; - blue += pixel[2] * pixel[3] / 255; - alpha += pixel[3]; - brightness += ((2126ULL * pixel[0] + - 7152ULL * pixel[1] + 722ULL * pixel[2]) / 10000) * - pixel[3] / 255; - } - } - if (count == 0) - return; - sample->red = red / count; - sample->green = green / count; - sample->blue = blue / count; - sample->alpha = alpha / count; - sample->brightness = brightness / count; -} + left = (uint64_t)x * im->canvas_width / im->sx; + top = (uint64_t)y * im->canvas_height / im->sy; + right = ((uint64_t)(x + w) * im->canvas_width + im->sx - 1) / im->sx; + bottom = ((uint64_t)(y + h) * im->canvas_height + im->sy - 1) / im->sy; -/* Build fallback samples for every image cell. */ -static void -image_make_cells(struct image *im) -{ - struct image_cell *cell; - uint64_t columns, rows, column, row; - u_int x, y, sample_x, sample_y; - - columns = (uint64_t)im->sx * IMAGE_SAMPLE_COLUMNS; - rows = (uint64_t)im->sy * IMAGE_SAMPLE_ROWS; - im->cells = xcalloc((size_t)im->sx * im->sy, sizeof *im->cells); - for (y = 0; y < im->sy; y++) { - for (x = 0; x < im->sx; x++) { - cell = &im->cells[(size_t)y * im->sx + x]; - image_sample(im, x, y, im->sx, im->sy, &cell->whole); - column = (uint64_t)x * IMAGE_SAMPLE_COLUMNS; - row = (uint64_t)y * IMAGE_SAMPLE_ROWS; - for (sample_y = 0; sample_y < IMAGE_SAMPLE_ROWS; - sample_y++) { - for (sample_x = 0; - sample_x < IMAGE_SAMPLE_COLUMNS; - sample_x++) { - image_sample(im, column + sample_x, - row + sample_y, columns, rows, - &cell->samples[sample_y][sample_x]); - } - } - } + if (left >= im->width || top >= im->height) + *px = *py = 0; + else { + if (right <= left) + right = left + 1; + if (bottom <= top) + bottom = top + 1; + if (right > im->width) + right = im->width; + if (bottom > im->height) + bottom = im->height; + *px = left; + *py = top; + *pw = right - left; + *ph = bottom - top; } } @@ -403,6 +355,13 @@ image_set_no_cursor(struct image *im) im->flags |= IMAGE_FLAG_NO_CURSOR; } +/* Return whether writing the image should leave the cursor in place. */ +int +image_has_no_cursor(struct image *im) +{ + return (!!(im->flags & IMAGE_FLAG_NO_CURSOR)); +} + /* Return an image's original SIXEL data. */ struct sixel_image * image_get_sixel(struct image *im) @@ -438,80 +397,6 @@ image_set_order_id(struct image *im, u_int id) im->order_id = id; } -/* Return whether writing the image should leave the cursor in place. */ -int -image_has_no_cursor(struct image *im) -{ - return (!!(im->flags & IMAGE_FLAG_NO_CURSOR)); -} - -/* Return cached fallback output data. */ -void * -image_get_fallback_data(struct image *im) -{ - return (im->fallback_data); -} - -/* Store cached fallback output data. */ -void -image_set_fallback_data(struct image *im, void *data) -{ - im->fallback_data = data; -} - -/* Return a precomputed fallback cell sample. */ -const struct image_cell * -image_get_cell(struct image *im, u_int x, u_int y) -{ - if (im == NULL || x >= im->sx || y >= im->sy) - return (NULL); - if (im->cells == NULL) - image_make_cells(im); - return (&im->cells[(size_t)y * im->sx + x]); -} - -/* Convert an image cell rectangle to pixel coordinates. */ -void -image_get_pixel_rect(struct image *im, u_int x, u_int y, - u_int width, u_int height, u_int *px, u_int *py, u_int *pwidth, - u_int *pheight) -{ - u_int left, top, right, bottom; - - *px = *py = *pwidth = *pheight = 0; - if (im == NULL || x >= im->sx || y >= im->sy || width == 0 || - height == 0) { - return; - } - if (width > im->sx - x) - width = im->sx - x; - if (height > im->sy - y) - height = im->sy - y; - - left = (uint64_t)x * im->canvas_width / im->sx; - top = (uint64_t)y * im->canvas_height / im->sy; - right = ((uint64_t)(x + width) * im->canvas_width + im->sx - 1) / - im->sx; - bottom = ((uint64_t)(y + height) * im->canvas_height + im->sy - 1) / - im->sy; - if (left >= im->width || top >= im->height) { - *px = *py = 0; - return; - } - if (right <= left) - right = left + 1; - if (bottom <= top) - bottom = top + 1; - if (right > im->width) - right = im->width; - if (bottom > im->height) - bottom = im->height; - *px = left; - *py = top; - *pwidth = right - left; - *pheight = bottom - top; -} - /* Decode a base64 payload with a size limit. */ u_char * image_base64_decode(const char *data, size_t len, size_t limit, size_t *size) diff --git a/regress/Makefile b/regress/Makefile index 18518d274..a4bcfc7a4 100644 --- a/regress/Makefile +++ b/regress/Makefile @@ -32,13 +32,18 @@ $(TESTS): prepare @base="$@"; base=$${base##*/}; \ log="$(LOGDIR)/$${base%.sh}.log"; \ start=$$(date +%s); \ - if env -i LC_CTYPE=C.UTF-8 MallocNanoZone=0 \ - sh -x "$@" >"$$log" 2>&1; then \ - end=$$(date +%s); \ + env -i LC_CTYPE=C.UTF-8 MallocNanoZone=0 \ + sh -x "$@" >"$$log" 2>&1; \ + result=$$?; \ + end=$$(date +%s); \ + case $$result in \ + 0) \ rm -f "$$log"; \ - printf '%-40s PASS (%ss)\n' "$@" "$$((end - start))"; \ - else \ - end=$$(date +%s); \ + printf '%-40s PASS (%ss)\n' "$@" "$$((end - start))" ;; \ + 101) \ + rm -f "$$log"; \ + printf '%-40s SKIP (%ss)\n' "$@" "$$((end - start))" ;; \ + *) \ printf '%-40s FAIL (%ss)\n log: %s\n' \ - "$@" "$$((end - start))" "$$log"; \ - fi + "$@" "$$((end - start))" "$$log" ;; \ + esac diff --git a/regress/image-border-status-sixel.sh b/regress/image-border-status-sixel.sh index bf5698e57..3a290f8d7 100755 --- a/regress/image-border-status-sixel.sh +++ b/regress/image-border-status-sixel.sh @@ -4,6 +4,8 @@ # pane-border-status text. Exercise the same queued new; newp; send-keys path # used by the reported reproduction, then repeat it with two tiled panes. +. ./image-common.inc + PATH=/bin:/usr/bin TERM=screen LC_ALL=C.UTF-8 @@ -38,7 +40,7 @@ missing_titles= trap "cleanup; rm -f $TMP" 0 1 15 $TMUX new-session -d -s inner -x 100 -y 30 sh || fail "new session failed" -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX $TMUX set -g status off $TMUX set -g pane-border-status top $TMUX set -g pane-border-format '#{pane_title}' diff --git a/regress/image-border-status-wipe.sh b/regress/image-border-status-wipe.sh index 1a2290ec9..d18f73a5b 100644 --- a/regress/image-border-status-wipe.sh +++ b/regress/image-border-status-wipe.sh @@ -3,6 +3,8 @@ # A tiled pane's border title must reappear after a floating pane moves away. # Start the drag below row zero so its initial press hits the floating pane. +. ./image-common.inc + PATH=/bin:/usr/bin TERM=screen LC_ALL=C.UTF-8 @@ -69,6 +71,7 @@ TMP=$(mktemp) trap "cleanup; rm -f $TMP" 0 1 15 $TMUX new-session -d -s inner -x 60 -y 20 'sh -c "sleep 100"' || exit 1 +image_check_support $TMUX $TMUX set -g mouse on $TMUX set -g pane-border-status top $TMUX set -g default-command 'sh -c "sleep 100"' @@ -116,7 +119,6 @@ while [ "$row" != "$FTOP" ]; do waypoints="$waypoints $row" done drag $((GRABCOL + 1)) $waypoints -drag $((GRABCOL + 1)) 1 $((GRABCOL + 1)) "$FTOP" $TMUX2 capturep -p -t "$OUTER" >$TMP || fail "capture failed" diff --git a/regress/image-common.inc b/regress/image-common.inc new file mode 100644 index 000000000..cd1524df4 --- /dev/null +++ b/regress/image-common.inc @@ -0,0 +1,10 @@ +# Check server image support. Exit 101 to tell the runner to skip this test. +image_check_support() +{ + support=$("$@" display-message -p '#{image_support}') || exit 1 + case "$support" in + 1) return ;; + 0) exit 101 ;; + *) echo "unexpected image_support: $support" >&2; exit 1 ;; + esac +} diff --git a/regress/image-fallback.sh b/regress/image-fallback.sh new file mode 100755 index 000000000..71ad37d7c --- /dev/null +++ b/regress/image-fallback.sh @@ -0,0 +1,108 @@ +#!/bin/sh + +# SIXEL input is visible as text on ASCII and UTF-8 clients, including +# copy-mode selection and a graphical client with no known cell pixel size. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +python3 -B - "$TEST_TMUX" <<'PY' +import re +import sys +from image_test import image_test, sixel +from image_terminal import Terminal + +white = sixel(16, 16, [(100, 100, 100)], [0] * (16 * 16)) + +for utf8 in (False, True): + with image_test(sys.argv[1]) as test: + client = test.attach(graphical=False, utf8=utf8) + char = b'\xe2\x96\x84' if utf8 else b'@' + test.emit(white) + assert not client.images(), 'graphical output on a fallback client' + assert client.data.count(char) >= 2, (utf8, bytes(client.data)) + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + test.run('send-keys', '-X', 'start-of-line') + test.run('send-keys', '-X', 'begin-selection') + test.run('send-keys', '-X', 'cursor-right') + test.redraw() + assert not client.images() + assert client.data.count(char) >= 2, ('selection lost fallback', utf8) + test.run('send-keys', '-X', 'cancel') + test.emit(b'\033[2J\033[H') + assert not client.images() + # Clearing must also remove the image from later full redraws. + test.redraw() + assert char not in client.data, ('cleared fallback reappeared', utf8) + +with image_test(sys.argv[1]) as test: + client = test.attach(graphical=True, utf8=False, cw=0, ch=0) + test.emit(white) + assert not client.images(), 'SIXEL sent without a cell pixel size' + # Default pixel sizes may leave padding, so the brightness character + # need not be '@'. Remove terminal controls before checking the text. + text = re.sub(rb'\033\[[0-?]*[ -/]*[@-~]', b'', client.data) + text = re.sub(rb'\033\][^\007]*?(?:\007|\033\\)', b'', text) + text = re.sub(rb'\033.', b'', text) + assert any(bytes([ch]) in text for ch in b'.:-=+*#%@'), ( + 'missing fallback with unknown pixel size', text) + +# Every ASCII brightness character is reachable, in the right grid cell. +levels = [0, 12, 23, 34, 45, 56, 67, 78, 89, 100] +with image_test(sys.argv[1]) as test: + client = test.attach(graphical=False, utf8=False) + test.emit(sixel(80, 16, [(v, v, v) for v in levels], + [x // 8 for y in range(16) for x in range(80)])) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + actual = ''.join(terminal.cells[x, 0][0] for x in range(10)) + assert actual == ' .:-=+*#%@', actual + +# The lower-half character uses the upper sample as background and the +# lower sample as foreground. Check true colour and 256-colour mapping. +for truecolour in (False, True): + with image_test(sys.argv[1]) as test: + if truecolour: + test.run('set-option', '-as', 'terminal-features', + 'screen-256color:RGB') + client = test.attach(graphical=False) + test.emit(b'\033[3;4H' + sixel(8, 16, + [(100, 0, 0), (0, 0, 100)], + [y >= 8 for y in range(16) for x in range(8)])) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + char, fg, bg, bold = terminal.cells[3, 2] + assert char == '\u2584' and not bold, (char, fg, bg, bold) + if truecolour: + expected = ((0, 0, 255), (255, 0, 0)) + else: + expected = (21, 196) + assert (fg, bg) == expected, (truecolour, fg, bg) + +# Eight-colour UTF-8 terminals use shades, with bold supplying extra levels +# unless Nobr disables it. Force the capabilities without extra terminfo files. +for nobr in (False, True): + with image_test(sys.argv[1]) as test: + overrides = 'screen:colors=8:Nobr=' if nobr else 'screen:colors=8:Nobr@' + test.run('set-option', '-as', 'terminal-overrides', overrides) + client = test.attach(graphical=False, term='screen') + levels = [0, 14, 29, 43, 57, 71, 86, 100] + test.emit(sixel(64, 16, [(v, v, v) for v in levels], + [x // 8 for y in range(16) for x in range(64)])) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + chars = ''.join(terminal.cells[x, 0][0] for x in range(8)) + bold = [terminal.cells[x, 0][3] for x in range(8)] + if nobr: + assert chars == ' \u2591\u2591\u2592\u2592\u2593\u2593\u2588', chars + assert not any(bold), bold + else: + assert chars == ' \u2591\u2591\u2592\u2592\u2593\u2588\u2588', chars + assert bold == [False, False, True, False, True, False, False, True], bold +PY diff --git a/regress/image-feature-redraw.sh b/regress/image-feature-redraw.sh new file mode 100755 index 000000000..d834d6db3 --- /dev/null +++ b/regress/image-feature-redraw.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +# Terminal replies which change no features must not replay our images. +# A reply which adds a feature must still cause a redraw. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +python3 -B - "$TEST_TMUX" <<'PY' +import os +import sys +from image_test import image_test, sixel, check_image + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(sixel(8, 16, [(100, 0, 0)], [0] * (8 * 16))) + assert len(client.images()) == 1 + client.clear() + # The primary DA was answered during attach. This secondary DA adds + # nothing, but still calls tty_update_features(). + os.write(client.fd, b'\033[>0;1;0c') + client.read(0.5) + assert not client.images(), 'unchanged features replayed the image' + features = test.run('display-message', '-p', '#{client_termfeatures}') + assert 'sync' not in features.split(','), features + os.write(client.fd, b'\033[?2026;1$y') + test.wait(lambda: 'sync' in test.run('display-message', '-p', + '#{client_termfeatures}').split(',')) + client.read(0.2) + assert len(client.images()) == 1, 'new terminal feature did not redraw' + check_image(client.images()[0], 8, 16, [(255, 0, 0)] * (8 * 16)) +PY diff --git a/regress/image-floating-content-wipe.sh b/regress/image-floating-content-wipe.sh index 0f5b2a89e..ab18d39f5 100644 --- a/regress/image-floating-content-wipe.sh +++ b/regress/image-floating-content-wipe.sh @@ -3,6 +3,8 @@ # A SIXEL pane redraw must erase only cells owned by that pane, preserving # content in an overlapping floating pane when leaving copy mode. +. ./image-common.inc + PATH=/bin:/usr/bin TERM=screen LC_ALL=C.UTF-8 @@ -21,7 +23,7 @@ $TMUX2 new-session -d -x 60 -y 20 "sh -c 'i=0; while [ \$i -lt 30 ]; do printf \ || exit 1 $TMUX2 new-pane -x20 -y6 -X8 -Y3 -T FLOATTITLE "sh -c 'printf FLOATCONTENT; exec sleep 100'" \ || exit 1 -[ "$($TMUX2 display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX2 # The attaching client must be detected as SIXEL-capable - this bug does # not reproduce on a fallback or Kitty client. diff --git a/regress/image-grid-operations.sh b/regress/image-grid-operations.sh new file mode 100755 index 000000000..6491c8363 --- /dev/null +++ b/regress/image-grid-operations.sh @@ -0,0 +1,125 @@ +#!/bin/sh + +# Check image positions and erases, line operations, scroll regions, +# history cleanup and reflow next to wrapped text. + +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 +from image_terminal import Terminal + +palette = [(x * 30, y * 40, 100) for y in range(3) for x in range(4)] +rgb = [tuple((c * 255 + 50) // 100 for c in colour) for colour in palette] +wire = sixel(32, 48, palette, + [y // 16 * 4 + x // 8 for y in range(48) for x in range(32)]) +cells = {(x + 2, y + 2): rgb[y * 4 + x] + for y in range(3) for x in range(4)} + +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)} + +def snapshot(test, client, expected): + """Check a full redraw independently of previous terminal contents.""" + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(pixels(expected)) + return terminal + +cases = [ + ('erase characters', b'\033[3;4H\033[2X', + {(x, y): c for (x, y), c in cells.items() if y != 2 or x not in (3, 4)}), + ('erase line end', b'\033[4;5H\033[K', + {(x, y): c for (x, y), c in cells.items() if y != 3 or x < 4}), + ('erase line start', b'\033[4;4H\033[1K', + {(x, y): c for (x, y), c in cells.items() if y != 3 or x > 3}), + ('erase whole line', b'\033[4;4H\033[2K', + {(x, y): c for (x, y), c in cells.items() if y != 3}), + ('erase display end', b'\033[4;5H\033[J', + {(x, y): c for (x, y), c in cells.items() if y < 3 or y == 3 and x < 4}), + ('erase display start', b'\033[4;5H\033[1J', + {(x, y): c for (x, y), c in cells.items() if y > 3 or y == 3 and x > 4}), + ('erase whole display', b'\033[2J', {}), + ('insert line', b'\033[4;1H\033[L', + {(x, y + (y >= 3)): c for (x, y), c in cells.items()}), + ('delete line', b'\033[4;1H\033[M', + {(x, y - (y > 3)): c for (x, y), c in cells.items() if y != 3}), + ('scroll region up', b'\033[3;6r\033[S', + {(x, y - 1): c for (x, y), c in cells.items() if y > 2}), + ('scroll region down', b'\033[3;6r\033[T', + {(x, y + 1): c for (x, y), c in cells.items()}), + ('reverse index', b'\033[3;6r\033[3;1H\033M', + {(x, y + 1): c for (x, y), c in cells.items()}), +] +with image_test(sys.argv[1]) as test: + client = test.attach() + for name, operation, expected in cases: + test.emit(b'\033[r\033[2J\033[3;3H' + wire) + terminal = snapshot(test, client, cells) + test.emit(operation) + if name.startswith('erase'): + # Check actual erase commands, before a full refresh can hide a bug. + terminal.feed(client.data) + terminal.check(pixels(expected)) + try: + snapshot(test, client, expected) + except AssertionError as e: + raise AssertionError(name) from e + +# Image lines stay whole while the surrounding text reflows. +with image_test(sys.argv[1], sx=16, sy=20) as test: + client = test.attach() + text = b'abcdefghijklmnopqrstuvwx' + small = sixel(32, 32, palette, + [y // 16 * 4 + x // 8 for y in range(32) for x in range(32)]) + test.emit(text + b'\r\n' + small + text + b'\r\n') + for width in (8, 16, 8, 16): + test.resize(client, width, 20) + test.redraw() + terminal = Terminal(sx=width, sy=20) + terminal.feed(client.data) + # Reflow can move the leading text into history to retain the cursor. + top = (len(text) + width - 1) // width - int(test.run( + 'display-message', '-p', '#{history_size}')) + assert top >= 0, top + expected = {(x, y + top): rgb[y * 4 + x] + for y in range(2) for x in range(4)} + terminal.check(pixels(expected)) + captured = test.run('capture-pane', '-p', '-S', '-').replace('\n', '') + assert captured == text.decode() * 2, captured + +# Evicted or explicitly cleared history must not leave images in copy mode. +for evict in (False, True): + with image_test(sys.argv[1], sy=6) as test: + if evict: + test.run('set-option', '-g', 'history-limit', '2') + test.run('new-window', '-k', '-t', 'image:0', + "stty raw -echo; printf '\033]2;READY2\007'; exec cat") + test.wait(lambda: test.run('display-message', '-p', + '#{pane_title}') == 'READY2') + client = test.attach() + test.emit(wire) + assert client.images(), 'image missing before scrolling into history' + test.emit(b'\033[12S') + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + test.redraw() + if evict: + assert not client.images(), 'evicted history retained an image' + else: + assert client.images(), 'image missing before clearing history' + test.run('send-keys', '-X', 'cancel') + test.run('clear-history') + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + test.redraw() + assert not client.images(), 'cleared history retained an image' +PY diff --git a/regress/image-grid.sh b/regress/image-grid.sh new file mode 100755 index 000000000..c2a4ab4cf --- /dev/null +++ b/regress/image-grid.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +# Images follow grid movement, overwrites, clear, reflow and copy mode. +# Check output pixels so lost or incorrect source coordinates are detected. + +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 + +palette = [(100, 0, 0), (0, 100, 0), (0, 0, 100), (100, 100, 100)] +rgb = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)] +pixels = [2 * (y >= 16) + (x >= 8) for y in range(32) for x in range(16)] +wire = sixel(16, 32, palette, pixels) + +with image_test(sys.argv[1]) as test: + client = test.attach() + # A scroll preserves the second source row and puts the first in history. + test.emit(wire + b'\033[1S') + assert len(client.images()) == 1 + check_image(client.images()[0], 16, 16, + [rgb[2 + (x >= 8)] for y in range(16) for x in range(16)]) + client.clear() + test.run('copy-mode') + test.run('send-keys', '-X', 'history-top') + client.read(0.3) + assert any(im[:2] == (16, 32) for im in client.images()) + image = next(im for im in client.images() if im[:2] == (16, 32)) + check_image(image, 16, 32, [rgb[c] for c in pixels]) + test.run('send-keys', '-X', 'cancel') + + # Insert a blank cell inside the image, then delete the gap again. + pixels = [x >= 8 for y in range(16) for x in range(16)] + test.emit(b'\033[2J\033[H' + sixel(16, 16, palette[:2], pixels)) + test.emit(b'\033[1;2H\033[@') + images = client.images() + assert len(images) == 2, [im[:2] for im in images] + for im, colour in zip(images, rgb[:2]): + check_image(im, 8, 16, [colour] * (8 * 16)) + test.emit(b'\033[1;2H\033[P') + images = client.images() + assert len(images) == 2, [im[:2] for im in images] + for im, colour in zip(images, rgb[:2]): + check_image(im, 8, 16, [colour] * (8 * 16)) + + # A double-width character overwrites both image cells. + test.emit(b'\033[H' + '界'.encode()) + assert not client.images() + assert test.run('capture-pane', '-p').startswith('界') + test.redraw() + assert not client.images() + + # Narrowing keeps image rows whole; widening restores the clipped column. + pixels = [2 * (y >= 16) + (x >= 8) for y in range(32) for x in range(16)] + test.emit(b'\033[2J\033[H' + wire) + test.resize(client, 1, 16) + assert len(client.images()) == 1 + check_image(client.images()[0], 8, 32, + [rgb[2 * (y >= 16)] for y in range(32) for x in range(8)]) + test.resize(client, 40, 16) + assert len(client.images()) == 1 + check_image(client.images()[0], 16, 32, [rgb[c] for c in pixels]) + + # Switching the alternate screen does not retain the other screen's image. + test.emit(b'\033[?1049h') + assert not client.images() + test.redraw() + assert not client.images() + test.emit(b'\033[?1049l') + assert len(client.images()) == 1 + check_image(client.images()[0], 16, 32, [rgb[c] for c in pixels]) +PY diff --git a/regress/image-kitty-clear.sh b/regress/image-kitty-clear.sh index 0b784acb5..67bd853b9 100644 --- a/regress/image-kitty-clear.sh +++ b/regress/image-kitty-clear.sh @@ -19,7 +19,7 @@ $TMUX new-session -d -x 10 -y 4 " read line printf '\033[H\033[2J' sleep 30" || exit 1 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 101 $TMUX set -g status off || exit 1 $TMUX set -g scroll-on-clear off || exit 1 $TMUX set -as terminal-features ',*:sixel@' || exit 1 diff --git a/regress/image-kitty-layers.sh b/regress/image-kitty-layers.sh index e09b94e2c..a007999f1 100644 --- a/regress/image-kitty-layers.sh +++ b/regress/image-kitty-layers.sh @@ -8,9 +8,9 @@ export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) export TEST_TMUX -command -v python3 >/dev/null || exit 0 +command -v python3 >/dev/null || exit 101 -python3 - <<'PY' +python3 -B - <<'PY' import base64 import os from pathlib import Path @@ -105,9 +105,9 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-layers-') as tmp: 'time.sleep(30)\n') try: run(inner, 'new-session', '-d', '-x', '40', '-y', '12', - shlex.join(['python3', str(helper)])) + shlex.join(['python3', '-B', str(helper)])) if run(inner, 'display-message', '-p', '#{image_support}') == '0': - raise SystemExit(0) + raise SystemExit(101) run(inner, 'set', '-g', 'status', 'off') run(inner, 'set', '-as', 'terminal-features', ',*:kittygraphics') pane = run(outer, 'new-session', '-dP', '-F', '#{pane_id}', diff --git a/regress/image-kitty-protocol.sh b/regress/image-kitty-protocol.sh index 40df2455f..ccbdb71ba 100644 --- a/regress/image-kitty-protocol.sh +++ b/regress/image-kitty-protocol.sh @@ -9,9 +9,9 @@ export TERM LC_ALL [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) export TEST_TMUX -command -v python3 >/dev/null || exit 0 +command -v python3 >/dev/null || exit 101 -python3 - <<'PY' +python3 -B - <<'PY' import base64 import json import re @@ -61,7 +61,7 @@ def check(name, command, cursor, expected='', text=None, render=None, resize=Non output = directory / name command_file = directory / (name + '.input') command_file.write_text(command, encoding='utf-8') - pane_command = 'python3 ' + shlex.quote(str(helper)) + ' ' + \ + 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: @@ -109,7 +109,7 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: try: run('new-session', '-d', '-x', '40', '-y', '12') if run('display-message', '-p', '#{image_support}') == '0': - raise SystemExit(0) + raise SystemExit(101) run('set', '-g', 'status', 'off') run('set', '-as', 'terminal-features', ',*:sixel@') run('set', '-as', 'terminal-features', ',*:kittygraphics@') @@ -264,13 +264,13 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: high_virtual = graphics('a=T,q=2,U=1,i=16777223,f=32,s=24,v=32,c=3,r=2', white) check('inherit-high-byte', high_virtual + '\033[38;2;0;0;7m' + ph + '\u0305\u0305\u030d' + ph, '2,0', - text=ph + '\u0305\u0305\u030d' + ph, render=r'48;2;255;255;255m {2}') + text=ph + '\u0305\u0305\u030d' + ph, render=r'48;2;255;255;255m[ ▄]{2}') check('inherit-high-byte-row', high_virtual + '\033[38;2;0;0;7m' + ph + '\u0305\u0305\u030d' + ph + '\u0305', '2,0', - render=r'48;2;255;255;255m {2}') + render=r'48;2;255;255;255m[ ▄]{2}') check('inherit-high-byte-column', high_virtual + '\033[38;2;0;0;7m' + ph + '\u0305\u0305\u030d' + ph + '\u0305\u030d', '2,0', - render=r'48;2;255;255;255m {2}') + render=r'48;2;255;255;255m[ ▄]{2}') red = base64.b64encode(b'\xff\0\0\xff' * (24 * 32)).decode() check('explicit-high-byte', high_virtual + graphics('a=T,q=2,U=1,i=7,f=32,s=24,v=32,c=3,r=2', red) + @@ -289,7 +289,7 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: check('negative-virtual', virtual + graphics('a=p,q=2,U=1,i=7,p=1,c=3,r=2,z=-1') + colours + ph, '1,0', render=r'255;255;255m') check('placeholder-resize', virtual + colours + ph, '1,0', - render=r'48;2;255;255;255m(?:\x1b\[[0-9;]+m)* \x1b\[(?:0|39)m', + render=r'48;2;255;255;255m(?:\x1b\[[0-9;]+m)*[ ▄]\x1b\[(?:0|39)m', resize=(50, 12)) for selector in ['a', 'z']: check('virtual-survives-' + selector, virtual + colours + ph + @@ -301,7 +301,7 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: check('virtual-survives-' + selector, virtual + colours + ph + '\033[H' + graphics('a=d,d=%s,q=2%s' % (selector, coordinates)) + '\033[2G' + ph + '\u0305\u030d', '2,0', - render=r'48;2;255;255;255m(?:\x1b\[[0-9;]+m)* {2}') + render=r'48;2;255;255;255m(?:\x1b\[[0-9;]+m)*[ ▄]{2}') # Deleting a prototype leaves its existing text-backed display intact. check('virtual-delete-display', virtual + colours + ph + graphics('a=d,d=I,i=7,p=1,q=2'), '1,0', render=r'255;255;255m') @@ -321,7 +321,7 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: graphics('a=t,q=2,i=7,f=32,s=1,v=1', pixel), '1,0', text=ph, render=ph) check('virtual-replace-display', virtual + colours + ph + graphics('a=p,i=7,p=1,q=2,C=1,c=3,r=2'), '1,0', - render=r'^(?:\x1b\[[0-9;]+m)*\x1b\[48;2;255;255;255m(?:\x1b\[[0-9;]+m)* ') + render=r'^(?:\x1b\[[0-9;]+m)*\x1b\[48;2;255;255;255m(?:\x1b\[[0-9;]+m)*[ ▄]') check('placeholder-overwrite', virtual + colours + ph + '\033[HX', '1,0', text='X', render=r'X') check('placeholder-erase', virtual + colours + ph + '\033[H\033[2K', '0,0', @@ -330,13 +330,13 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: # Ordinary placements stretch; placeholders retain aspect ratio. check('stretch-tall', graphics('a=T,q=2,f=32,s=1,v=1,c=2,r=2,C=1', white_pixel), - '0,0', render=r'48;2;255;255;255m {2}') + '0,0', render=r'48;2;255;255;255m[ ▄]{2}') check('stretch-wide', graphics('a=T,q=2,f=32,s=1,v=1,c=4,r=1,C=1', white_pixel), - '0,0', render=r'48;2;255;255;255m {4}') + '0,0', render=r'48;2;255;255;255m[ ▄]{4}') check('stretch-crop', graphics('a=T,q=2,f=32,s=24,v=32,x=4,y=8,w=8,h=8,' 'c=2,r=2,C=1', white), - '0,0', render=r'48;2;255;255;255m {2}') + '0,0', render=r'48;2;255;255;255m[ ▄]{2}') check('virtual-letterbox', graphics('a=T,q=2,U=1,i=7,p=1,f=32,s=1,v=1,' 'c=2,r=2', white_pixel) + colours + ph + '\u0305\u0305' + ph + '\u0305\u030d', @@ -344,24 +344,24 @@ with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: check('virtual-pillarbox', graphics('a=T,q=2,U=1,i=7,p=1,f=32,s=1,v=1,' 'c=4,r=1', white_pixel) + colours + ph + '\u0305\u0305' + ph + '\u0305\u030d', - '2,0', render=(r'^(?:\x1b\[[0-9;]+m)* ' - r'(?:\x1b\[[0-9;]+m)*\x1b\[48;2;255;255;255m ')) + '2,0', render=(r'^(?:\x1b\[[0-9;]+m)*[ ▄]' + r'(?:\x1b\[[0-9;]+m)*\x1b\[48;2;255;255;255m[ ▄]')) # Equal-z images follow source creation order, even with descending IDs. older = graphics('a=t,q=2,i=200,f=32,s=1,v=1', pixel) newer = graphics('a=t,q=2,i=100,f=32,s=1,v=1', white_pixel) put_older = graphics('a=p,q=2,C=1,i=200,c=1,r=1') put_newer = graphics('a=p,q=2,C=1,i=100,c=1,r=1') check('same-z-descending-ids', older + newer + put_older + put_newer, - '0,0', render=r'48;2;255;255;255m ') + '0,0', render=r'48;2;255;255;255m[ ▄]') check('same-z-reversed-placements', older + newer + put_newer + put_older, - '0,0', render=r'48;2;255;255;255m ') + '0,0', render=r'48;2;255;255;255m[ ▄]') check('same-z-retransmit', older + newer + put_newer + older + put_older, - '0,0', render=r'48;2;255;255;255m ') + '0,0', render=r'48;2;255;255;255m[ ▄]') check('same-z-recreate', older + newer + put_newer + graphics('a=d,d=I,q=2,i=200') + older + put_older, - '0,0', render=r'48;2;255;0;0m ') + '0,0', render=r'48;2;255;0;0m[ ▄]') # An empty line tail must not discard the preceding background run. background = '\033[11GX\033[17G\033[42m \033[0m' check('background-tail', background, '20,0', render=r'42m {4}') diff --git a/regress/image-kitty-region-scroll.sh b/regress/image-kitty-region-scroll.sh index 7d60564bc..1d18186aa 100755 --- a/regress/image-kitty-region-scroll.sh +++ b/regress/image-kitty-region-scroll.sh @@ -54,7 +54,7 @@ $TMUX new-session -d -s inner -x 40 -y 20 "printf '$HEADER'; exec sh" || exit 1 sleep 0.3 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 101 $TMUX set -as terminal-features ',*:kittygraphics' || exit 1 $TMUX2 new-session -d -x 40 -y 20 || exit 1 diff --git a/regress/image-lifecycle.sh b/regress/image-lifecycle.sh new file mode 100755 index 000000000..2004278a7 --- /dev/null +++ b/regress/image-lifecycle.sh @@ -0,0 +1,74 @@ +#!/bin/sh + +# Stress placement cleanup and copied references, restore image rows after +# height changes, and switch between graphical and fallback output. + +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 +from image_terminal import Terminal + +palette = [(100, 0, 0), (0, 100, 0)] +rgb = [(255, 0, 0), (0, 255, 0)] +wire = sixel(16, 32, palette, + [y >= 16 for y in range(32) for x in range(16)]) +expected = [rgb[y >= 16] for y in range(32) for x in range(16)] + +with image_test(sys.argv[1], sy=6) as test: + client = test.attach() + test.emit(wire) + test.resize(client, 40, 2) + test.redraw() + images = client.images() + assert len(images) == 1, [im[:2] for im in images] + check_image(images[0], 16, 16, [rgb[1]] * (16 * 16)) + test.resize(client, 40, 6) + test.redraw() + assert len(client.images()) == 1 + check_image(client.images()[0], 16, 32, expected) + +# Changing pixel size can change the selected backend without reattaching. +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(wire) + for _ in range(3): + test.resize(client, 40, 16, cw=0, ch=0) + test.redraw() + assert not client.images(), 'SIXEL backend retained without pixel size' + terminal = Terminal() + terminal.feed(client.data) + assert terminal.cells[0, 0][0] == '\u2584', terminal.cells[0, 0] + test.resize(client, 40, 16) + test.redraw() + assert len(client.images()) == 1, 'SIXEL backend not restored' + check_image(client.images()[0], 16, 32, expected) + +# Keep one server alive through many overlapping placements, split spans, +# scrolling and clears, with copy-mode snapshots and resizes between batches. +with image_test(sys.argv[1]) as test: + client = test.attach() + overlay = sixel(16, 32, palette, + [None if x % 2 else 1 for y in range(32) for x in range(16)]) + cycle = (b'\033[2J\033[H' + wire + b'\033[H' + overlay + + b'\033[1;2H\033[@\033[P\033[X\033[2;1H\033[L\033[M\033[2S') + for _ in range(10): + test.emit(cycle * 8 + b'\033[2J\033[H' + wire) + test.run('copy-mode') + test.run('send-keys', '-X', 'cancel') + test.resize(client, 1, 16) + test.resize(client, 40, 16) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check({(x, y): expected[y * 16 + x] + for y in range(32) for x in range(16)}) + test.run('clear-history') + test.emit(b'\033[2J') + test.redraw() + assert not client.images(), 'images survived the final clear' +PY diff --git a/regress/image-movepane-drag-noflash.sh b/regress/image-movepane-drag-noflash.sh index d12f0a7d1..cea7e2bc9 100755 --- a/regress/image-movepane-drag-noflash.sh +++ b/regress/image-movepane-drag-noflash.sh @@ -11,7 +11,7 @@ FLOAT=$($TMUX new-pane -d -PF '#{pane_id}' -x 16 -y 5 -X 30 -Y 5) || fail "new-pane -X -Y failed" sleep 0.3 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX $TMUX set -as terminal-features ',*:sixel' || exit 1 start_capture 60 20 diff --git a/regress/image-noflash-common.inc b/regress/image-noflash-common.inc index 73c42bdbe..0de79d38b 100644 --- a/regress/image-noflash-common.inc +++ b/regress/image-noflash-common.inc @@ -2,6 +2,8 @@ # keyboard interaction that must not retransmit an unrelated pane's image, # checked by counting DCS (\033P) sequences in the client's raw output. +. ./image-common.inc + PATH=/bin:/usr/bin TERM=screen LC_ALL=C.UTF-8 @@ -64,6 +66,6 @@ assert_image_reached() # see that test's header comment. assert_no_retransmit() { - n=$(grep -ac "$(printf '\033P')" $TMP) + n=$(grep -aoE "$(printf '\033P')[0-9;]*q" $TMP | wc -l) [ "$n" -eq 0 ] || fail "image was retransmitted ($n times) $1" } diff --git a/regress/image-obscured-structural-op.sh b/regress/image-obscured-structural-op.sh index bbe34541b..054434dc9 100755 --- a/regress/image-obscured-structural-op.sh +++ b/regress/image-obscured-structural-op.sh @@ -1,25 +1,9 @@ #!/bin/sh -# Verification test, not a known-bug regression test: checks whether a -# structural screen operation (insert-line / delete-line) on a pane that is -# currently obscured by a floating pane correctly retransmits an image -# sitting in the affected rows. -# -# screen_write_insertline()/screen_write_deleteline() (screen-write.c), when -# the target pane is obscured, escalate via screen_write_redraw_pane() -> -# screen_write_redraw_line() -> tty_cmd_redrawline() -> tty_draw_line(). -# This is a different, older mechanism from the sub-pane damage-rectangle -# system built elsewhere this session - it was never migrated onto it. But -# both functions also unconditionally call image_redraw_area() up front -# (independent of obscured status), and tty_draw_line() (tty-draw.c) has -# its own built-in ENABLE_IMAGES handling, so reading the code suggests -# this path should already correctly recomposite images, unlike the bugs -# fixed elsewhere this session where nothing signalled a redraw at all. -# This test exists to confirm that empirically rather than relying on that -# reading holding up in practice. -# -# If this ever fails, it means that reading was wrong and the obscured -# structural-op path needs migrating onto the damage system too. +# Insert and delete a line behind a floating pane. The affected SIXEL +# image must be sent again even when the pane is obscured. + +. ./image-common.inc PATH=/bin:/usr/bin TERM=screen @@ -47,29 +31,21 @@ cleanup TMP=$(mktemp) trap "cleanup; rm -f $TMP" 0 1 15 -# A small, distinctive SIXEL raster (26x26 pixels) at the top-left of the -# base pane, matching the fixture already used in image-support.sh. After a -# pause (long enough for the floating pane below to be created), it emits a -# cursor-home, insert-line, delete-line sequence itself - a content no-op, -# but it exercises the obscured-pane structural-op redraw path across the -# image's rows. This is done from the pane's own script rather than typed -# via send-keys, since interactive typing races against the shell's own -# line-editing redraws and does not reliably reach the terminal parser as -# real escape sequences. +# Hold the pane after its image until capture is ready, then trigger the +# insert/delete-line operation explicitly instead of racing a fixed sleep. SIXEL='\033Pq"1;1;26;26#0;2;100;100;100#0!26~-!26~-!26~-!26~-!26B\033\\' TRIGGER='\033[H\033[L\033[M' $TMUX new-session -d -s inner -x 40 -y 15 \ - "printf '$SIXEL'; sleep 2; printf '$TRIGGER'; sleep 100" || exit 1 + "printf '$SIXEL'; $TMUX wait-for image-ops-$$; printf '$TRIGGER'; sleep 100" || exit 1 sleep 0.5 BASE=$($TMUX list-panes -F '#{pane_id}' | head -1) [ -n "$BASE" ] || fail "No base pane." -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX $TMUX set -as terminal-features ',*:sixel' || exit 1 # Float a small pane so it overlaps the top rows of the base pane, where -# the image sits - this is what makes the base pane obscured there. This -# must happen before the base pane's 2-second internal sleep elapses. +# the image sits, making the base pane obscured. $TMUX new-pane -d -x 12 -y 4 -X 5 -Y 0 || fail "new-pane -X -Y failed" $TMUX2 new-session -d -x 40 -y 15 "$TMUX attach -t inner" || exit 1 @@ -78,7 +54,12 @@ OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) [ -n "$OUTER" ] || fail "No outer pane." $TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed" -sleep 2.5 +$TMUX refresh-client || fail "initial refresh failed" +sleep 1 +grep -aq '"1;1;26;26' $TMP || fail "initial image was not sent" +: >$TMP +$TMUX wait-for -S image-ops-$$ || fail "trigger failed" +sleep 1 grep -a -q '"1;1;26;26' $TMP || fail "image was not retransmitted after insert/delete-line on an obscured pane" diff --git a/regress/image-placeholder-reflow.sh b/regress/image-placeholder-reflow.sh index 3440c661e..1d681927b 100644 --- a/regress/image-placeholder-reflow.sh +++ b/regress/image-placeholder-reflow.sh @@ -20,6 +20,7 @@ $TMUX2 new-session -d -x 10 -y 4 " i=\$((i + 1)) done sleep 30" || exit 1 +[ "$($TMUX2 display-message -p '#{image_support}')" = 0 ] && exit 101 $TMUX2 set -g status off || exit 1 $TMUX new-session -d -x 10 -y 4 "$TMUX2 attach-session" || exit 1 $TMUX set -g status off || exit 1 diff --git a/regress/image-resize-many-placements.sh b/regress/image-resize-many-placements.sh index 64f5073bc..870ec364f 100644 --- a/regress/image-resize-many-placements.sh +++ b/regress/image-resize-many-placements.sh @@ -1,39 +1,49 @@ #!/bin/sh -# Widening a pane restores clipped columns for every image placement. +# Adapt the PR's many-placement recovery test to SIXEL: place images on +# separate rows so they remain live, then recover every clipped column. PATH=/bin:/usr/bin TERM=screen -LC_ALL=C -export TERM LC_ALL - +export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -Limage-resize-outer$$ -f/dev/null" -TMUX2="env -u TMUX LC_ALL=C $TEST_TMUX -Limage-resize-inner$$ -f/dev/null" -TMP=$(mktemp) -trap "$TMUX kill-server 2>/dev/null; $TMUX2 kill-server 2>/dev/null; rm -f $TMP" 0 1 15 -# White placements stay behind the final red placement. -# An 8:1 source fills 16 columns by one row without letterboxing. -$TMUX2 new-session -d -x 8 -y 4 " - i=0 - while [ \$i -lt 64 ]; do - printf '\\033_Ga=T,q=2,C=1,f=32,s=8,v=1,c=16,r=1;//////////////////////////////////////////8=\\033\\\\' - i=\$((i + 1)) - done - printf '\\033_Ga=T,q=2,C=1,f=32,s=8,v=1,c=16,r=1;/wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP8=\\033\\\\' - sleep 30" || exit 1 -[ "$($TMUX2 display-message -p '#{image_support}')" = 0 ] && exit 0 -$TMUX2 set -g status off || exit 1 -$TMUX new-session -d -x 8 -y 4 "$TMUX2 attach-session" || exit 1 -$TMUX set -g status off || exit 1 -sleep 1 +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_test import image_test, sixel, check_image -$TMUX capture-pane -pS0 -E0 >$TMP || exit 1 -[ "$(cat $TMP)" = '........' ] || exit 1 -$TMUX resize-window -x 16 -y 4 || exit 1 -sleep 1 -$TMUX capture-pane -pS0 -E0 >$TMP || exit 1 -[ "$(cat $TMP)" = '................' ] || exit 1 +def check_rows(images, width): + """Each placement has two coloured halves; rows may use several rasters.""" + row = x = 0 + for image in images: + assert row < 64 and x + image[0] <= width, (row, x, image[:2]) + expected = [rgb[(row + (xx >= 64)) % 3] + for xx in range(x, x + image[0])] + check_image(image, image[0], 1, expected) + x += image[0] + if x == width: + row += 1 + x = 0 + assert (row, x) == (64, 0), (row, x) -exit 0 + +palette = [(100, 0, 0), (0, 100, 0), (0, 0, 100)] +rgb = [(255, 0, 0), (0, 255, 0), (0, 0, 255)] +with image_test(sys.argv[1], sx=8, sy=80) as test: + client = test.attach() + wire = b'' + for y in range(64): + wire += ('\033[%d;1H' % (y + 1)).encode() + wire += sixel(128, 1, palette, + [y % 3] * 64 + [(y + 1) % 3] * 64) + test.emit(wire) + test.redraw() + check_rows(client.images(), 64) + + test.resize(client, 16, 80) + test.redraw() + check_rows(client.images(), 128) + test.redraw() + check_rows(client.images(), 128) + +PY diff --git a/regress/image-resize-width-recovery.sh b/regress/image-resize-width-recovery.sh index 0dc8c009d..1bb4800d1 100755 --- a/regress/image-resize-width-recovery.sh +++ b/regress/image-resize-width-recovery.sh @@ -1,89 +1,46 @@ #!/bin/sh -# Widening a pane must recover image columns clipped at placement time. -# A damage redraw can send the newly visible columns as a separate raster, -# so compare the combined raster widths with the original image width. +# The PR's SIXEL fixture must recover all columns clipped at placement time. +# A PTY gives a known cell size; allow restored rows to use several rasters. PATH=/bin:/usr/bin TERM=screen -LC_ALL=C.UTF-8 -export TERM LC_ALL - +export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" -TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" -cleanup() -{ - $TMUX kill-server >/dev/null 2>&1 - $TMUX2 kill-server >/dev/null 2>&1 -} -fail() -{ - echo "$*" >&2 - cleanup - exit 1 -} +python3 -B - "$TEST_TMUX" <<'PY' +from pathlib import Path +import re +import sys +from image_test import image_test -cleanup +wire = Path('monkey-2.sixel.txt').read_bytes() +match = re.search(rb'"1;1;(\d+);(\d+)', wire) +assert match is not None +w, h = map(int, match.groups()) -TMP=$(mktemp) -trap "cleanup; rm -f $TMP" 0 1 15 +with image_test(sys.argv[1], sx=12, sy=40) as test: + client = test.attach() + test.emit(wire) + test.redraw() + images = client.images() + assert len(images) == 1, [im[:2] for im in images] + assert images[0][:2] == (96, h), images[0][:2] -FIXTURE=$(pwd)/monkey-2.sixel.txt -FULL=$(grep -oa '"1;1;[0-9]*;[0-9]*' $FIXTURE | head -1 | cut -d';' -f3) -[ -n "$FULL" ] || fail "could not read fixture's own raster width" - -# The fixture is 360px wide - narrow enough that a 12-column pane clips it, -# comfortably below the 360px it would take to show in full. -$TMUX new-session -d -s inner -x 12 -y 40 "cat '$FIXTURE'; exec sh" || exit 1 -sleep 0.5 - -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 -$TMUX set -as terminal-features ',*:sixel' || exit 1 - -# Start the outer session with a plain shell, then start capturing before -# triggering the attach - starting the attach as the outer pane's initial -# command would mean pipe-pane only starts after the attach-driven initial -# redraw (which sends the image) has already happened, missing it. -$TMUX2 new-session -d -x 12 -y 40 || exit 1 -OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) -[ -n "$OUTER" ] || fail "No outer pane." -# The outer client is just another tmux client receiving raw PTY output -# that happens to contain sixel DCS sequences - left alone, it would -# independently decode and place its own second, differently-sized image -# on top of the inner session's, confounding the raster-width measurement -# below. Disable sixel on the outer client so only the inner session's -# placement exists. -$TMUX2 set -as terminal-features ',*:sixel@' || fail "disable outer sixel failed" -$TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed" -$TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || fail "send attach failed" -$TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed" -sleep 1 - -before=$(grep -oa '"1;1;[0-9]*;' $TMP | grep -o '[0-9]*' | sort -n | tail -1) -[ -n "$before" ] || fail "sanity: image never reached the client" -[ "$before" -lt 360 ] || fail "sanity: image was not clipped by the narrow pane ($before)" -: >$TMP - -# Widen the window well past the image's full width - resize the outer -# client, since the inner session's displayed size follows whatever -# terminal size its attached client actually has. -$TMUX2 resize-window -t "$OUTER" -x 30 -y 40 || fail "resize-window failed" -sleep 1 -$TMUX refresh-client || fail "refresh-client failed" -sleep 1 - -# The widths seen after the resize, added together (deduplicated - the -# same chunk may legitimately be retransmitted), should account for the -# fixture's full pixel width: this is expected to fail before the fix, -# where the newly-uncovered columns are never redrawn at all and only the -# original clipped width ever appears - see the header comment. -after_total=$(grep -oa '"1;1;[0-9]*;' $TMP | grep -o '[0-9]*' | sort -nu | - awk '{s+=$1} END{print s+0}') -[ "$after_total" -gt 0 ] || - fail "no image raster was sent at all after widening the window" -[ "$after_total" -ge $((FULL - 20)) ] || - fail "image stayed clipped at ${before}px after widening the window (widths summed to only ${after_total}px, expected close to ${FULL}px)" - -exit 0 + test.resize(client, 80, 40) + test.redraw() + images = client.images() + assert images, 'no image after widening' + x = y = 0 + row_h = None + for im in images: + if x == 0: + row_h = im[1] + assert im[1] == row_h, (im[:2], row_h) + x += im[0] + assert x <= w, (x, w) + if x == w: + y += row_h + x = 0 + assert (x, y) == (0, h), ('image stayed clipped', x, y, w, h) +PY diff --git a/regress/image-scroll-no-erase.sh b/regress/image-scroll-no-erase.sh index aafa37c21..85d8b7d7a 100755 --- a/regress/image-scroll-no-erase.sh +++ b/regress/image-scroll-no-erase.sh @@ -1,107 +1,16 @@ #!/bin/sh -# Regression test for a tmux bug (not a terminal bug): image_redraw_scroll() -# (image.c) marks the whole scroll region as damage on every scroll. For a -# client whose terminal has imagescroll, the terminal has already moved the -# image (and the text) along with the scroll, but the damage was still -# composed: the image was not retransmitted, but the ordinary text pass -# drew blank grid content directly over the image's cells and erased a -# correctly scrolled image. This is what a user saw as "the image -# disappears when the pane scrolls in Windows Terminal" even with -# imagescroll and margins both granted, and could never be reproduced by -# any terminal-side test because the bug is entirely server side. -# -# redraw_client_damage_rect() (screen-redraw.c) now composes nothing for a -# scroll the client's terminal is trusted to have done itself, logging -# "composing damage" only when it draws. Nothing being drawn is what keeps -# the image, so the absence of that log line for the scroll is checked. -# -# The image is placed comfortably mid-pane (not at the very top) and wide -# enough to span the whole pane, so it is still on screen - not already -# scrolled into history - by the time a real scroll's damage gets composed. +# The PR's trusted-scroll erase regression: inspect terminal erases directly +# rather than depending on a particular debug log message. PATH=/bin:/usr/bin TERM=screen -LC_ALL=C.UTF-8 -export TERM LC_ALL - +export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -LtestA$$ -vv -f/dev/null" -TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" -cleanup() -{ - $TMUX kill-server >/dev/null 2>&1 - $TMUX2 kill-server >/dev/null 2>&1 -} -fail() -{ - echo "$*" >&2 - cleanup - exit 1 -} +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_scroll import check_scroll -cleanup - -DIR=$(mktemp -d) || exit 1 -cd "$DIR" || exit 1 -trap "cleanup; cd /; rm -rf $DIR" 0 1 15 - -WIDTH=40 -HEIGHT=20 -ROW=10 - -# A sixel image exactly WIDTH cells wide (at a typical ~9px cell, 360px) and -# one cell tall, placed at screen row ROW - comfortably mid-pane, not at -# the very top, so it stays visible (not yet scrolled into history) through -# the first several scrolls. -HEADER='\033Pq"1;1;360;18#0;2;100;100;100#0!360~-!360~-!360B\033\\' - -# Pad with blank lines after the image so the shell prompt lands at the -# bottom of the pane - without this, the very first scroll from a -# not-yet-settled cursor position falls back to tty_redraw_region() (a -# different, unrelated path that the trusted-scroll skip never applies to -# anyway), and the test would not exercise what it is meant to. -PAD=$((HEIGHT - ROW - 2)) -$TMUX new-session -d -s inner -x $WIDTH -y $HEIGHT \ - "printf '\\033[$((ROW + 1));1H$HEADER'; i=0; while [ \$i -lt $PAD ]; do echo; i=\$((i + 1)); done; exec sh" || - exit 1 -sleep 0.3 - -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 -$TMUX set -g pane-scrollbars on || exit 1 -$TMUX set -as terminal-features ',*:sixel' || exit 1 -$TMUX set -as terminal-features ',*:margins' || exit 1 -$TMUX set -as terminal-features ',*:imagescroll' || exit 1 - -$TMUX2 new-session -d -x $WIDTH -y $HEIGHT || exit 1 -OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) -[ -n "$OUTER" ] || fail "No outer pane." -$TMUX2 set -as terminal-features ',*:sixel@' || fail "disable outer sixel failed" -TMP=$(mktemp) -$TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed" -$TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || fail "send attach failed" -$TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed" -sleep 1 - -grep -qa '"1;1;360;18' "$TMP" || fail "sanity: image never reached the client" - -LOG=$(ls tmux-server*.log 2>/dev/null | head -1) -[ -n "$LOG" ] || fail "sanity: no server -vv log was produced" -BEFORE=$(wc -l <"$LOG") -: >"$TMP" - -# The image (row ROW, 10 of 20) has plenty of room above it - this stays -# well within the visible pane for the whole burst. -$TMUX send-keys -t inner -l "yes | head -n 10" || fail "send scroll failed" -$TMUX send-keys -t inner Enter || fail "send enter failed" -sleep 1 - -n_dcs=$(grep -ac "$(printf '\033P')" "$TMP") -[ "$n_dcs" -eq 0 ] || - fail "sanity: image was retransmitted ($n_dcs times) - imagescroll's trust did not engage, so this run cannot test what it is meant to" - -tail -n +$((BEFORE + 1)) "$LOG" | grep -q "redraw_client_damage_rect: .* composing damage" && - fail "damage was composed for a scroll the terminal is trusted to have done - this would draw over the image and erase it" - -exit 0 +check_scroll(sys.argv[1], True, no_erase=True) +PY diff --git a/regress/image-scroll-no-text-redraw.sh b/regress/image-scroll-no-text-redraw.sh index 6398259c1..aeae2570c 100644 --- a/regress/image-scroll-no-text-redraw.sh +++ b/regress/image-scroll-no-text-redraw.sh @@ -1,87 +1,17 @@ #!/bin/sh -# When the client's terminal has the imagescroll feature, tmux trusts it to -# have moved everything in the scrolled region - text and image alike - so -# a scroll must not cause any existing text to be redrawn. It used to skip -# only the image phases of the damage redraw and still redraw all of the -# text around them. -# -# Without imagescroll the images must be redrawn, and the text around them -# is redrawn too, so the same scroll must resend it. -# -# The feature only takes effect for a client attached after it is set, so -# each case starts a new server and client. +# Adapt the PR's Kitty marker check to SIXEL. Native image scrolling must +# skip the surrounding text repaint as well as image retransmission. PATH=/bin:/usr/bin TERM=screen -LC_ALL=C.UTF-8 -export TERM LC_ALL - +export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" -TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" -cleanup() -{ - $TMUX kill-server >/dev/null 2>&1 - $TMUX2 kill-server >/dev/null 2>&1 -} -fail() -{ - echo "$*" >&2 - cleanup - exit 1 -} +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_scroll import check_scroll -TMP=$(mktemp) -trap "cleanup; rm -f $TMP" 0 1 15 - -HEADER=$(printf '\033_Ga=T,q=2,f=32,s=1,v=1,i=424243;/wAA/w==\033\\') - -# Scroll a pane containing an image and marker text on a client with the -# given terminal features, and set N to the number of marker lines sent to -# the terminal. -scroll_markers() -{ - cleanup - - # Marker text around an image, filling the pane so that the next - # newlines scroll it while the image stays in view. - $TMUX new-session -d -s inner -x 40 -y 20 \ - "i=0; while [ \$i -lt 5 ]; do printf 'MARKER%02d\n' \$i; i=\$((i + 1)); done; printf '$HEADER'; while [ \$i -lt 17 ]; do printf 'MARKER%02d\n' \$i; i=\$((i + 1)); done; exec sh" || - exit 1 - sleep 0.3 - [ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 - $TMUX set -as terminal-features "$1" || exit 1 - - $TMUX2 new-session -d -x 40 -y 20 || exit 1 - OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) - [ -n "$OUTER" ] || fail "No outer pane." - $TMUX2 set -as terminal-features ',*:kittygraphics@' || - fail "disable outer kitty failed" - : >"$TMP" - $TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed" - $TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || - fail "send attach failed" - $TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed" - sleep 2 - grep -qa "$(printf '\033_Ga=p')" "$TMP" || - fail "sanity: image never reached the client" - - : >"$TMP" - $TMUX send-keys -t inner -l "printf '\n\n\n'" || - fail "send scroll failed" - $TMUX send-keys -t inner Enter || fail "send enter failed" - sleep 1 - N=$(grep -ac MARKER "$TMP") -} - -scroll_markers ',*:kittygraphics,*:imagescroll' -[ "$N" -eq 0 ] || - fail "existing text was redrawn ($N lines) scrolling with imagescroll" - -scroll_markers ',*:kittygraphics' -[ "$N" -gt 0 ] || - fail "text was not redrawn scrolling without imagescroll" - -exit 0 +check_scroll(sys.argv[1], True) +check_scroll(sys.argv[1], False) +PY diff --git a/regress/image-scroll-off-recovery.sh b/regress/image-scroll-off-recovery.sh index f7ae3b8a0..2661f6f78 100755 --- a/regress/image-scroll-off-recovery.sh +++ b/regress/image-scroll-off-recovery.sh @@ -2,6 +2,8 @@ # Images scrolled off-screen must reappear when copy mode scrolls back up. +. ./image-common.inc + PATH=/bin:/usr/bin TERM=screen LC_ALL=C.UTF-8 @@ -37,7 +39,7 @@ FIXTURE=$(pwd)/monkey-2.sixel.txt $TMUX new-session -d -s inner -x 30 -y 5 "cat '$FIXTURE'; exec sh" || exit 1 sleep 0.5 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX $TMUX set -as terminal-features ',*:sixel' || exit 1 # Start the outer session with a plain shell, then start capturing before diff --git a/regress/image-scroll-uses-region.sh b/regress/image-scroll-uses-region.sh index 4555bbaf9..495de0958 100755 --- a/regress/image-scroll-uses-region.sh +++ b/regress/image-scroll-uses-region.sh @@ -1,142 +1,20 @@ #!/bin/sh -# Regression test: with imagescroll granted, does tmux actually use a native -# scrolling-region command (DECSTBM, and DECSLRM if the pane isn't full -# width) to perform the scroll, rather than tty_redraw_region()'s fallback -# full manual repaint? This directly checks the concern raised in PR review -# that tmux "isn't using scrolling regions" for an image-bearing pane - -# imagescroll only makes sense to grant a terminal if tmux is actually -# relying on a real native scroll to carry the image along, not silently -# falling back to redrawing every line by hand while still skipping the -# image retransmission (which would just lose the image for nothing). -# -# tty_redraw_region() (tty.c) logs one of two lines whenever it runs at -# all - "%s large region redraw" or "%s small region redraw" - so their -# total absence after the scroll proves the native path -# (tty_cmd_linefeed()/scrollup()/scrolldown()/reverseindex()) was taken -# instead. Combined with image-sixel-region-scroll.sh's own check (the -# image is not retransmitted), this proves imagescroll's trust is actually -# backed by a real native scroll, not a coincidence. -# -# Phase 2 is the deliberate opposite case, and is what PR review's concern -# actually described: pane-scrollbars makes this pane less than full width, -# and without margins granted, tty_cmd_*()'s own -# "(!tty_full_width(tty, ctx) && !tty_use_margin(tty))" check has no native -# scrolling-region option left and must fall back - proving phase 1 wasn't -# trivially passing, and that redraw_image_scroll_result()'s failure path -# correctly also stops imagescroll's trust from applying when that happens -# (the image must be retransmitted here, exactly as -# image-sixel-region-scroll.sh's own phase 2 already separately proves). -# -# A real attached client is needed for any of this redraw code to run at -# all, so this uses the same nested inner/outer tmux pattern as -# image-sixel-region-scroll.sh. +# The PR's narrow-pane scroll check. Margins permit native scrolling; +# without them imagescroll must not suppress a necessary repaint. +# Different clients must make that decision independently. PATH=/bin:/usr/bin TERM=screen -LC_ALL=C.UTF-8 -export TERM LC_ALL - +export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -LtestA$$ -vv -f/dev/null" -TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" -cleanup() -{ - $TMUX kill-server >/dev/null 2>&1 - $TMUX2 kill-server >/dev/null 2>&1 -} -fail() -{ - echo "$*" >&2 - cleanup - exit 1 -} +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_scroll import check_scroll, check_clients -cleanup - -DIR=$(mktemp -d) || exit 1 -cd "$DIR" || exit 1 -trap "cleanup; cd /; rm -rf $DIR" 0 1 15 - -WIDTH=40 -HEIGHT=20 - -HEADER='\033Pq"1;1;26;26#0;2;100;100;100#0!26~-!26~-!26~-!26~-!26B\033\\' - -$TMUX new-session -d -s inner -x $WIDTH -y $HEIGHT \ - "printf '$HEADER'; exec sh" || exit 1 -sleep 0.3 - -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 -$TMUX set -g pane-scrollbars on || exit 1 -$TMUX set -as terminal-features ',*:sixel' || exit 1 -$TMUX set -as terminal-features ',*:imagescroll' || exit 1 - -$TMUX2 new-session -d -x $WIDTH -y $HEIGHT || exit 1 -OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) -[ -n "$OUTER" ] || fail "No outer pane." -$TMUX2 set -as terminal-features ',*:sixel@' || fail "disable outer sixel failed" -TMP=$(mktemp) -$TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed" -$TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || fail "send attach failed" -$TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed" -sleep 1 - -grep -qa '"1;1;26;26' "$TMP" || fail "sanity: image never reached the client" - -LOG=$(ls tmux-server*.log 2>/dev/null | head -1) -[ -n "$LOG" ] || fail "sanity: no server -vv log was produced" - -run_scroll() -{ - label=$1 - BEFORE=$(wc -l <"$LOG") - : >"$TMP" - - $TMUX send-keys -t inner -l "yes | head -n 10" || fail "$label: send scroll failed" - $TMUX send-keys -t inner Enter || fail "$label: send enter failed" - sleep 1 - - n_dcs=$(grep -ac "$(printf '\033P')" "$TMP") - n_redraw=$(tail -n +$((BEFORE + 1)) "$LOG" | grep -c "region redraw") -} - -# --- Phase 1: margins granted - real native scrolling region expected, -# image never retransmitted. --- -$TMUX set -as terminal-features ',*:margins' || fail "grant margins failed" -sleep 0.3 -$TMUX display-message -p '#{client_termfeatures}' | - grep -q imagescroll || fail "sanity: imagescroll was not granted to the client" - -run_scroll "phase 1 (margins granted)" -[ "$n_dcs" -eq 0 ] || - fail "phase 1: image was retransmitted ($n_dcs times) - imagescroll's trust did not engage, so this run cannot test what it is meant to" -[ "$n_redraw" -eq 0 ] || - fail "phase 1: tty_redraw_region() fired ($n_redraw times) while scrolling an image-bearing pane with margins+imagescroll granted - tmux fell back to a full manual repaint instead of a real native scroll, so trusting the terminal to have moved the image was never justified" - -# --- Phase 2: margins revoked - the pane still isn't full width -# (pane-scrollbars is on), so this is the scenario PR review's "not using -# scrolling regions" concern actually described: no native scrolling-region -# option is available, tty_redraw_region() must fire, and -# redraw_image_scroll_result()'s failure path must in turn stop -# imagescroll's trust from applying, so the image is retransmitted instead -# of silently lost. Resend the image first - phase 1's scrolling already -# pushed the original one (26 pixels tall, a couple of rows at most) out of -# the pane, so without this there would be nothing left to retransmit -# either way, and the check below would pass for the wrong reason. --- -$TMUX set -as terminal-features ',*:margins@' || fail "revoke margins failed" -sleep 0.3 -: >"$TMP" -$TMUX send-keys -t inner -l "printf '$HEADER'" || fail "resend image failed" -$TMUX send-keys -t inner Enter || fail "send enter failed" -sleep 1.5 -grep -qa '"1;1;26;26' "$TMP" || fail "sanity: image did not reappear before phase 2" - -run_scroll "phase 2 (margins revoked)" -[ "$n_redraw" -gt 0 ] || - fail "sanity: without margins, scrolling a scrollbar-enabled image-bearing pane never fell back to tty_redraw_region() - this scenario no longer exercises the bug phase 1 checks for" -[ "$n_dcs" -gt 0 ] || - fail "phase 2: image was not retransmitted after tty_redraw_region() fired - imagescroll's trust incorrectly stayed engaged despite the fallback, which would silently lose the image" - -exit 0 +check_scroll(sys.argv[1], True, margins=True) +check_scroll(sys.argv[1], True, margins=False) +check_clients(sys.argv[1]) +check_clients(sys.argv[1], narrow=True) +PY diff --git a/regress/image-selection-noflash.sh b/regress/image-selection-noflash.sh index 9a1ce8698..3db93e92f 100755 --- a/regress/image-selection-noflash.sh +++ b/regress/image-selection-noflash.sh @@ -12,7 +12,7 @@ $TMUX new-session -d -s inner -x 40 -y 20 \ exit 1 sleep 0.5 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX $TMUX set -as terminal-features ',*:sixel' || exit 1 start_capture 40 20 diff --git a/regress/image-sixel-output.sh b/regress/image-sixel-output.sh new file mode 100755 index 000000000..a242f38e9 --- /dev/null +++ b/regress/image-sixel-output.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +# Check RGBA to SIXEL output, palette splitting, transparent edges, source +# clipping and repeated redraws at different terminal pixel sizes. + +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 + +palette = [(100, 0, 0), (0, 100, 0), (0, 0, 100), (100, 100, 100)] +rgb = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)] +pixels = [2 * (y >= 16) + (x >= 8) for y in range(32) for x in range(16)] +expected = [rgb[c] for c in pixels] +wire = sixel(16, 32, palette, pixels) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(wire) + assert len(client.images()) == 1 + check_image(client.images()[0], 16, 32, expected) + for _ in range(3): + test.redraw() + assert len(client.images()) == 1 + check_image(client.images()[0], 16, 32, expected) + + # Keep the stored cell footprint when terminal cell pixels change. + for cw, ch in [(16, 32), (12, 24), (12, 16)]: + test.resize(client, 40, 16, cw=cw, ch=ch) + w, h = cw * 2, ch * 2 + scaled = [expected[(y * 32 // h) * 16 + x * 16 // w] + for y in range(h) for x in range(w)] + assert len(client.images()) == 1 + check_image(client.images()[0], w, h, scaled) + test.resize(client, 40, 16) + check_image(client.images()[0], 16, 32, expected) + + # Removing one grid cell crops the original source, retaining its colours. + test.emit(b'\033[HX') + images = client.images() + assert len(images) == 2, [im[:2] for im in images] + check_image(images[0], 8, 16, [rgb[1]] * (8 * 16)) + check_image(images[1], 16, 16, + [rgb[2 + (x >= 8)] for y in range(16) for x in range(16)]) + test.emit(b'\033[2J\033[H') + assert not client.images() + test.redraw() + assert not client.images() + + # A partial last cell keeps its actual pixel size instead of stretching. + test.emit(sixel(26, 26, [(100, 100, 100)], [0] * (26 * 26))) + assert len(client.images()) == 1 + check_image(client.images()[0], 26, 26, [rgb[3]] * (26 * 26)) + + # Transparent edges and holes must not acquire coloured pixels. + pixels = [None if x == 0 or y == 0 or x == 7 or y == 15 else 0 + for y in range(16) for x in range(8)] + test.emit(b'\033[2J\033[H' + sixel(8, 16, palette[:1], pixels)) + assert len(client.images()) == 1 + check_image(client.images()[0], 8, 16, + [None if c is None else rgb[0] for c in pixels]) + + # A skewed histogram still splits into black, grey and white entries. + pixels = [0] * 10 + [1] * 10 + [2] * 80 + test.emit(b'\033[2J\033[H' + + sixel(100, 1, [(0, 0, 0), (50, 50, 50), (100, 100, 100)], pixels)) + assert len(client.images()) == 1 + check_image(client.images()[0], 100, 1, + [(0, 0, 0)] * 10 + [(128, 128, 128)] * 10 + [rgb[3]] * 80) + + # The right edge of a pane crops the source, rather than rescaling it. + test.emit(b'\033[2J\033[1;40H' + wire) + assert len(client.images()) == 1 + check_image(client.images()[0], 8, 32, + [rgb[2 * (y >= 16)] for y in range(32) for x in range(8)]) + +# Different clients independently use the same image at their own cell size. +with image_test(sys.argv[1]) as test: + large = test.attach() + test.emit(wire) + small = test.attach(cw=4, ch=8) + fallback = test.attach(graphical=False) + test.redraw() + assert len(large.images()) == 1 + check_image(large.images()[0], 16, 32, expected) + assert len(small.images()) == 1 + check_image(small.images()[0], 8, 16, + [expected[y * 2 * 16 + x * 2] + for y in range(16) for x in range(8)]) + assert not fallback.images() + assert b'\xe2\x96\x84' in fallback.data, 'missing simultaneous fallback' +PY diff --git a/regress/image-sixel-overlap.sh b/regress/image-sixel-overlap.sh index f1cf000ca..583f4db16 100644 --- a/regress/image-sixel-overlap.sh +++ b/regress/image-sixel-overlap.sh @@ -10,6 +10,8 @@ # covered must survive, so a second case draws a narrower image over a wider # one and expects both. +. ./image-common.inc + PATH=/bin:/usr/bin TERM=screen LC_ALL=C.UTF-8 @@ -43,7 +45,7 @@ count_images() $TMUX new-session -d -s inner -x 40 -y 10 "printf '$1'; exec sh" || exit 1 sleep 0.3 - [ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 + image_check_support $TMUX $TMUX set -as terminal-features ',*:sixel' || exit 1 $TMUX2 new-session -d -x 40 -y 10 || exit 1 diff --git a/regress/image-sixel-palette.sh b/regress/image-sixel-palette.sh new file mode 100755 index 000000000..9f529892e --- /dev/null +++ b/regress/image-sixel-palette.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +# Reduce a 512-colour cube to a SIXEL palette. Check colour error, opaque +# pixels, palette size, extremes and reproducible output after redraws. + +PATH=/bin:/usr/bin +TERM=screen +export TERM +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +python3 -B - "$TEST_TMUX" <<'PY' +import base64 +import sys +from image_test import image_test, 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) + assert images[0][2] == expected, 'original SIXEL palette changed' + test.redraw() + assert client.images() == images, 'retained palette changed on redraw' + + # Kitty input exercises RGBA quantization rather than SIXEL retention. + rgba = bytes(c for pixel in expected for c in (*pixel, 255)) + test.emit(b'\033[2J\033[H\033_Ga=T,q=2,f=32,s=32,v=16;' + + base64.b64encode(rgba) + b'\033\\') + 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-sixel-parse.sh b/regress/image-sixel-parse.sh new file mode 100755 index 000000000..db8b0919b --- /dev/null +++ b/regress/image-sixel-parse.sh @@ -0,0 +1,68 @@ +#!/bin/sh + +# Valid, fragmented and rejected SIXEL input, including the PR's stray +# character case and limits reached while growing the image. + +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 + +with image_test(sys.argv[1]) as test: + client = test.attach() + red = (255, 0, 0) + valid = sixel(8, 16, [(100, 0, 0)], [0] * (8 * 16)) + # Check a clean image and the same one with stray bytes between bands. + for wire in (valid, valid.replace(b'-', b' \t\177\200-', 1)): + test.emit(b'\033[2J\033[H' + wire) + images = client.images() + assert len(images) == 1, len(images) + check_image(images[0], 8, 16, [red] * (8 * 16)) + + # HLS uses blue at 0, red at 120 and green at 240 degrees. + for hue, rgb in [(0, (0, 0, 255)), (120, red), (240, (0, 255, 0))]: + wire = ('\033P9;1q"1;1;8;16#0;1;%d;50;100' + '#0!8~-!8~-!8N\033\\' % hue).encode() + test.emit(b'\033[2J\033[H' + wire) + assert len(client.images()) == 1 + check_image(client.images()[0], 8, 16, [rgb] * (8 * 16)) + + # Undefined registers are black in the shared model and in retained output. + test.emit(b'\033[2J\033[H\033P9;1q"1;1;8;16' + b'#5!8~-!8~-!8N\033\\') + assert len(client.images()) == 1 + check_image(client.images()[0], 8, 16, [(0, 0, 0)] * (8 * 16)) + test.redraw() + check_image(client.images()[0], 8, 16, [(0, 0, 0)] * (8 * 16)) + + # Deliver the terminator in two pieces. No image is placed before it. + test.emit(b'\033[2J\033[H') + for byte in valid[:-1]: + test.send(bytes([byte])) + client.read() + assert not client.images(), 'image placed before the DCS terminator' + assert test.run('display-message', '-p', '#{cursor_y}') == '0' + test.emit(valid[-1:]) + assert len(client.images()) == 1 + check_image(client.images()[0], 8, 16, [red] * (8 * 16)) + + rejected = [ + b'q', b'q#0;2;100', b'q!0~', b'q!10001~', b'q#1025~', + b'q#0;2;101;0;0~', b'q#0;1;361;50;100~', + b'q"1;1;10001;1', b'q"1;1;1;10001', + b'q"1;1;4096;4097', + b'q"1;1;18446744073709551616;1', + b'q"1;1;10000;1#0;2;100;0;0@' + b'-' * 300 + b'@', + b'q"1;1;1;10000#0;2;100;0;0!2000@', + ] + for body in rejected: + test.emit(b'\033[2J\033[H\033P9;1' + body + b'\033\\') + assert not client.images(), ('invalid image accepted', body[:80]) + assert test.run('display-message', '-p', '#{cursor_x},#{cursor_y}') == '0,0' + test.redraw() + assert not client.images(), ('invalid image retained', body[:80]) +PY diff --git a/regress/image-sixel-region-scroll.sh b/regress/image-sixel-region-scroll.sh index 56fab3f12..33f1a30fa 100755 --- a/regress/image-sixel-region-scroll.sh +++ b/regress/image-sixel-region-scroll.sh @@ -1,96 +1,21 @@ #!/bin/sh -# Regression test for the imagescroll terminal-feature (tty-features.c), -# SIXEL side - see image-kitty-region-scroll.sh for the Kitty side of the -# same feature: scrolling a pane that has a SIXEL image in it must not -# retransmit the image when the client's terminal has imagescroll - tmux -# trusts the terminal to have moved the image along with the rest of the -# scrolling region. Without imagescroll (the default for any terminal not -# individually confirmed and added to tty-features.c's table), today's -# always-redraw-on-scroll behaviour is unchanged. -# -# There is no way to query a terminal for whether it actually moves SIXEL -# pixels along with a scroll, so this only proves tmux's own decision to -# skip or redraw is wired correctly - not that any particular terminal -# renders the result correctly. That needs a human, on real terminals. +# The PR's SIXEL imagescroll check, with known cell pixels and no shell race. +# Check the default repaint and an overwrite merged with trusted scroll damage. PATH=/bin:/usr/bin TERM=screen -LC_ALL=C.UTF-8 -export TERM LC_ALL - +export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" -TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null" -cleanup() -{ - $TMUX kill-server >/dev/null 2>&1 - $TMUX2 kill-server >/dev/null 2>&1 -} -fail() -{ - echo "$*" >&2 - cleanup - exit 1 -} +python3 -B - "$TEST_TMUX" <<'PY' +import sys +from image_scroll import check_scroll, check_capability -cleanup - -TMP=$(mktemp) -trap "cleanup; rm -f $TMP" 0 1 15 - -HEADER='\033Pq"1;1;26;26#0;2;100;100;100#0!26~-!26~-!26~-!26~-!26B\033\\' - -# A small image near the top of a tall pane, then enough plain output below -# it to force several ordinary (no DECSTBM sub-region) linefeed scrolls, -# each one shifting the image rows along with everything else. -$TMUX new-session -d -s inner -x 40 -y 20 "printf '$HEADER'; exec sh" || - exit 1 -sleep 0.3 - -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 -$TMUX set -as terminal-features ',*:sixel' || exit 1 - -$TMUX2 new-session -d -x 40 -y 20 || exit 1 -OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1) -[ -n "$OUTER" ] || fail "No outer pane." -$TMUX2 set -as terminal-features ',*:sixel@' || fail "disable outer sixel failed" -$TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed" -$TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || fail "send attach failed" -$TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed" -sleep 2 - -grep -qa '"1;1;26;26' "$TMP" || fail "sanity: image never reached the client" - -# --- Phase 1: imagescroll granted - expect no DCS. --- -$TMUX set -as terminal-features ',*:imagescroll' || fail "grant imagescroll failed" -sleep 0.5 -: >"$TMP" -$TMUX send-keys -t inner -l "yes | head -n 30" || fail "send scroll failed" -$TMUX send-keys -t inner Enter || fail "send enter failed" -sleep 1 - -n_on=$(grep -ac "$(printf '\033P')" "$TMP") -[ "$n_on" -eq 0 ] || - fail "image was retransmitted ($n_on times) scrolling with imagescroll granted" - -# --- Phase 2: imagescroll not granted (the default) - expect the image -# back, then more scrolling to redraw it. --- -$TMUX send-keys -t inner -l "printf '$HEADER'" || fail "resend image failed" -$TMUX send-keys -t inner Enter || fail "send enter failed" -sleep 1.5 -grep -qa '"1;1;26;26' "$TMP" || fail "sanity: image did not reappear before phase 2" - -$TMUX set -as terminal-features ',*:imagescroll@' || fail "revoke imagescroll failed" -sleep 0.5 -: >"$TMP" -$TMUX send-keys -t inner -l "yes | head -n 30" || fail "send scroll failed" -$TMUX send-keys -t inner Enter || fail "send enter failed" -sleep 1 - -n_off=$(grep -ac "$(printf '\033P')" "$TMP") -[ "$n_off" -gt 0 ] || - fail "image was not retransmitted scrolling without imagescroll - expected the default always-redraw behaviour" - -exit 0 +for command in ('linefeed', 'up', 'down', 'reverse'): + check_scroll(sys.argv[1], True, command=command) + check_scroll(sys.argv[1], False, command=command) +check_scroll(sys.argv[1], True, mixed=True) +check_capability(sys.argv[1], True) +check_capability(sys.argv[1], False) +PY diff --git a/regress/image-sixel-transparency.sh b/regress/image-sixel-transparency.sh new file mode 100755 index 000000000..f8945f95a --- /dev/null +++ b/regress/image-sixel-transparency.sh @@ -0,0 +1,54 @@ +#!/bin/sh + +# Transparent overlap preserves underlying pixels; opaque background modes +# replace them. Check positions, redraws, resize and erasing stacked images. + +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 +from image_terminal import Terminal + +w, h = 24, 32 +holes = [None if x // 8 == 1 and y < 16 or (x + y) % 3 == 0 else 0 + for y in range(h) for x in range(w)] +base = sixel(w, h, [(100, 0, 0)], [0] * (w * h)) + +for p2 in (0, 1, 2): + with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(b'\033[3;3H' + base + b'\033[3;3H' + + sixel(w, h, [(0, 0, 100)], holes, p2=p2)) + background = (255, 0, 0) if p2 == 1 else (0, 0, 0) + expected = {(x + 16, y + 32): (0, 0, 255) + if holes[y * w + x] == 0 else background + for y in range(h) for x in range(w)} + for width in (40, 4, 40): + test.resize(client, width, 16) + test.redraw() + terminal = Terminal(sx=width) + terminal.feed(client.data) + terminal.check({point: c for point, c in expected.items() + if point[0] < width * 8}) + # One erase must remove the same cell from every overlapping placement. + test.emit(b'\033[3;3H\033[X') + terminal.feed(client.data) + expected = {point: c for point, c in expected.items() + if not (16 <= point[0] < 24 and 32 <= point[1] < 48)} + terminal.check(expected) + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + +with image_test(sys.argv[1]) as test: + client = test.attach() + test.emit(sixel(24, 32, [(100, 0, 0)], [None] * (24 * 32))) + assert not client.images(), 'fully transparent image produced output' + test.redraw() + assert not client.images(), 'fully transparent image survived redraw' +PY diff --git a/regress/image-splitwindow-resize-noflash.sh b/regress/image-splitwindow-resize-noflash.sh index cdb14b785..1c7542534 100755 --- a/regress/image-splitwindow-resize-noflash.sh +++ b/regress/image-splitwindow-resize-noflash.sh @@ -10,7 +10,7 @@ $TMUX new-session -d -s inner -x 60 -y 20 "printf '$SIXEL_HEADER'; exec sh" || $TMUX set -g mouse on || fail "set mouse failed" sleep 0.3 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX $TMUX set -as terminal-features ',*:sixel' || exit 1 start_capture 60 20 diff --git a/regress/image-status-pane-click-noflash.sh b/regress/image-status-pane-click-noflash.sh index fd382a65c..2b26cfb74 100755 --- a/regress/image-status-pane-click-noflash.sh +++ b/regress/image-status-pane-click-noflash.sh @@ -34,7 +34,7 @@ $TMUX split-window -h -t inner 'exec sh' || fail "split-window failed" $TMUX select-pane -t inner.0 || fail "select-pane failed" sleep 0.3 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +image_check_support $TMUX $TMUX set -as terminal-features ',*:sixel' || exit 1 start_capture 60 12 diff --git a/regress/image-support.sh b/regress/image-support.sh index c251b1911..41e97b26a 100755 --- a/regress/image-support.sh +++ b/regress/image-support.sh @@ -25,7 +25,7 @@ $TMUX new-session -d -x 20 -y 8 " sleep 10" sleep 1 -[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 101 [ "$($TMUX display-message -p '#{sixel_support}')" = 1 ] || exit 1 # Older Chafa chunks are independently padded, with nonzero unused bits. diff --git a/regress/image_scroll.py b/regress/image_scroll.py new file mode 100644 index 000000000..4de254405 --- /dev/null +++ b/regress/image_scroll.py @@ -0,0 +1,133 @@ +"""PTY checks adapted from the PR's image scroll regressions.""" + +import re +from image_test import image_test, sixel, check_image +from image_terminal import Terminal + + +MARKER = b'SCROLLMARKER' +WIRE = sixel(16, 16, [(100, 0, 0), (0, 100, 0)], + [x >= 8 for y in range(16) for x in range(16)]) + + +def attach(test, scrolls, margins=None, term='xterm-256color'): + """Choose scroll features before attaching a client.""" + features = [term, 'imagescroll' if scrolls else 'imagescroll@'] + if margins is not None: + features.append('margins' if margins else 'margins@') + test.run('set-option', '-as', 'terminal-features', ':'.join(features)) + client = test.attach(term=term) + prefix = str(client.pid) + ':' + actual = next(line[len(prefix):] for line in test.run('list-clients', + '-F', '#{client_pid}:#{client_termfeatures}').splitlines() + if line.startswith(prefix)).split(',') + assert ('imagescroll' in actual) == scrolls, actual + if margins is not None: + assert ('margins' in actual) == margins, actual + return client + + +def place(test): + """Place marker text and a two-colour image away from the scroll edges.""" + test.emit(b'\033[7;1H' + MARKER + b'\033[9;3H' + WIRE + b'\033[16;1H') + for client in test.clients: + images = client.images() + assert len(images) == 1, 'initial image was not sent' + check_image(images[0], 16, 16, + [(255, 0, 0) if x < 8 else (0, 255, 0) + for y in range(16) for x in range(16)]) + + +def check_output(client, replay): + """Check whether scroll damage replayed the image and surrounding text.""" + data = bytes(client.data) + if replay: + assert client.images(), 'image was not replayed after a manual repaint' + assert MARKER in data, 'scroll damage did not repaint surrounding text' + else: + assert not client.images(), 'native scroll replayed the image' + assert MARKER not in data, 'native scroll repainted existing text' + return data + + +def check_position(test, mixed=False, down=False): + """A later full redraw must show the scrolled image at its new position.""" + test.redraw() + row = 9 if down else 7 + expected = {(x, y): (255, 0, 0) if x < 24 else (0, 255, 0) + for y in range(row * 16, (row + 1) * 16) for x in range(16, 32) + if not mixed or x >= 24} + for client in test.clients: + terminal = Terminal() + terminal.feed(client.data) + terminal.check(expected) + + +def check_scroll(tmux, scrolls, margins=None, no_erase=False, mixed=False, + command='linefeed'): + """Check one client's native scroll or fallback repaint.""" + with image_test(tmux) as test: + test.run('set-option', '-w', 'automatic-rename', 'off') + if margins is not None: + test.run('set-option', '-w', 'pane-scrollbars', 'on') + client = attach(test, scrolls, margins) + place(test) + if no_erase: + test.redraw() + terminal = Terminal() + terminal.feed(client.data) + operation = {'linefeed': b'\n', 'up': b'\033[S', 'down': b'\033[T', + 'reverse': b'\033[H\033M'}[command] + if mixed: + # An ordinary overwrite must prevent skipping merged scroll damage. + operation += b'\033[8;3HX' + test.emit(operation) + replay = not scrolls or margins is False or mixed + data = check_output(client, replay) + if no_erase: + # Erasing the newly exposed bottom row is valid. Check that + # none of the moved image pixels were erased or drawn over. + terminal.feed(data) + terminal.check({(x, y): (255, 0, 0) if x < 24 else (0, 255, 0) + for y in range(7 * 16, 8 * 16) for x in range(16, 32)}) + if scrolls and margins: + assert re.search(rb'\033\[\d+;\d+s', data), ( + 'no horizontal scrolling region was set', data) + check_position(test, mixed, command in ('down', 'reverse')) + + +def check_clients(tmux, narrow=False): + """Check different client capabilities and shared fallback damage.""" + with image_test(tmux) as test: + test.run('set-option', '-w', 'automatic-rename', 'off') + if narrow: + test.run('set-option', '-w', 'pane-scrollbars', 'on') + native = attach(test, True, True) + repaint = attach(test, True, False, term='xterm') + else: + native = attach(test, True) + repaint = attach(test, False, term='xterm') + place(test) + test.emit(b'\n') + # A large fallback repaint adds ordinary window damage, which must + # be drawn on both clients even if the first scrolled natively. + check_output(native, narrow) + check_output(repaint, True) + check_position(test) + + +def check_capability(tmux, enabled): + """Iscrl grants image scrolling, and Iscrl@ overrides an enabled feature.""" + with image_test(tmux) as test: + test.run('set-option', '-w', 'automatic-rename', 'off') + capability = 'Iscrl' if enabled else 'Iscrl@' + test.run('set-option', '-as', 'terminal-overrides', + 'xterm-256color:' + capability) + if not enabled: + test.run('set-option', '-as', 'terminal-features', + 'xterm-256color:imagescroll') + client = test.attach() + place(test) + test.emit(b'\n') + check_output(client, not enabled) + check_position(test) diff --git a/regress/image_terminal.py b/regress/image_terminal.py new file mode 100644 index 000000000..c5ef50462 --- /dev/null +++ b/regress/image_terminal.py @@ -0,0 +1,192 @@ +"""Observe cursor positions, colours and erases in tmux's terminal output.""" + +import re +import unicodedata +from image_test import decode_sixel + + +class Terminal: + """Track image pixels and text cells for the controls used by these tests.""" + + def __init__(self, sx=40, sy=16, cw=8, ch=16): + self.sx, self.sy, self.cw, self.ch = sx, sy, cw, ch + self.x = self.y = 0 + self.top, self.bottom = 0, sy - 1 + self.fg = self.bg = None + self.bold = False + self.pixels = {} + self.cells = {} + self.pending = b'' + + def erase(self, x, y, w, h): + """Remove image pixels and text in a rectangle of cells.""" + for px, py in list(self.pixels): + if x * self.cw <= px < (x + w) * self.cw and \ + y * self.ch <= py < (y + h) * self.ch: + del self.pixels[px, py] + for cy in range(y, y + h): + for cx in range(x, x + w): + self.cells[cx, cy] = (' ', self.fg, self.bg, self.bold) + + def sgr(self, args): + """Apply text colours and bold attributes.""" + i = 0 + while i < len(args): + value = args[i] + i += 1 + if value == 0: + self.fg = self.bg = None + self.bold = False + elif value in (1, 22): + self.bold = value == 1 + elif value in (39, 49): + setattr(self, 'fg' if value == 39 else 'bg', None) + elif 30 <= value <= 37 or 40 <= value <= 47: + setattr(self, 'fg' if value < 40 else 'bg', value % 10) + elif 90 <= value <= 97 or 100 <= value <= 107: + setattr(self, 'fg' if value < 100 else 'bg', value % 10 + 8) + elif value in (38, 48): + mode = args[i] + i += 1 + assert mode in (2, 5), args + n = 3 if mode == 2 else 1 + colour = tuple(args[i:i + n]) if n == 3 else args[i] + setattr(self, 'fg' if value == 38 else 'bg', colour) + i += n + + def scroll(self, top, bottom, n): + """Move pixels and text within a vertical scroll region.""" + moved = {} + for (x, y), colour in list(self.pixels.items()): + if top * self.ch <= y < (bottom + 1) * self.ch: + del self.pixels[x, y] + if top * self.ch <= y - n * self.ch < (bottom + 1) * self.ch: + moved[x, y - n * self.ch] = colour + cells = {(x, y - n): cell for (x, y), cell in self.cells.items() + if top <= y <= bottom and top <= y - n <= bottom} + self.erase(0, top, self.sx, bottom - top + 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: + return + args = [int(p or b'0') for p in params.split(b';')] + n = args[0] or 1 + if command in b'Hf': + self.y = n - 1 + self.x = (args[1] or 1) - 1 if len(args) > 1 else 0 + elif command == ord('G'): + self.x = n - 1 + elif command == ord('d'): + self.y = n - 1 + elif command in b'ABCD': + dx, dy = {65: (0, -n), 66: (0, n), + 67: (n, 0), 68: (-n, 0)}[command] + self.x = max(0, self.x + dx) + self.y = max(0, self.y + dy) + elif command == ord('X'): + self.erase(self.x, self.y, n, 1) + elif command == ord('K'): + start = self.x if args[0] == 0 else 0 + end = self.x + 1 if args[0] == 1 else self.sx + self.erase(start, self.y, end - start, 1) + elif command == ord('J'): + if args[0] in (2, 3): + self.erase(0, 0, self.sx, self.sy) + elif args[0] == 0: + self.erase(self.x, self.y, self.sx - self.x, 1) + self.erase(0, self.y + 1, self.sx, self.sy - self.y - 1) + elif args[0] == 1: + self.erase(0, 0, self.sx, self.y) + self.erase(0, self.y, self.x + 1, 1) + elif command == ord('m'): + self.sgr(args) + elif command == ord('r'): + self.top = n - 1 + self.bottom = (args[1] or self.sy) - 1 if len(args) > 1 else self.sy - 1 + self.x = self.y = 0 + elif command in b'STLM': + top = self.y if command in b'LM' else self.top + self.scroll(top, self.bottom, n if command in b'SM' else -n) + else: + assert command in b'hlcqnt', (params, chr(command)) + + def feed(self, data): + """Apply complete output sequences, keeping any unfinished sequence.""" + data = self.pending + bytes(data) + pos = 0 + while pos < len(data): + rest = data[pos:] + if rest.startswith((b'\033P', b'\033]')): + match = re.match(rb'\033([P\]])(.*?)(?:\007|\033\\)', + rest, re.S) + if match is None: + break + if match[1] == b'P': + image = re.match(rb'([0-9;]*)q(.*)', match[2], re.S) + if image is not None: + params = image[1].split(b';') + p2 = int(params[1] or b'0') if len(params) > 1 else 0 + w, h, pixels = decode_sixel(image[2], p2) + for i, colour in enumerate(pixels): + if colour is not None: + self.pixels[self.x * self.cw + i % w, + self.y * self.ch + i // w] = colour + pos += match.end() + elif rest.startswith(b'\033['): + match = re.match(rb'\033\[([0-?]*[ -/]*)([@-~])', rest) + if match is None: + break + self.csi(match[1], match[2][0]) + pos += match.end() + elif rest[0] == 27: + match = re.match(rb'\033(?:[()][0-~]|.)', rest, re.S) + if match is None: + break + assert match[0] in (b'\033(B', b'\033(0', b'\033)B', + b'\033=', b'\033>', b'\033\\'), match[0] + pos += match.end() + elif rest[0] < 32 or rest[0] == 127: + if rest[0] == 13: + self.x = 0 + elif rest[0] == 10: + if self.y == self.bottom: + self.scroll(self.top, self.bottom, 1) + else: + self.y += 1 + elif rest[0] == 8: + self.x = max(0, self.x - 1) + pos += 1 + else: + if rest[0] < 128: + n = 1 + elif rest[0] < 224: + n = 2 + elif rest[0] < 240: + n = 3 + else: + n = 4 + if len(rest) < n: + break + char = rest[:n].decode() + width = 2 if unicodedata.east_asian_width(char) in 'WF' else 1 + if self.x >= self.sx: + self.x = 0 + self.y += 1 + self.erase(self.x, self.y, width, 1) + self.cells[self.x, self.y] = (char, self.fg, self.bg, self.bold) + self.x += width + pos += n + self.pending = data[pos:] + + def check(self, expected): + """Compare all visible image pixels, including their positions.""" + assert self.pixels.keys() == expected.keys(), ( + 'pixel positions differ', len(self.pixels), len(expected), + list(self.pixels.keys() - expected.keys())[:5], + list(expected.keys() - self.pixels.keys())[:5]) + for point, colour in expected.items(): + assert max(abs(a - b) for a, b in zip(self.pixels[point], colour)) \ + <= 3, (point, self.pixels[point], colour) diff --git a/regress/image_test.py b/regress/image_test.py new file mode 100644 index 000000000..c04470ce3 --- /dev/null +++ b/regress/image_test.py @@ -0,0 +1,307 @@ +"""Helpers for image tests using an attached client on a PTY.""" + +import contextlib +import colorsys +import errno +import fcntl +import itertools +import os +import re +import select +import signal +import struct +import subprocess +import sys +import termios +import time + + +class Client: + """An attached client and the bytes written to its terminal.""" + + def __init__(self, pid, fd): + self.pid = pid + self.fd = fd + self.data = bytearray() + + def read(self, duration=0.1): + """Read terminal output for a bounded time.""" + end = time.monotonic() + duration + while time.monotonic() < end: + if not select.select([self.fd], [], [], 0.01)[0]: + continue + try: + data = os.read(self.fd, 65536) + except OSError as e: + if e.errno == errno.EIO: + raise RuntimeError("attached client exited") from e + raise + if not data: + raise RuntimeError("attached client exited") + self.data.extend(data) + + def clear(self): + """Finish reading earlier output before starting a new check.""" + self.read() + self.data.clear() + + def images(self): + """Decode complete SIXEL sequences from the terminal output.""" + images = [] + for params, body in re.findall( + rb"\033P([0-9;]*)q(.*?)\033\\", self.data, re.S): + params = params.split(b";") + p2 = int(params[1] or b"0") if len(params) > 1 else 0 + images.append(decode_sixel(body, p2)) + return images + + +class ImageTest: + """A server with one pane which copies input directly to its output.""" + + serial = itertools.count() + + def __init__(self, tmux, sx=40, sy=16): + # Short, unique socket labels also fit macOS's socket path limit. + self.server = [tmux, "-Lis%d-%d" % (os.getpid(), next(self.serial)), + "-f/dev/null"] + self.sx = sx + self.sy = sy + self.clients = [] + self.marker = 0 + + def run(self, *args, check=True, input=None): + """Run a command on this server.""" + p = subprocess.run(self.server + list(args), capture_output=True, + input=input, timeout=10) + if check and p.returncode != 0: + raise RuntimeError("%r: %s" % (args, p.stderr.decode())) + return p.stdout.decode().strip() + + def wait(self, predicate): + """Read client output while waiting for a server state change.""" + end = time.monotonic() + 10 + while True: + for client in self.clients: + client.read(0.01) + if predicate(): + return + if time.monotonic() >= end: + raise RuntimeError("timed out waiting for server state") + time.sleep(0.01) + + def start(self): + """Start the pane and skip when the server has no image support.""" + self.run("new-session", "-d", "-s", "image", "-x", str(self.sx), + "-y", str(self.sy), "stty raw -echo; " + "printf '\033]2;READY\007'; exec cat") + support = self.run("display-message", "-p", "#{image_support}") + if support == "0": + sys.exit(101) + assert support == "1", ("unexpected image_support", support) + self.run("set-option", "-g", "status", "off") + self.wait(lambda: self.run("display-message", "-p", + "#{pane_title}") == "READY") + + def attach(self, graphical=True, utf8=True, cw=8, ch=16, term=None): + """Attach a client with a known cell pixel size and image feature.""" + if term is None: + term = "xterm-256color" if graphical else "screen-256color" + features = term + (":sixel" if graphical else ":sixel@") + self.run("set-option", "-as", "terminal-features", features) + pid, fd = os.forkpty() + if pid == 0: + try: + os.environ.pop("TMUX", None) + os.environ["TERM"] = term + os.environ["LC_ALL"] = "C.UTF-8" if utf8 else "C" + fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", + self.sy, self.sx, self.sx * cw, self.sy * ch)) + args = self.server + (["-u"] if utf8 else []) + os.execv(self.server[0], args + + ["attach-session", "-t", "image"]) + finally: + os._exit(1) + client = Client(pid, fd) + self.clients.append(client) + self.wait(lambda: any(line.startswith(str(pid) + ":") for line in + self.run("list-clients", "-F", "#{client_pid}:").splitlines())) + # Answer DA so the startup timeout cannot schedule a later redraw. + os.write(fd, b"\033[?65;4c" if graphical else b"\033[?65c") + self.wait(lambda: self.client_size(client) == + "%dx%d:%dx%d" % (self.sx, self.sy, cw, ch)) + if len(self.clients) == 1 and cw != 0 and ch != 0: + self.wait(lambda: self.run("display-message", "-p", + "#{window_cell_width}x#{window_cell_height}") == + "%dx%d" % (cw, ch)) + client.name = next(line.split(":", 1)[1] for line in + self.run("list-clients", "-F", + "#{client_pid}:#{client_name}").splitlines() + if line.startswith(str(pid) + ":")) + client.read(0.2) + client.clear() + return client + + def client_size(self, client): + """Get the character and cell pixel size of one client.""" + prefix = str(client.pid) + ":" + return next((line[len(prefix):] for line in self.run("list-clients", + "-F", "#{client_pid}:#{client_width}x#{client_height}:" + "#{client_cell_width}x#{client_cell_height}").splitlines() + if line.startswith(prefix)), None) + + def send(self, data): + """Send literal bytes to the pane.""" + if len(data) > 512: + self.run("load-buffer", "-b", "image", "-", input=data) + self.run("paste-buffer", "-b", "image", "-S", "-r", "-d") + else: + self.run("send-keys", "-H", *["%02x" % ch for ch in data]) + + def emit(self, data): + """Send pane output and wait until its trailing title is parsed.""" + for client in self.clients: + client.clear() + self.marker += 1 + title = "image-%d" % self.marker + self.send(data + b"\033]2;" + title.encode() + b"\007") + self.wait(lambda: self.run("display-message", "-p", + "#{pane_title}") == title) + for client in self.clients: + client.read(0.2) + + def redraw(self): + """Request a full redraw and collect its terminal output.""" + for client in self.clients: + client.clear() + for client in self.clients: + self.run("refresh-client", "-t", client.name) + for client in self.clients: + client.read(0.2) + + def resize(self, client, sx, sy, cw=8, ch=16): + """Resize one client's terminal and wait for the window size.""" + client.clear() + fcntl.ioctl(client.fd, termios.TIOCSWINSZ, + struct.pack("HHHH", sy, sx, sx * cw, sy * ch)) + os.kill(client.pid, signal.SIGWINCH) + self.wait(lambda: self.client_size(client) == + "%dx%d:%dx%d" % (sx, sy, cw, ch)) + self.wait(lambda: self.run("display-message", "-p", + "#{window_width}x#{window_height}") == "%dx%d" % (sx, sy)) + client.read(0.2) + + def close(self): + """Stop the server and reap every attached client.""" + self.run("kill-server", check=False) + for client in self.clients: + os.close(client.fd) + if os.waitpid(client.pid, os.WNOHANG) == (0, 0): + os.kill(client.pid, signal.SIGTERM) + os.waitpid(client.pid, 0) + + +@contextlib.contextmanager +def image_test(tmux, sx=40, sy=16): + """Clean up the server even when a test fails or skips.""" + test = ImageTest(tmux, sx, sy) + try: + test.start() + yield test + finally: + test.close() + + +def sixel(w, h, palette, pixels, p2=1): + """Build SIXEL input from a palette and row-major palette indexes.""" + assert len(pixels) == w * h + out = ['\033P9;%dq"1;1;%d;%d' % (p2, w, h)] + for c, rgb in enumerate(palette): + out.append("#%d;2;%d;%d;%d" % (c, *rgb)) + for y in range(0, h, 6): + planes = [] + for c in range(len(palette)): + columns = [] + for x in range(w): + bits = sum(1 << bit for bit in range(min(6, h - y)) + if pixels[(y + bit) * w + x] == c) + columns.append(chr(0x3f + bits)) + if all(ch == "?" for ch in columns): + continue + plane = "#%d" % c + for ch, run in itertools.groupby(columns): + n = len(list(run)) + plane += "!%d%s" % (n, ch) if n > 3 else ch * n + planes.append(plane) + out.append("$".join(planes)) + if y + 6 < h: + out.append("-") + out.append("\033\\") + return "".join(out).encode() + + +def decode_sixel(body, p2=1): + """Read RGB output pixels without using tmux's SIXEL decoder.""" + attributes = re.match(rb'"1;1;(\d+);(\d+)', body) + assert attributes is not None, ("missing raster attributes", body[:80]) + w, h = map(int, attributes.groups()) + pixels = [None if p2 == 1 else (0, 0, 0)] * (w * h) + palette = {} + c = x = y = 0 + pos = attributes.end() + while pos < len(body): + ch = body[pos] + pos += 1 + if ch == ord("#"): + match = re.match(rb"(\d+)(?:;([12]);(\d+);(\d+);(\d+))?", body[pos:]) + assert match is not None, body[pos:pos + 80] + c = int(match[1]) + if match[2] is not None: + values = tuple(int(match[i]) for i in range(3, 6)) + if match[2] == b"1": + hue, lightness, saturation = values + rgb = colorsys.hls_to_rgb(((hue + 240) % 360) / 360, + lightness / 100, saturation / 100) + palette[c] = tuple(int(v * 255 + .5) for v in rgb) + else: + assert all(v <= 100 for v in values), values + palette[c] = tuple((v * 255 + 50) // 100 for v in values) + pos += match.end() + continue + if ch == ord("$"): + x = 0 + continue + if ch == ord("-"): + x = 0 + y += 6 + continue + n = 1 + if ch == ord("!"): + match = re.match(rb"(\d+)(.)", body[pos:], re.S) + assert match is not None + n = int(match[1]) + ch = match[2][0] + pos += match.end() + assert 0x3f <= ch <= 0x7e, ch + for bit in range(6): + if not (ch - 0x3f) & (1 << bit): + continue + assert y + bit < h and x + n <= w, (x, y, n, bit, w, h) + assert c in palette, ("undefined output colour", c) + for xx in range(x, x + n): + pixels[(y + bit) * w + xx] = palette[c] + x += n + return w, h, pixels + + +def check_image(image, w, h, expected): + """Compare decoded output with expected RGB pixels and transparency.""" + assert image[:2] == (w, h), (image[:2], (w, h)) + assert len(expected) == w * h + for i, (actual, wanted) in enumerate(zip(image[2], expected)): + if wanted is None: + assert actual is None, (i % w, i // w, actual, wanted) + else: + assert actual is not None, (i % w, i // w, actual, wanted) + assert max(abs(a - b) for a, b in zip(actual, wanted)) <= 3, ( + i % w, i // w, actual, wanted) diff --git a/regress/tty-keys-replies.sh b/regress/tty-keys-replies.sh index e22442ae4..b8b564e7c 100755 --- a/regress/tty-keys-replies.sh +++ b/regress/tty-keys-replies.sh @@ -9,7 +9,7 @@ TERM=screen [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) -python3 - "$TEST_TMUX" <<'PY' +python3 -B - "$TEST_TMUX" <<'PY' import fcntl import os import re diff --git a/screen-redraw.c b/screen-redraw.c index 3191a7721..d6e519617 100644 --- a/screen-redraw.c +++ b/screen-redraw.c @@ -1,4 +1,4 @@ -/* $OpenBSD: screen-redraw.c,v 1.163 2026/10/02 12:48:52 nicm Exp $ */ +/* $OpenBSD: screen-redraw.c,v 1.164 2026/10/14 10:34:14 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -98,11 +98,12 @@ enum redraw_span_type { #define REDRAW_ALL 0x7fffffff #define REDRAW_IS_ALL(flags) ((flags) == REDRAW_ALL) -enum redraw_image_phase { +enum redraw_phase { REDRAW_IMAGES_BEFORE, REDRAW_TEXT, REDRAW_IMAGES_AFTER }; +#define REDRAW_PHASE_COUNT 3 /* UTF-8 isolate characters. */ #define REDRAW_START_ISOLATE "\342\201\246" @@ -216,10 +217,11 @@ struct redraw_damage { u_int y; u_int sx; u_int sy; + int flags; #define REDRAW_DAMAGE_SCROLL 0x1 - TAILQ_ENTRY(redraw_damage) entry; + TAILQ_ENTRY(redraw_damage) entry; }; /* @@ -1109,9 +1111,7 @@ redraw_free_damage(struct window *w) free(rd); } w->damage_count = 0; -#ifdef ENABLE_IMAGES - w->image_scroll_pending = 0; -#endif + w->scroll_pending = 0; } /* Collapse all pending damage for a window into one rectangle. */ @@ -1154,7 +1154,7 @@ redraw_collapse_damage(struct window *w) /* Record window damage, merging nearby rectangles and limiting the count. */ static void -redraw_damage_window_flags(struct window *w, u_int x, u_int y, u_int sx, +redraw_apply_window_damage(struct window *w, u_int x, u_int y, u_int sx, u_int sy, int flags) { struct redraw_damage *rd; @@ -1171,9 +1171,8 @@ redraw_damage_window_flags(struct window *w, u_int x, u_int y, u_int sx, TAILQ_FOREACH(rd, &w->damage, entry) { if (x > rd->x + rd->sx || rd->x > x + sx || - y > rd->y + rd->sy || rd->y > y + sy) { + y > rd->y + rd->sy || rd->y > y + sy) continue; - } x0 = (x < rd->x) ? x : rd->x; y0 = (y < rd->y) ? y : rd->y; @@ -1208,44 +1207,44 @@ redraw_damage_window_flags(struct window *w, u_int x, u_int y, u_int sx, void redraw_damage_window(struct window *w, u_int x, u_int y, u_int sx, u_int sy) { - redraw_damage_window_flags(w, x, y, sx, sy, 0); + redraw_apply_window_damage(w, x, y, sx, sy, 0); } -#ifdef ENABLE_IMAGES -/* Record damage caused by an image moving with a terminal scroll. */ +/* Record damage caused by terminal scrolling. */ void -redraw_damage_window_scroll(struct window *w, u_int x, u_int y, u_int sx, - u_int sy) +redraw_damage_scroll(struct window *w, u_int x, u_int y, u_int sx, u_int sy) { - if (!w->image_scroll_pending) { - w->image_scroll_epoch++; - w->image_scroll_pending = 1; + if (!w->scroll_pending) { + w->scroll_generation++; + w->scroll_pending = 1; } - redraw_damage_window_flags(w, x, y, sx, sy, REDRAW_DAMAGE_SCROLL); + redraw_apply_window_damage(w, x, y, sx, sy, REDRAW_DAMAGE_SCROLL); } /* Remember whether this client used terminal scrolling for the damage. */ void -redraw_image_scroll_result(struct tty *tty, const struct tty_ctx *ctx, - int failed) +redraw_scroll_result(struct tty *tty, const struct tty_ctx *ctx, int failed) { struct window_pane *wp = ctx->arg; struct window *w; - if (wp == NULL || (w = wp->window) == NULL || !w->image_scroll_pending) + if (wp == NULL) return; - if (tty->image_scroll_window != w || - tty->image_scroll_epoch != w->image_scroll_epoch) { - tty->image_scroll_window = w; - tty->image_scroll_epoch = w->image_scroll_epoch; - tty->image_scroll_failed = 0; + w = wp->window; + if (!w->scroll_pending) + return; + if (tty->scroll_window != w || + tty->scroll_generation != w->scroll_generation) { + tty->scroll_window = w; + tty->scroll_generation = w->scroll_generation; + tty->scroll_failed = 0; } if (failed) - tty->image_scroll_failed = 1; + tty->scroll_failed = 1; } -#endif #ifdef ENABLE_IMAGES +/* XXX not sure if they is needed */ /* Record damage for every pane border-status row in a window. */ static void redraw_damage_window_pane_status(struct window *w) @@ -1312,8 +1311,7 @@ redraw_get_scene(struct client *c) /* Draw a pane span. */ static void redraw_draw_pane_span(struct redraw_draw_ctx *dctx, - struct redraw_span *span, u_int x, u_int y, u_int n, - enum redraw_image_phase phase) + struct redraw_span *span, u_int x, u_int y, u_int n) { struct redraw_scene *scene = dctx->scene; struct client *c = scene->c; @@ -1331,15 +1329,7 @@ redraw_draw_pane_span(struct redraw_draw_ctx *dctx, px = span->data.p.px + (x - span->x); py = span->data.p.py; -#ifdef ENABLE_IMAGES - if (phase != REDRAW_TEXT) { - image_draw_line(tty, s, px, py, n, x, y, - phase == REDRAW_IMAGES_BEFORE); - return; - } -#endif - if (phase == REDRAW_TEXT) - tty_draw_line(tty, s, px, py, n, x, y, &style_ctx); + tty_draw_line(tty, s, px, py, n, x, y, &style_ctx); } /* Get default border style for spans without a pane. */ @@ -1594,10 +1584,11 @@ redraw_draw_menu_span(struct redraw_draw_ctx *dctx, /* Draw a span. */ static void redraw_draw_span(struct redraw_draw_ctx *dctx, struct redraw_span *span, - u_int y, enum redraw_image_phase phase) + u_int y, enum redraw_phase phase) { struct redraw_span_data *data = &span->data; enum redraw_span_type type = data->type; + u_int width = span->width; if (type == REDRAW_SPAN_STATUS && ~data->st.wp->flags & PANE_NEWSTATUS) return; @@ -1606,22 +1597,29 @@ redraw_draw_span(struct redraw_draw_ctx *dctx, struct redraw_span *span, switch (span->data.type) { case REDRAW_SPAN_PANE: - redraw_draw_pane_span(dctx, span, span->x, y, span->width, - phase); + if (phase == REDRAW_TEXT) + redraw_draw_pane_span(dctx, span, span->x, y, width); +#ifdef ENABLE_IMAGES + else { + image_draw_line(&dctx->scene->c->tty, data->p.wp->screen, + data->p.px, data->p.py, width, span->x, y, + phase == REDRAW_IMAGES_BEFORE); + } +#endif break; case REDRAW_SPAN_BORDER: case REDRAW_SPAN_EMPTY: case REDRAW_SPAN_OUTSIDE: - redraw_draw_border_span(dctx, span, span->x, y, span->width); + redraw_draw_border_span(dctx, span, span->x, y, width); break; case REDRAW_SPAN_STATUS: - redraw_draw_status_span(dctx, span, span->x, y, span->width); + redraw_draw_status_span(dctx, span, span->x, y, width); break; case REDRAW_SPAN_SCROLLBAR: - redraw_draw_scrollbar_span(dctx, span, span->x, y, span->width); + redraw_draw_scrollbar_span(dctx, span, span->x, y, width); break; case REDRAW_SPAN_MENU: - redraw_draw_menu_span(dctx, span, span->x, y, span->width); + redraw_draw_menu_span(dctx, span, span->x, y, width); break; } } @@ -1635,7 +1633,7 @@ redraw_draw_pane_lines(struct redraw_draw_ctx *dctx, struct window_pane *wp, struct redraw_line *line; struct redraw_spans *spans; struct redraw_span *span; - enum redraw_image_phase phase; + enum redraw_phase phase; u_int cy; int y, top, bottom; @@ -1669,8 +1667,7 @@ redraw_draw_pane_lines(struct redraw_draw_ctx *dctx, struct window_pane *wp, #endif /* Draw images behind text, then text, then images above it. */ - for (phase = REDRAW_IMAGES_BEFORE; phase <= REDRAW_IMAGES_AFTER; - phase++) { + for (phase = 0; phase < REDRAW_PHASE_COUNT; phase++) { for (y = top; y < bottom; y++) { line = &scene->lines[y]; if (dctx->flags & REDRAW_STATUS_TOP) @@ -1680,20 +1677,18 @@ redraw_draw_pane_lines(struct redraw_draw_ctx *dctx, struct window_pane *wp, if (flags & REDRAW_PANE) { spans = &line->spans[REDRAW_SPAN_PANE]; TAILQ_FOREACH(span, spans, entry) { - if (span->data.p.wp == wp) { - redraw_draw_span(dctx, span, cy, - phase); - } + if (span->data.p.wp != wp) + continue; + redraw_draw_span(dctx, span, cy, phase); } } if (phase == REDRAW_TEXT && (flags & REDRAW_PANE_SCROLLBAR)) { spans = &line->spans[REDRAW_SPAN_SCROLLBAR]; TAILQ_FOREACH(span, spans, entry) { - if (span->data.sb.wp == wp) { - redraw_draw_span(dctx, span, cy, - phase); - } + if (span->data.sb.wp != wp) + continue; + redraw_draw_span(dctx, span, cy, phase); } } } @@ -1707,6 +1702,36 @@ redraw_draw_pane_lines(struct redraw_draw_ctx *dctx, struct window_pane *wp, #endif } +/* Return whether to draw a span type in this phase for these flags. */ +static int +redraw_should_draw(enum redraw_span_type type, int flags, + enum redraw_phase phase) +{ + if (phase != REDRAW_TEXT && type != REDRAW_SPAN_PANE) + return (0); + if (REDRAW_IS_ALL(flags)) + return (1); + + switch (type) { + case REDRAW_SPAN_PANE: + return (flags & REDRAW_PANE); + case REDRAW_SPAN_OUTSIDE: + return (flags & REDRAW_OUTSIDE); + case REDRAW_SPAN_EMPTY: + return (flags & REDRAW_EMPTY); + case REDRAW_SPAN_BORDER: + return (flags & REDRAW_PANE_BORDER); + case REDRAW_SPAN_STATUS: + return (flags & REDRAW_PANE_STATUS); + case REDRAW_SPAN_SCROLLBAR: + return (flags & REDRAW_PANE_SCROLLBAR); + case REDRAW_SPAN_MENU: + return (flags & REDRAW_MENU); + default: + return (0); + } +} + /* Draw lines. */ static void redraw_draw_lines(struct redraw_draw_ctx *dctx, int flags) @@ -1715,12 +1740,11 @@ redraw_draw_lines(struct redraw_draw_ctx *dctx, int flags) struct redraw_line *line; struct redraw_spans *spans; struct redraw_span *span; - enum redraw_image_phase phase; + enum redraw_phase phase; u_int y, cy, type; - /* Draw each image layer around the text spans. */ - for (phase = REDRAW_IMAGES_BEFORE; phase <= REDRAW_IMAGES_AFTER; - phase++) { + /* Draw each phase. */ + for (phase = 0; phase < REDRAW_PHASE_COUNT; phase++) { for (y = 0; y < scene->sy; y++) { line = &scene->lines[y]; if (dctx->flags & REDRAW_STATUS_TOP) @@ -1728,44 +1752,8 @@ redraw_draw_lines(struct redraw_draw_ctx *dctx, int flags) else cy = y; for (type = 0; type < REDRAW_SPAN_TYPES; type++) { - if (phase != REDRAW_TEXT && - type != REDRAW_SPAN_PANE) { + if (!redraw_should_draw(type, flags, phase)) continue; - } - if (!REDRAW_IS_ALL(flags)) { - switch (type) { - case REDRAW_SPAN_PANE: - if (~flags & REDRAW_PANE) - continue; - break; - case REDRAW_SPAN_OUTSIDE: - if (~flags & REDRAW_OUTSIDE) - continue; - break; - case REDRAW_SPAN_EMPTY: - if (~flags & REDRAW_EMPTY) - continue; - break; - case REDRAW_SPAN_BORDER: - if (~flags & REDRAW_PANE_BORDER) - continue; - break; - case REDRAW_SPAN_STATUS: - if (~flags & REDRAW_PANE_STATUS) - continue; - break; - case REDRAW_SPAN_SCROLLBAR: - if (~flags & REDRAW_PANE_SCROLLBAR) - continue; - break; - case REDRAW_SPAN_MENU: - if (~flags & REDRAW_MENU) - continue; - break; - default: - continue; - } - } spans = &line->spans[type]; TAILQ_FOREACH(span, spans, entry) redraw_draw_span(dctx, span, cy, phase); @@ -2050,7 +2038,7 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) } #ifdef ENABLE_IMAGES if ((flags & REDRAW_PANE) && - (image_backend_flags(tty) & + (image_backend_get_flags(tty) & (IMAGE_BACKEND_GRAPHICAL|IMAGE_BACKEND_CLIPPED)) == IMAGE_BACKEND_GRAPHICAL) { redraw_damage_window_pane_status(scene->w); @@ -2216,7 +2204,7 @@ redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y, struct redraw_line *line; struct redraw_spans *spans; struct redraw_span *span; - enum redraw_image_phase phase; + enum redraw_phase phase; u_int cy, yy, type; if (x >= scene->sx || y >= scene->sy) @@ -2253,8 +2241,7 @@ redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y, #endif /* Compose image layers and text across the damaged spans. */ - for (phase = REDRAW_IMAGES_BEFORE; phase <= REDRAW_IMAGES_AFTER; - phase++) { + for (phase = 0; phase < REDRAW_PHASE_COUNT; phase++) { for (yy = y; yy < y + sy; yy++) { line = &scene->lines[yy]; if (dctx->flags & REDRAW_STATUS_TOP) @@ -2263,9 +2250,8 @@ redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y, cy = yy; for (type = 0; type < REDRAW_SPAN_TYPES; type++) { if (phase != REDRAW_TEXT && - type != REDRAW_SPAN_PANE) { + type != REDRAW_SPAN_PANE) continue; - } if (type == REDRAW_SPAN_STATUS) continue; spans = &line->spans[type]; @@ -2310,46 +2296,17 @@ redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y, } } -/* Compose one damaged rectangle, clipped to what this client can see. */ -static void -redraw_client_damage_rect(struct client *c, struct redraw_draw_ctx *dctx, - __unused struct window *w, u_int ox, u_int oy, u_int sx, u_int sy, - struct redraw_damage *rd) -{ - u_int x0, y0, x1, y1; - -#ifdef ENABLE_IMAGES - /* Skip scroll damage already handled by this terminal. */ - if ((rd->flags & REDRAW_DAMAGE_SCROLL) && - (image_backend_flags(&c->tty) & IMAGE_BACKEND_SCROLLS) && - c->tty.image_scroll_window == w && - c->tty.image_scroll_epoch == w->image_scroll_epoch && - !c->tty.image_scroll_failed) { - return; - } -#endif - x0 = (rd->x > ox) ? rd->x : ox; - y0 = (rd->y > oy) ? rd->y : oy; - x1 = (rd->x + rd->sx < ox + sx) ? rd->x + rd->sx : ox + sx; - y1 = (rd->y + rd->sy < oy + sy) ? rd->y + rd->sy : oy + sy; - if (x0 >= x1 || y0 >= y1) - return; - log_debug("%s: %s composing damage %u,%u %ux%u", __func__, c->name, - x0 - ox, y0 - oy, x1 - x0, y1 - y0); - redraw_draw_damage_rectangle(dctx, x0 - ox, y0 - oy, x1 - x0, - y1 - y0); -} - /* Draw pending window damage on this client. */ void redraw_client_damage(struct client *c) { struct window *w = c->session->curw->window; + struct tty *tty = &c->tty; struct window_pane *wp; struct redraw_scene *scene; struct redraw_draw_ctx dctx; struct redraw_damage *rd; - u_int ox, oy, sx, sy; + u_int ox, oy, sx, sy, x0, y0, x1, y1; if (TAILQ_EMPTY(&w->damage)) return; @@ -2366,9 +2323,25 @@ redraw_client_damage(struct client *c) wp->active_border_gc_set = 0; } - tty_sync_start(&c->tty); - tty_update_mode(&c->tty, c->tty.mode & ~CURSOR_MODES, NULL); + tty_sync_start(tty); + tty_update_mode(tty, tty->mode & ~CURSOR_MODES, NULL); - TAILQ_FOREACH(rd, &w->damage, entry) - redraw_client_damage_rect(c, &dctx, w, ox, oy, sx, sy, rd); + TAILQ_FOREACH(rd, &w->damage, entry) { +#ifdef ENABLE_IMAGES + if ((rd->flags & REDRAW_DAMAGE_SCROLL) && + (image_backend_get_flags(tty) & IMAGE_BACKEND_SCROLLS) && + tty->scroll_window == w && + tty->scroll_generation == w->scroll_generation && + !tty->scroll_failed) + continue; +#endif + x0 = (rd->x > ox) ? rd->x : ox; + y0 = (rd->y > oy) ? rd->y : oy; + x1 = (rd->x + rd->sx < ox + sx) ? rd->x + rd->sx : ox + sx; + y1 = (rd->y + rd->sy < oy + sy) ? rd->y + rd->sy : oy + sy; + if (x0 < x1 && y0 < y1) { + redraw_draw_damage_rectangle(&dctx, x0 - ox, y0 - oy, + x1 - x0, y1 - y0); + } + } } diff --git a/screen-write.c b/screen-write.c index 866401e39..6caac2b4b 100644 --- a/screen-write.c +++ b/screen-write.c @@ -730,6 +730,10 @@ screen_write_fast_copy(struct screen_write_ctx *ctx, struct screen *src, grid_get_cell(gd, xx, yy, &gc); if (xx + gc.data.width > px + nx) break; +#ifdef ENABLE_IMAGES + if (ctx->flags & SCREEN_WRITE_INPUT) + image_damage_screen(ctx, s->cx, s->cy, 1, 1); +#endif grid_view_set_cell(s->grid, s->cx, s->cy, &gc); if (!window_position_is_visible(r, xoff + s->cx)) diff --git a/tmux-image.h b/tmux-image.h index aed392518..c13a7a4df 100644 --- a/tmux-image.h +++ b/tmux-image.h @@ -32,53 +32,16 @@ struct screen_write_ctx; struct sixel_image; struct tty; struct grid_cell; -struct image_rect; struct image_backend; +/* Maximum image size in bytes. */ #define IMAGE_SIZE_LIMIT (64 * 1024 * 1024) +/* Backend draws images as graphics. */ #define IMAGE_BACKEND_GRAPHICAL 0x1 +/* Backend images move with terminal scrolling. */ #define IMAGE_BACKEND_SCROLLS 0x2 #define IMAGE_BACKEND_CLIPPED 0x4 -/* Private ACS keys used by the text image backend. */ -#define TTY_ACS_IMAGE_SHADE_LIGHT 0x80 -#define TTY_ACS_IMAGE_SHADE_MEDIUM 0x81 -#define TTY_ACS_IMAGE_SHADE_DARK 0x82 -#define TTY_ACS_IMAGE_BLOCK 0x83 -#define TTY_ACS_IMAGE_HALF_UPPER 0x84 -#define TTY_ACS_IMAGE_HALF_LOWER 0x85 -#define TTY_ACS_IMAGE_HALF_LEFT 0x86 -#define TTY_ACS_IMAGE_HALF_RIGHT 0x87 -#define TTY_ACS_IMAGE_QUADRANT_LOWER_LEFT 0x88 -#define TTY_ACS_IMAGE_QUADRANT_LOWER_RIGHT 0x89 -#define TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT 0x8a -#define TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_LOWER_LEFT_LOWER_RIGHT 0x8b -#define TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_LOWER_RIGHT 0x8c -#define TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_UPPER_RIGHT_LOWER_LEFT 0x8d -#define TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_UPPER_RIGHT_LOWER_RIGHT 0x8e -#define TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT 0x8f -#define TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT_LOWER_LEFT 0x90 -#define TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT_LOWER_LEFT_LOWER_RIGHT 0x91 -#define TTY_ACS_IMAGE_SEXTANT_FIRST 0x92 -#define TTY_ACS_IMAGE_SEXTANT_LAST 0xcd - -/* Averaged RGBA colour and brightness used by the text image backend. */ -struct image_sample { - u_char red; - u_char green; - u_char blue; - u_char alpha; - u_char brightness; -}; -#define IMAGE_SAMPLE_COLUMNS 2 -#define IMAGE_SAMPLE_ROWS 6 - -/* Whole-cell and subcell samples generated lazily for one image cell. */ -struct image_cell { - struct image_sample whole; - struct image_sample samples[IMAGE_SAMPLE_ROWS][IMAGE_SAMPLE_COLUMNS]; -}; - enum kitty_parse_status { KITTY_PARSE_ERROR = -1, KITTY_PARSE_OK, @@ -135,6 +98,24 @@ struct image_view { u_int sy; }; +/* A part of an image to draw. */ +struct image_rect { + struct image *image; + int64_t z; + + /* Size in cells. */ + u_int sx; + u_int sy; + + /* Source in image in cells. */ + u_int src_x; + u_int src_y; + + /* Destination on terminal in cells. */ + u_int dst_x; + u_int dst_y; +}; + /* image.c */ struct image *image_create(u_int, u_int, u_int, u_int, u_int, u_int, u_char *); @@ -156,10 +137,7 @@ void image_set_sixel(struct image *, struct sixel_image *); u_int image_get_source_id(struct image *); u_int image_get_order_id(struct image *); void image_set_order_id(struct image *, u_int); -void *image_get_fallback_data(struct image *); -void image_set_fallback_data(struct image *, void *); -const struct image_cell *image_get_cell(struct image *, u_int, u_int); -void image_get_pixel_rect(struct image *, 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 *); 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, u_int *, @@ -200,8 +178,10 @@ void image_write_kitty(struct screen_write_ctx *, struct image *, u_int, void image_clear(struct screen_write_ctx *, u_int); void image_clear_kitty(struct screen_write_ctx *, const struct kitty_parse_result *); -int image_get_fallback_at(struct tty *, struct screen *, u_int, u_int, - const struct grid_cell *, struct grid_cell *); +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, + u_int, const struct grid_cell *, struct grid_cell *, int *); struct image *image_rect_get_image(const struct image_rect *); void image_rect_get_coords(const struct image_rect *, u_int *, u_int *, u_int *, u_int *, u_int *, u_int *); @@ -211,15 +191,16 @@ void image_draw_line(struct tty *, struct screen *, u_int, u_int, u_int, void image_redraw_start(struct tty *, u_int, u_int, u_int, u_int); void image_redraw_finish(struct tty *); void image_draw_flush(struct tty *); -int image_backend_flags(struct tty *); +int image_backend_get_flags(struct tty *); int image_tty_update(struct tty *); -void image_tty_geometry_changed(struct tty *); +void image_tty_size_changed(struct tty *); void image_tty_free(struct tty *, int); /* image-fallback.c */ -void fallback_get_cell(struct tty *, struct image *, u_int, u_int, - const struct grid_cell *, struct grid_cell *); -void fallback_free(struct image *); +int fallback_select(struct tty *); +int fallback_get_flags(struct tty *); +const struct grid_cell *fallback_map_cell(struct tty *, struct grid *, u_int, + u_int, const struct grid_cell *, struct grid_cell *, int *); /* image-kitty.c */ struct image *kitty_parse_image(void **, const u_char *, size_t, u_int, @@ -230,6 +211,8 @@ int kitty_cell_is_placeholder(const struct grid_cell *); void kitty_delete_images(void *, struct screen_write_ctx *, const struct kitty_parse_result *); void kitty_free_state(void *); +int kitty_select(struct tty *); +int kitty_get_flags(struct tty *); void kitty_draw_rect(struct tty *, const struct image_rect *); void kitty_redraw_start(struct tty *, u_int, u_int, u_int, u_int); void kitty_redraw_finish(struct tty *); @@ -237,6 +220,8 @@ void kitty_free_output_state(struct tty *, int); /* image-sixel.c */ #define SIXEL_COLOUR_REGISTERS 1024 +int sixel_select(struct tty *); +int sixel_get_flags(struct tty *); struct sixel_image *sixel_parse(const char *, size_t, u_int, u_int, u_int, u_int); void sixel_write(struct screen_write_ctx *, struct sixel_image *, diff --git a/tmux.1 b/tmux.1 index c4bb7e2d9..7b4bd7c45 100644 --- a/tmux.1 +++ b/tmux.1 @@ -5194,12 +5194,8 @@ Supports extended keys. Supports focus reporting. .It hyperlinks Supports OSC 8 hyperlinks. -.It image-quadrants -Allows image fallbacks to use Unicode quadrant block characters. -.It image-sextants -Allows image fallbacks to use Unicode sextant block characters. .It imagescroll -Moves image content along with scrolling regions. +Moves image content with scroll regions. .It ignorefkeys Ignore function keys from .Xr terminfo 5 @@ -8990,6 +8986,8 @@ These are set automatically if the capability is present. .It Em \&Hls Set or clear a hyperlink annotation. +.It Em \&Iscrl +Indicates that the terminal moves image content with scroll regions. .It Em \&Nobr Tell .Nm diff --git a/tmux.h b/tmux.h index bffe7b62c..faec1cca1 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1459 2026/10/13 10:21:58 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1461 2026/10/14 10:34:14 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -499,6 +499,7 @@ enum tty_code_code { TTYC_IND, TTYC_INDN, TTYC_INVIS, + TTYC_ISCRL, TTYC_KCBT, TTYC_KCUB1, TTYC_KCUD1, @@ -1478,10 +1479,8 @@ struct window { struct redraw_damages damage; u_int damage_count; -#ifdef ENABLE_IMAGES - uint64_t image_scroll_epoch; - int image_scroll_pending; -#endif + uint64_t scroll_generation; + int scroll_pending; struct menu_data *menu; u_int menu_last_px; @@ -1752,11 +1751,6 @@ struct tty_term { #define TERM_INVALIDMS 0x80 #define TERM_NOREPLACE 0x100 #define TERM_KITTYGRAPHICS 0x200 -#ifdef ENABLE_IMAGES -#define TERM_IMAGE_QUADRANTS 0x400 -#define TERM_IMAGE_SEXTANTS 0x800 -#define TERM_IMAGESCROLL 0x1000 -#endif int flags; LIST_ENTRY(tty_term) entry; @@ -1861,12 +1855,14 @@ struct tty { struct event key_timer; struct tty_key *key_tree; + + struct window *scroll_window; + uint64_t scroll_generation; + int scroll_failed; + #ifdef ENABLE_IMAGES - const struct image_backend *image_backend; - void *image_data; - struct window *image_scroll_window; - uint64_t image_scroll_epoch; - int image_scroll_failed; + struct image_backend *image_backend; + void *image_data; #endif }; @@ -2363,7 +2359,7 @@ struct client { int references; - int theme_colours[COLOUR_THEME_COUNT]; + int theme_colours[COLOUR_THEME_COUNT]; void *pan_window; u_int pan_ox; @@ -2957,7 +2953,7 @@ void tty_cell(struct tty *, const struct grid_cell *, const struct tty_style_ctx *); int tty_init(struct tty *, struct client *); void tty_resize(struct tty *); -void tty_set_size(struct tty *, u_int, u_int, u_int, u_int); +int tty_set_size(struct tty *, u_int, u_int, u_int, u_int); void tty_invalidate(struct tty *); void tty_start_tty(struct tty *); void tty_send_requests(struct tty *); @@ -3042,7 +3038,6 @@ void tty_default_features(struct client *, const char *, u_int); /* tty-acs.c */ int tty_acs_needed(struct tty *); const char *tty_acs_get(struct tty *, u_char); -u_char tty_acs_image_sextant(u_int); int tty_acs_reverse_get(struct tty *, const char *, size_t); const struct utf8_data *tty_acs_double_borders(int); const struct utf8_data *tty_acs_heavy_borders(int); @@ -3656,11 +3651,8 @@ int redraw_client_has_window(struct client *, struct window *); void redraw_invalidate_scene(struct window *); void redraw_invalidate_all_scenes(void); void redraw_damage_window(struct window *, u_int, u_int, u_int, u_int); -#ifdef ENABLE_IMAGES -void redraw_damage_window_scroll(struct window *, u_int, u_int, u_int, - u_int); -void redraw_image_scroll_result(struct tty *, const struct tty_ctx *, int); -#endif +void redraw_damage_scroll(struct window *, u_int, u_int, u_int, u_int); +void redraw_scroll_result(struct tty *, const struct tty_ctx *, int); void redraw_free_damage(struct window *); void redraw_client_damage(struct client *); int redraw_get_status_border_cell_type(struct redraw_span **, u_int); diff --git a/tty-acs.c b/tty-acs.c index 9c0470fc5..eedb79c2f 100644 --- a/tty-acs.c +++ b/tty-acs.c @@ -64,67 +64,9 @@ static const struct tty_acs_entry tty_acs_table[] = { { '{', "\317\200" }, /* greek pi */ { '|', "\342\211\240" }, /* not-equal */ { '}', "\302\243" }, /* UK pound sign */ - { '~', "\302\267" }, /* bullet */ -#ifdef ENABLE_IMAGES - { TTY_ACS_IMAGE_SHADE_LIGHT, "\342\226\221" }, - { TTY_ACS_IMAGE_SHADE_MEDIUM, "\342\226\222" }, - { TTY_ACS_IMAGE_SHADE_DARK, "\342\226\223" }, - { TTY_ACS_IMAGE_BLOCK, "\342\226\210" }, - { TTY_ACS_IMAGE_HALF_UPPER, "\342\226\200" }, - { TTY_ACS_IMAGE_HALF_LOWER, "\342\226\204" }, - { TTY_ACS_IMAGE_HALF_LEFT, "\342\226\214" }, - { TTY_ACS_IMAGE_HALF_RIGHT, "\342\226\220" }, - { TTY_ACS_IMAGE_QUADRANT_LOWER_LEFT, "\342\226\226" }, - { TTY_ACS_IMAGE_QUADRANT_LOWER_RIGHT, "\342\226\227" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT, "\342\226\230" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_LOWER_LEFT_LOWER_RIGHT, - "\342\226\231" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_LOWER_RIGHT, "\342\226\232" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_UPPER_RIGHT_LOWER_LEFT, - "\342\226\233" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_LEFT_UPPER_RIGHT_LOWER_RIGHT, - "\342\226\234" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT, "\342\226\235" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT_LOWER_LEFT, "\342\226\236" }, - { TTY_ACS_IMAGE_QUADRANT_UPPER_RIGHT_LOWER_LEFT_LOWER_RIGHT, - "\342\226\237" } -#endif + { '~', "\302\267" } /* bullet */ }; -#ifdef ENABLE_IMAGES -static char tty_acs_image_sextants[60][5]; - -/* Encode the Unicode sextant characters used by the image fallback. */ -static void -tty_acs_image_sextants_init(void) -{ - u_int cp, i; - - if (tty_acs_image_sextants[0][0] != '\0') - return; - for (i = 0; i < nitems(tty_acs_image_sextants); i++) { - cp = 0x1fb00 + i; - tty_acs_image_sextants[i][0] = 0xf0 | (cp >> 18); - tty_acs_image_sextants[i][1] = 0x80 | ((cp >> 12) & 0x3f); - tty_acs_image_sextants[i][2] = 0x80 | ((cp >> 6) & 0x3f); - tty_acs_image_sextants[i][3] = 0x80 | (cp & 0x3f); - } -} - -/* Return the sextant ACS key for a six-bit foreground mask. */ -u_char -tty_acs_image_sextant(u_int mask) -{ - if (mask == 0 || mask == 21 || mask == 42 || mask >= 63) - return (0); - if (mask < 21) - return (TTY_ACS_IMAGE_SEXTANT_FIRST + mask - 1); - if (mask < 42) - return (TTY_ACS_IMAGE_SEXTANT_FIRST + mask - 2); - return (TTY_ACS_IMAGE_SEXTANT_FIRST + mask - 3); -} -#endif - /* Table mapping UTF-8 to ACS entries. */ struct tty_acs_reverse_entry { const char *string; @@ -297,15 +239,6 @@ tty_acs_get(struct tty *tty, u_char ch) return (&tty->term->acs[ch][0]); } -#ifdef ENABLE_IMAGES - if (ch >= TTY_ACS_IMAGE_SEXTANT_FIRST && - ch <= TTY_ACS_IMAGE_SEXTANT_LAST) { - tty_acs_image_sextants_init(); - return (tty_acs_image_sextants[ - ch - TTY_ACS_IMAGE_SEXTANT_FIRST]); - } -#endif - /* Otherwise look up the UTF-8 translation. */ entry = bsearch(&ch, tty_acs_table, nitems(tty_acs_table), sizeof tty_acs_table[0], tty_acs_cmp); diff --git a/tty-draw.c b/tty-draw.c index 08b74efc1..431a1fd05 100644 --- a/tty-draw.c +++ b/tty-draw.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty-draw.c,v 1.18 2026/10/12 15:28:45 nicm Exp $ */ +/* $OpenBSD: tty-draw.c,v 1.19 2026/10/13 22:44:53 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -122,11 +122,6 @@ tty_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, u_int nx, struct grid *gd = s->grid; const struct grid_cell *gcp; struct grid_cell gc, ngc, last; -#ifdef ENABLE_IMAGES - struct grid_cell image_gc; - struct kitty_placeholder placeholder; - int image_status; -#endif struct grid_line *gl; u_int i, j, last_i, cx, ex, width; u_int cellsize, bg; @@ -136,6 +131,11 @@ tty_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, u_int nx, enum tty_draw_line_state current_state, next_state; struct tty_style_ctx default_style_ctx = { 0 }; const struct grid_cell *defaults; +#ifdef ENABLE_IMAGES + struct image *im; + struct kitty_placeholder placeholder; + u_int x, y; +#endif if (style_ctx == NULL) { default_style_ctx.defaults = &grid_default_cell; @@ -193,6 +193,10 @@ tty_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, u_int nx, grid_view_get_cell(gd, i, py, &gc); if (~gc.flags & GRID_FLAG_PADDING) break; +#ifdef ENABLE_IMAGES + if (image_grid_get_at(gd, i, gd->hsize + py, &im, &x, &y) == 1) + break; +#endif cx++; } if (cx != 0) { @@ -256,18 +260,14 @@ tty_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, u_int nx, if (px >= ex || i >= ex - px) { /* Outside the area being drawn. */ + empty = nx - i; gcp = &grid_default_cell; + #ifdef ENABLE_IMAGES - image_status = image_get_fallback_at(tty, s, px + i, - py, gcp, &image_gc); - if (image_status == 1) { - gcp = &image_gc; - empty = 0; - } else if (image_status == -1) - empty = 1; - else + /* Map an image cell if needed. */ + gcp = image_map_cell(tty, gd, px + i, + gd->hsize + py, gcp, &gc, &empty); #endif - empty = nx - i; /* Flush the background before clearing. */ if (empty != 0 && gcp->bg != last.bg) @@ -283,15 +283,16 @@ tty_draw_line(struct tty *tty, struct screen *s, u_int px, u_int py, u_int nx, } #endif gcp = &gc; + #ifdef ENABLE_IMAGES - if (image_get_fallback_at(tty, s, px + i, py, &gc, - &image_gc) == 1) { - gcp = &image_gc; - } + /* Map an image cell if needed. */ + gcp = image_map_cell(tty, gd, px + i, + gd->hsize + py, gcp, &gc, NULL); #endif /* Work out empty cells. */ - empty = tty_draw_line_get_empty(gcp, &last, nx - i); + empty = tty_draw_line_get_empty(gcp, &last, + nx - i); if (empty == 0) { /* Update for codeset if needed. */ gcp = tty_check_codeset(tty, gcp); diff --git a/tty-features.c b/tty-features.c index 65858888c..0a08bbedd 100644 --- a/tty-features.c +++ b/tty-features.c @@ -376,31 +376,16 @@ static const struct tty_feature tty_feature_kittygraphics = { TERM_KITTYGRAPHICS }; -#ifdef ENABLE_IMAGES -static const char *const tty_feature_image_capabilities[] = { +/* Terminal moves images with scroll regions. */ +static const char *const tty_feature_imagescroll_capabilities[] = { + "Iscrl", NULL }; -static const struct tty_feature tty_feature_image_quadrants = { - "image-quadrants", - tty_feature_image_capabilities, - TERM_IMAGE_QUADRANTS -}; -static const struct tty_feature tty_feature_image_sextants = { - "image-sextants", - tty_feature_image_capabilities, - TERM_IMAGE_SEXTANTS -}; - -/* - * Terminal moves images with a scrolling region. Text margin support does - * not imply this capability. - */ static const struct tty_feature tty_feature_imagescroll = { "imagescroll", - tty_feature_image_capabilities, - TERM_IMAGESCROLL + tty_feature_imagescroll_capabilities, + 0 }; -#endif /* Terminal supports the OSC 9;4 progress bar. */ static const char *const tty_feature_progressbar_capabilities[] = { @@ -432,12 +417,8 @@ static const struct tty_feature *const tty_features[] = { &tty_feature_extkeys, &tty_feature_focus, &tty_feature_ignorefkeys, - &tty_feature_kittygraphics, -#ifdef ENABLE_IMAGES - &tty_feature_image_quadrants, - &tty_feature_image_sextants, &tty_feature_imagescroll, -#endif + &tty_feature_kittygraphics, &tty_feature_margins, &tty_feature_mouse, &tty_feature_osc7, diff --git a/tty-keys.c b/tty-keys.c index 7031b8796..b7d127310 100644 --- a/tty-keys.c +++ b/tty-keys.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty-keys.c,v 1.217 2026/10/12 12:26:46 nicm Exp $ */ +/* $OpenBSD: tty-keys.c,v 1.218 2026/10/14 08:33:19 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -1914,7 +1914,8 @@ tty_keys_winsz(struct tty *tty, const char *buf, size_t len, size_t *size, return (0); /* Window size in characters. */ - tty_set_size(tty, sx, sy, tty->xpixel, tty->ypixel); + if (tty_set_size(tty, sx, sy, tty->xpixel, tty->ypixel)) + server_redraw_client(c); return (0); } else if (sscanf(tmp, "4;%u;%u", &ypixel, &xpixel) == 2) { *size = end + 1; @@ -1924,10 +1925,9 @@ tty_keys_winsz(struct tty *tty, const char *buf, size_t len, size_t *size, /* Window size in pixels. */ char_x = (xpixel && tty->sx) ? xpixel / tty->sx : 0; char_y = (ypixel && tty->sy) ? ypixel / tty->sy : 0; - tty_set_size(tty, tty->sx, tty->sy, char_x, char_y); + if (tty_set_size(tty, tty->sx, tty->sy, char_x, char_y)) + server_redraw_client(c); tty_invalidate(tty); - - /* Windows take their cell size from the clients. */ recalculate_sizes(); tty->flags &= ~TTY_WINSIZEQUERY; diff --git a/tty-term.c b/tty-term.c index 3311701ba..c99f143db 100644 --- a/tty-term.c +++ b/tty-term.c @@ -116,6 +116,7 @@ static const struct tty_term_code_entry tty_term_codes[] = { [TTYC_IND] = { TTYCODE_STRING, "ind" }, [TTYC_INDN] = { TTYCODE_STRING, "indn" }, [TTYC_INVIS] = { TTYCODE_STRING, "invis" }, + [TTYC_ISCRL] = { TTYCODE_FLAG, "Iscrl" }, [TTYC_KCBT] = { TTYCODE_STRING, "kcbt" }, [TTYC_KCUB1] = { TTYCODE_STRING, "kcub1" }, [TTYC_KCUD1] = { TTYCODE_STRING, "kcud1" }, diff --git a/tty.c b/tty.c index 0891699a7..1c2de0c27 100644 --- a/tty.c +++ b/tty.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty.c,v 1.486 2026/10/12 10:39:05 nicm Exp $ */ +/* $OpenBSD: tty.c,v 1.487 2026/10/14 08:33:19 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -155,25 +155,22 @@ tty_resize(struct tty *tty) tty_invalidate(tty); } -void +int tty_set_size(struct tty *tty, u_int sx, u_int sy, u_int xpixel, u_int ypixel) { -#ifdef ENABLE_IMAGES - int geometry_changed = 0; - - if (tty->xpixel != xpixel || tty->ypixel != ypixel) - geometry_changed = 1; -#endif + if (tty->sx == sx && + tty->sy == sy && + tty->xpixel == xpixel && + tty->ypixel == ypixel) + return (0); tty->sx = sx; tty->sy = sy; tty->xpixel = xpixel; tty->ypixel = ypixel; #ifdef ENABLE_IMAGES - if (geometry_changed) { - image_tty_geometry_changed(tty); - server_redraw_client(tty->client); - } + image_tty_size_changed(tty); #endif + return (1); } static void @@ -586,16 +583,10 @@ tty_update_features(struct tty *tty) tty_puts(tty, tty_term_string(tty->term, TTYC_ENFCS)); tty_puts(tty, tty_term_string(tty->term, TTYC_ENESC)); - /* - * Redraw only for changed features; repeated DA replies must not erase - * images sent through DCS passthrough. - */ - if (!changed) - return; - - server_redraw_client(c); - - tty_invalidate(tty); + if (changed) { + server_redraw_client(c); + tty_invalidate(tty); + } } void @@ -1695,17 +1686,12 @@ tty_cmd_reverseindex(struct tty *tty, const struct tty_ctx *ctx) !tty_term_has(tty->term, TTYC_RIN)) || ctx->sx == 1 || ctx->sy == 1) { -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 1); -#endif + redraw_scroll_result(tty, ctx, 1); tty_redraw_region(tty, ctx); return; } -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 0); -#endif - + redraw_scroll_result(tty, ctx, 0); tty_default_attributes(tty, ctx->bg, &ctx->style_ctx); tty_region_pane(tty, ctx, ctx->orupper, ctx->orlower); @@ -1730,17 +1716,12 @@ tty_cmd_linefeed(struct tty *tty, const struct tty_ctx *ctx) !tty_term_has(tty->term, TTYC_CSR) || ctx->sx == 1 || ctx->sy == 1) { -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 1); -#endif + redraw_scroll_result(tty, ctx, 1); tty_redraw_region(tty, ctx); return; } -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 0); -#endif - + redraw_scroll_result(tty, ctx, 0); tty_default_attributes(tty, ctx->bg, &ctx->style_ctx); tty_region_pane(tty, ctx, ctx->orupper, ctx->orlower); @@ -1775,17 +1756,12 @@ tty_cmd_scrollup(struct tty *tty, const struct tty_ctx *ctx) !tty_term_has(tty->term, TTYC_CSR) || ctx->sx == 1 || ctx->sy == 1) { -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 1); -#endif + redraw_scroll_result(tty, ctx, 1); tty_redraw_region(tty, ctx); return; } -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 0); -#endif - + redraw_scroll_result(tty, ctx, 0); tty_default_attributes(tty, ctx->bg, &ctx->style_ctx); tty_region_pane(tty, ctx, ctx->orupper, ctx->orlower); @@ -1820,17 +1796,12 @@ tty_cmd_scrolldown(struct tty *tty, const struct tty_ctx *ctx) !tty_term_has(tty->term, TTYC_RIN)) || ctx->sx == 1 || ctx->sy == 1) { -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 1); -#endif + redraw_scroll_result(tty, ctx, 1); tty_redraw_region(tty, ctx); return; } -#ifdef ENABLE_IMAGES - redraw_image_scroll_result(tty, ctx, 0); -#endif - + redraw_scroll_result(tty, ctx, 0); tty_default_attributes(tty, ctx->bg, &ctx->style_ctx); tty_region_pane(tty, ctx, ctx->orupper, ctx->orlower); diff --git a/window-client.c b/window-client.c index 05bb2f801..a275c51d9 100644 --- a/window-client.c +++ b/window-client.c @@ -89,19 +89,20 @@ static const char *window_client_info_lines[] = { " #[#{E:tree-mode-border-style},acs]x#[default] " WINDOW_CLIENT_FEATURE(hyperlinks) " " WINDOW_CLIENT_FEATURE(ignorefkeys) " " - WINDOW_CLIENT_FEATURE(kittygraphics) " " - WINDOW_CLIENT_FEATURE(margins), + WINDOW_CLIENT_FEATURE(imagescroll) " " + WINDOW_CLIENT_FEATURE(kittygraphics), " #[#{E:tree-mode-border-style},acs]x#[default] " + WINDOW_CLIENT_FEATURE(margins) " " WINDOW_CLIENT_FEATURE(mouse) " " WINDOW_CLIENT_FEATURE(osc7) " " - WINDOW_CLIENT_FEATURE(overline) " " - WINDOW_CLIENT_FEATURE(progressbar), + WINDOW_CLIENT_FEATURE(overline), " #[#{E:tree-mode-border-style},acs]x#[default] " + WINDOW_CLIENT_FEATURE(progressbar) " " WINDOW_CLIENT_FEATURE(rectfill) " " WINDOW_CLIENT_FEATURE(sixel) " " - WINDOW_CLIENT_FEATURE(strikethrough) " " - WINDOW_CLIENT_FEATURE(sync), + WINDOW_CLIENT_FEATURE(strikethrough), " #[#{E:tree-mode-border-style},acs]x#[default] " + WINDOW_CLIENT_FEATURE(sync) " " WINDOW_CLIENT_FEATURE(title) " " WINDOW_CLIENT_FEATURE(usstyle) " " WINDOW_CLIENT_FEATURE(utf8), diff --git a/window-copy.c b/window-copy.c index 82d29b4af..ed9cc764f 100644 --- a/window-copy.c +++ b/window-copy.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-copy.c,v 1.437 2026/10/08 07:50:05 nicm Exp $ */ +/* $OpenBSD: window-copy.c,v 1.438 2026/10/13 23:17:42 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -297,9 +297,11 @@ struct window_copy_mode_data { u_int oy; /* number of lines scrolled up */ - u_int image_base; /* hsize - oy images were last drawn for */ - int image_base_set; /* image_base is valid */ - int image_refresh; /* current redraw needs image refresh */ +#ifdef ENABLE_IMAGES + u_int image_base; /* where images were last drawn for */ + int image_base_set; + int image_refresh; +#endif u_int selx; /* beginning of selection */ u_int sely; @@ -545,7 +547,8 @@ window_copy_sync_backing(struct window_mode_entry *wme) if (collected > 0) { grid_free_lines(dg, 0, collected); memmove(&dg->linedata[0], &dg->linedata[collected], - (old_hsize + sy - collected) * sizeof *dg->linedata); + (old_hsize + sy - collected) * + sizeof *dg->linedata); memset(&dg->linedata[old_hsize + sy - collected], 0, collected * sizeof *dg->linedata); } @@ -564,7 +567,7 @@ window_copy_sync_backing(struct window_mode_entry *wme) */ dg->hsize = new_hsize; - /* Copy the newly scrolled history, then refresh the viewport. */ + /* Copy the newly scrolled history, then refresh the view. */ if (added > 0) grid_duplicate_lines(dg, kept, sg, kept, added); grid_duplicate_lines(dg, new_hsize, sg, new_hsize, sy); @@ -2328,6 +2331,7 @@ window_copy_cmd_selection_mode(struct window_copy_cmd_state *cs) { struct window_mode_entry *wme = cs->wme; struct options *so = cs->s->options; + struct options *oo = wme->wp->window->options; struct window_copy_mode_data *data = wme->data; struct grid_reader gr; const char *s = args_string(cs->wargs, 0); @@ -2347,7 +2351,8 @@ window_copy_cmd_selection_mode(struct window_copy_cmd_state *cs) /* * Set up the reset positions normally initialized by select-word or - * select-line, including when changing the mode of an existing selection. + * select-line, including when changing the mode of an existing + * selection. */ if (data->cursordrag == CURSORDRAG_SEL) { fx = data->endselx; @@ -2374,8 +2379,7 @@ window_copy_cmd_selection_mode(struct window_copy_cmd_state *cs) grid_reader_start(&gr, data->backing->grid, ex, ey); if (data->selflag == SEL_WORD) { grid_reader_cursor_next_word_end(&gr, data->separators); - if (options_get_number(wme->wp->window->options, "mode-keys") == - MODEKEY_VI) + if (options_get_number(oo, "mode-keys") == MODEKEY_VI) grid_reader_cursor_left(&gr, 1); } else grid_reader_cursor_end_of_line(&gr, 1, 0); @@ -5591,9 +5595,8 @@ window_copy_write_one(struct window_mode_entry *wme, grid_get_cell(gd, fx, fy, &gc); if (fx + gc.data.width <= nx) { #ifdef ENABLE_IMAGES - /* Write image cells without styling or damage. */ - if (image_grid_area_has_images(gd, fx, fy, gc.data.width, - 1)) { + if (image_grid_area_has_images(gd, fx, fy, + gc.data.width, 1)) { grid_view_set_cell(ctx->s->grid, px + fx, py, &gc); screen_write_cursormove(ctx, @@ -5606,8 +5609,8 @@ window_copy_write_one(struct window_mode_entry *wme, if (gc.flags & GRID_FLAG_PADDING) { if (ctx->s->cy == py && ctx->s->cx <= px + fx) { gc.flags &= ~GRID_FLAG_PADDING; - screen_write_cursormove(ctx, px + fx, py, - 0); + screen_write_cursormove(ctx, px + fx, + py, 0); screen_write_putc(ctx, &gc, ' '); } } else if (gc.flags & GRID_FLAG_TAB) { @@ -5796,6 +5799,9 @@ window_copy_write_line(struct window_mode_entry *wme, char *expanded; struct format_tree *ft; int current, mode; +#ifdef ENABLE_IMAGES + int flags; +#endif width = window_copy_line_number_width(wme); if (width >= sx) @@ -5850,13 +5856,13 @@ window_copy_write_line(struct window_mode_entry *wme, content_sx, &mgc, &cgc, &mkgc, &clgc); #ifdef ENABLE_IMAGES - /* Copy image spans; refresh only when the history view moves. */ - image_grid_free_line(s->grid, - &s->grid->linedata[s->grid->hsize + py]); + image_grid_free_line(s->grid, &s->grid->linedata[s->grid->hsize + py]); image_grid_copy_area(s->grid, width, s->grid->hsize + py, data->backing->grid, 0, hsize - data->oy + py, content_sx, 1); if (data->image_refresh) image_redraw_area(ctx, width, py, content_sx, 1); + flags = ctx->flags; + ctx->flags |= SCREEN_WRITE_INPUT; #endif if (py == 0 && s->rupper < s->rlower && !data->hide_position) { @@ -5878,6 +5884,9 @@ window_copy_write_line(struct window_mode_entry *wme, screen_write_putc(ctx, &grid_default_cell, '$'); } +#ifdef ENABLE_IMAGES + ctx->flags = flags; +#endif format_free(ft); } @@ -5910,32 +5919,30 @@ window_copy_redraw_selection(struct window_mode_entry *wme, u_int old_y) } #ifdef ENABLE_IMAGES -/* Refresh images only when the visible history range has moved. */ +/* Refresh images when the visible range of history changes. */ static void window_copy_update_image_refresh(struct window_copy_mode_data *data) { - u_int base; + u_int base = screen_hsize(data->backing) - data->oy; - base = screen_hsize(data->backing) - data->oy; - data->image_refresh = 0; if (!data->image_base_set || base != data->image_base) data->image_refresh = 1; + else + data->image_refresh = 0; data->image_base = base; data->image_base_set = 1; } -/* Return whether the visible backing range contains image spans. */ +/* Check whether visible history contains images. */ static int window_copy_visible_has_images(struct window_copy_mode_data *data) { struct grid *gd = data->backing->grid; - u_int sy = screen_size_y(&data->screen); - int has_images; + struct screen *s = &data->screen; + u_int sx = screen_size_x(s), sy = screen_size_y(s); + u_int hsize = screen_hsize(data->backing); - has_images = image_grid_area_has_images(gd, 0, - screen_hsize(data->backing) - data->oy, - screen_size_x(&data->screen), sy); - return (has_images); + return (image_grid_area_has_images(gd, 0, hsize - data->oy, sx, sy)); } #endif @@ -5946,7 +5953,7 @@ window_copy_redraw_lines(struct window_mode_entry *wme, u_int py, u_int ny) struct window_copy_mode_data *data = wme->data; struct screen *s = &data->screen; struct screen_write_ctx ctx; - u_int i; + u_int i, off; #ifdef ENABLE_IMAGES window_copy_update_image_refresh(data); @@ -5970,9 +5977,8 @@ window_copy_redraw_lines(struct window_mode_entry *wme, u_int py, u_int ny) screen_write_start_pane(&ctx, wp, NULL); for (i = py; i < py + ny; i++) window_copy_write_line(wme, &ctx, i); - screen_write_cursormove(&ctx, - window_copy_cursor_offset(wme, data->cx, screen_size_x(s)), data->cy, - 0); + off = window_copy_cursor_offset(wme, data->cx, screen_size_x(s)); + screen_write_cursormove(&ctx, off, data->cy, 0); screen_write_stop(&ctx); window_pane_scrollbar_redraw(wp);