#!/bin/sh # Kitty placements survive text writes and erases, but follow screen clears # and scrolling. Overwritten SIXEL pixels can reveal an older Kitty image. PATH=/bin:/usr/bin TERM=screen export TERM [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) command -v python3 >/dev/null || exit 101 python3 -B - "$TEST_TMUX" <<'PY' import base64 import sys from image_test import image_test, sixel from image_terminal import Terminal def graphics(control, data=b''): """Build a Kitty graphics APC with replies suppressed.""" payload = b';' + base64.b64encode(data) if data else b'' return b'\033_Gq=2,' + control.encode() + payload + b'\033\\' red, blue = (255, 0, 0), (0, 0, 255) origin = b'\033[3;3H' upload = graphics('a=t,i=7,f=32,s=1,v=1', bytes((*red, 255))) place = origin + graphics('a=p,i=7,p=9,c=4,r=3,C=1') cells = {(x, y): red for y in range(2, 5) for x in range(2, 6)} def snapshot(test, client, expected): """Check image pixels after a full redraw.""" test.redraw() terminal = Terminal() terminal.feed(client.data) terminal.check(expected) return terminal def pixels(cells): """Expand solid image cells into positioned pixels.""" return {(x * 8 + px, y * 16 + py): colour for (x, y), colour in cells.items() for py in range(16) for px in range(8)} cases = [ ('ASCII text', origin + b'ab'), ('wide text', origin + '\u754c'.encode()), ('insert characters', origin + b'\033[2@'), ('delete characters', b'\033[3;4H\033[2P'), ('insert mode', origin + b'\033[4hX\033[4l'), ('wide insert mode', origin + b'\033[4h' + '\u754c'.encode() + b'\033[4l'), ('insert line', b'\033[4;1H\033[L'), ('delete line', b'\033[4;1H\033[M'), ('insert lines', b'\033[4;1H\033[2L'), ('delete lines', b'\033[4;1H\033[2M'), ('insert all remaining lines', b'\033[4;1H\033[99L'), ('line edit outside margins', b'\033[6;12r\033[4;1H\033[L\033[r'), ('erase characters', b'\033[3;4H\033[2X'), ('erase line end', b'\033[4;5H\033[K'), ('erase line start', b'\033[4;4H\033[1K'), ('erase whole line', b'\033[4;4H\033[2K'), ('erase display end', b'\033[4;5H\033[J'), ('erase display start', b'\033[4;5H\033[1J'), ('erase whole display', b'\033[2J'), ] with image_test(sys.argv[1]) as test: test.run('set-option', '-g', 'scroll-on-clear', 'off') client = test.attach() fallback = test.attach(graphical=False) test.emit(upload) for name, operation in cases: test.emit(b'\033[2J' + place) wanted = pixels(cells) terminal = snapshot(test, client, wanted) test.emit(operation) if name == 'erase whole display': wanted = {} try: terminal.feed(client.data) terminal.check(wanted) snapshot(test, client, wanted) # Fallback must still show the retained image after a line erase. term = Terminal() term.feed(fallback.data) for x, y in cells: assert term.cells[x, y][2] == (196 if wanted else None), ( x, y, term.cells[x, y]) except AssertionError as e: raise AssertionError(name) from e # Image persistence must not prevent writes and erases of underlying text. test.emit(b'\033[2J' + place + origin + b'ab') assert test.run('capture-pane', '-p') == 'ab' test.emit(origin + b'\033[X') assert test.run('capture-pane', '-p') == 'b' snapshot(test, client, pixels(cells)) test.emit(origin + '\u754c'.encode()) assert test.run('capture-pane', '-p') == '\u754c' test.emit(origin + b'\033[2K') assert test.run('capture-pane', '-p') == '' snapshot(test, client, pixels(cells)) # Text line edits preserve images; complete images follow scrolling. colours = [red, (0, 255, 0), blue] striped = {(x, y): colours[y - 2] for x, y in cells} data = bytes(c for colour in colours for c in (*colour, 255)) display = origin + graphics('a=T,i=8,p=9,f=32,s=1,v=3,c=4,r=3,C=1', data) operations = [ ('insert line', b'\033[4;1H\033[L', striped), ('delete line', b'\033[4;1H\033[M', striped), ('scroll down', b'\033[3;6r\033[T', {(x, y + 1): c for (x, y), c in striped.items()}), ('reverse index', b'\033[3;6r\033[3;1H\033M', {(x, y + 1): c for (x, y), c in striped.items()}), ] for name, operation, wanted in operations: test.emit(b'\033[r\033[2J' + display + operation) try: snapshot(test, client, pixels(wanted)) except AssertionError as e: raise AssertionError(name) from e test.emit(b'\033[r') # Erasing a SIXEL layer reveals the Kitty placement retained beneath it. wire = sixel(32, 48, [(0, 0, 100)], [0] * (32 * 48)) test.emit(b'\033[2J' + place + origin + wire) terminal = snapshot(test, client, pixels({point: blue for point in cells})) test.emit(b'\033[4;1H\033[2K') wanted = pixels({(x, y): red if y == 3 else blue for x, y in cells}) terminal.feed(client.data) terminal.check(wanted) snapshot(test, client, wanted) # An image crossing a scroll margin stays intact and stationary. test.emit(b'\033[2J' + place + b'\033[4;7r\033[S') wanted = pixels(cells) snapshot(test, client, wanted) test.emit(b'\033[r') # Complete images scroll and clip; images crossing either margin stay. operations = [ ('clip all', b'\033[3;6r\033[3S', {}), ('clip top', b'\033[3;6r\033[S', {(x, y - 1): c for (x, y), c in striped.items() if y > 2}), ('clip bottom', b'\033[3;5r\033[T', {(x, y + 1): c for (x, y), c in striped.items() if y < 4}), ('cross top', b'\033[4;7r\033[S', striped), ('cross bottom', b'\033[1;4r\033[T', striped), ('cross top reverse index', b'\033[4;7r\033[4;1H\033M', striped), ] for name, operation, wanted in operations: test.emit(b'\033[r\033[2J' + display + operation) try: snapshot(test, client, pixels(wanted)) except AssertionError as e: raise AssertionError(name) from e test.emit(b'\033[r') # Two placements of the same image can scroll differently at a margin. other = b'\033[5;9H' + graphics( 'a=p,i=8,p=10,c=4,r=3,C=1') test.emit(b'\033[2J' + display + other + b'\033[4;7r\033[S\033[r') wanted = striped | {(x + 6, y + 1): c for (x, y), c in striped.items()} snapshot(test, client, pixels(wanted)) # With scroll-on-clear enabled, Kitty images move into history. test.emit(b'\033[2J') test.run('clear-history') test.run('set-option', '-g', 'scroll-on-clear', 'on') test.emit(place + b'\033[2J') snapshot(test, client, {}) assert int(test.run('display-message', '-p', '#{history_size}')) > 0 # Scrolling a region must leave images already in history in place. test.emit(b'\033[4;7r\033[S\033[r') test.run('copy-mode') test.run('send-keys', '-X', 'history-top') snapshot(test, client, pixels(cells)) test.run('send-keys', '-X', 'cancel') # Replacing a source removes its placements even when wholly in history. test.emit(graphics('a=t,i=7,f=32,s=1,v=1', bytes((*blue, 255)))) test.run('copy-mode') test.run('send-keys', '-X', 'history-top') snapshot(test, client, {}) test.run('send-keys', '-X', 'cancel') # Restore a historical placement before checking explicit history cleanup. test.emit(place + b'\033[2J') test.run('clear-history') test.run('copy-mode') test.run('send-keys', '-X', 'history-top') snapshot(test, client, {}) # The alternate screen has no history, but uses the same image rules. test.run('send-keys', '-X', 'cancel') test.emit(b'\033[?1049h') for name, operation, wanted in operations: test.emit(b'\033[r\033[2J' + display + operation) try: snapshot(test, client, pixels(wanted)) except AssertionError as e: raise AssertionError('alternate screen: ' + name) from e test.emit(b'\033[r\033[?1049l') PY