Kitty image input path.

This commit is contained in:
Nicholas Marriott
2026-10-16 17:26:41 +01:00
parent bfb01dcb54
commit 4237434271
29 changed files with 3389 additions and 73 deletions

127
regress/image-kitty-decode.sh Executable file
View File

@@ -0,0 +1,127 @@
#!/bin/sh
# Check RGB, RGBA and PNG pixels, including compression and transparency.
# These tests require libpng and zlib in the tmux build.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import sys
import zlib
from image_test import image_test, kitty, png
from image_terminal import Terminal
def rows(data, width, height, channels):
"""Split raster bytes into PNG rows."""
size = width * channels
return [data[y * size:(y + 1) * size] for y in range(height)]
def check(test, client, command, expected):
"""Check newly decoded pixels and their later redraw."""
test.emit(b'\033[2J\033[H' + command)
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
colours = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)]
w, h = 16, 32
rgb = [colours[2 * (y >= 16) + (x >= 8)]
for y in range(h) for x in range(w)]
expected = {(x, y): rgb[y * w + x] for y in range(h) for x in range(w)}
raw_rgb = bytes(c for pixel in rgb for c in pixel)
raw_rgba = bytes(c for pixel in rgb for c in (*pixel, 255))
png_rgb = png(w, h, rows(raw_rgb, w, h, 3), colour_type=2)
png_rgba = png(w, h, rows(raw_rgba, w, h, 4))
with image_test(sys.argv[1]) as test:
test.run('set-option', '-g', 'scroll-on-clear', 'off')
client = test.attach()
for format, data in [(24, raw_rgb), (32, raw_rgba),
(100, png_rgb), (100, png_rgba)]:
for compressed in (False, True):
control = 'a=T,i=7,C=1,f=%d' % format
if format != 100:
control += ',s=16,v=32'
payload = data
if compressed:
control += ',o=z,S=%d' % len(data)
payload = zlib.compress(data)
try:
check(test, client, kitty(control, payload), expected)
except AssertionError as e:
raise AssertionError((format, compressed)) from e
# Palette PNGs retain their colours and transparent palette entries.
indexes = bytes(2 * (y >= 16) + (x >= 8)
for y in range(h) for x in range(w))
palette = bytes(c for pixel in colours for c in pixel)
data = png(w, h, rows(indexes, w, h, 1), colour_type=3,
chunks=[(b'PLTE', palette), (b'tRNS', b'\xff\0\xff\xff')])
visible = {point: colour for point, colour in expected.items()
if not (point[0] >= 8 and point[1] < 16)}
check(test, client, kitty('a=T,i=7,f=100,C=1', data), visible)
# Grayscale and grayscale-alpha PNGs expand to the shared RGBA format.
gray = bytes(255 if y >= 16 else 0 for y in range(h) for x in range(w))
gray_expected = {(x, y): (gray[y * w + x],) * 3
for y in range(h) for x in range(w)}
data = png(w, h, rows(gray, w, h, 1), colour_type=0)
check(test, client, kitty('a=T,i=7,f=100,C=1', data), gray_expected)
gray_alpha = bytes(c for y in range(h) for x in range(w)
for c in (gray[y * w + x], 255 if x < 8 else 0))
data = png(w, h, rows(gray_alpha, w, h, 2), colour_type=4)
check(test, client, kitty('a=T,i=7,f=100,C=1', data),
{point: colour for point, colour in gray_expected.items()
if point[0] < 8})
# Test both an entirely transparent cell and holes inside a visible cell.
rgba = bytes(c for y in range(h) for x in range(w)
for c in (*rgb[y * w + x], 0 if (x >= 8 and y < 16) or
(x + y) % 3 == 0 else 255))
visible = {point: colour for point, colour in expected.items()
if rgba[(point[1] * w + point[0]) * 4 + 3] != 0}
for format, data in [(32, rgba), (100, png(w, h, rows(rgba, w, h, 4)))]:
control = 'a=T,i=7,f=%d,C=1' % format
if format == 32:
control += ',s=16,v=32'
check(test, client, kitty(control, data), visible)
empty = b'\xff\0\0\0' * (w * h)
data = empty if format == 32 else png(w, h, rows(empty, w, h, 4))
check(test, client, kitty(control, data), {})
# SIXEL represents partial alpha as a mixture of coloured and empty pixels.
test.run('set-option', '-as', 'terminal-features', 'screen-256color:RGB')
fallback = test.attach(graphical=False)
rgba = b'\xff\0\0\x40' * 64 + b'\xff\0\0\xc0' * 64
for format, data in [(32, rgba), (100, png(8, 16, rows(rgba, 8, 16, 4)))]:
control = 'a=T,i=7,f=%d,C=1' % format
if format == 32:
control += ',s=8,v=16'
test.emit(b'\033[2J\033[H' + kitty(control, data))
for redraw in (False, True):
if redraw:
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
upper = sum(y < 8 for x, y in terminal.pixels)
lower = len(terminal.pixels) - upper
assert 12 <= upper <= 20 and 44 <= lower <= 52, (upper, lower)
assert all(colour == (255, 0, 0)
for colour in terminal.pixels.values()), terminal.pixels
terminal = Terminal()
terminal.feed(fallback.data)
char, fg, bg, bold = terminal.cells[0, 0]
assert char == '\u2584' and not bold, (char, bold)
assert (fg, bg) == ((192, 0, 0), (64, 0, 0)), (fg, bg)
PY

148
regress/image-kitty-delete.sh Executable file
View File

@@ -0,0 +1,148 @@
#!/bin/sh
# Kitty deletion selects placements and preserves other images and text.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_test import image_test, kitty, sixel
from image_terminal import Terminal
def rectangle(colour, xoff=0, yoff=0):
"""Expected pixels for a one-column, two-row placement."""
return {(x + xoff, y + yoff): colour for y in range(32) for x in range(8)}
def snapshot(test, client, expected):
"""Check image positions and colours after a full redraw."""
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
return terminal
red, green, blue, white = (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)
uploads = kitty('a=t,i=7,f=32,s=1,v=1', bytes((*red, 255))) + \
kitty('a=t,i=8,f=32,s=1,v=1', bytes((*green, 255)))
put = 'a=p,i=7,C=1,c=1,r=2'
display = b'\033[H' + kitty(put + ',p=1') + b'\033[1;3H' + \
kitty(put + ',p=2') + b'\033[1;5H' + \
kitty('a=p,i=8,p=1,C=1,c=1,r=2') + b'\033[1;7H' + \
kitty('a=T,f=32,s=1,v=1,C=1,c=1,r=2', bytes((*blue, 255))) + \
b'\033[1;9H' + sixel(8, 32, [(100, 100, 100)], [0] * 256) + b'\033[2;3H'
pictures = dict(first=rectangle(red), second=rectangle(red, 16),
other=rectangle(green, 32), anonymous=rectangle(blue, 48),
sixel=rectangle(white, 64))
cases = [
('one placement', kitty('a=d,d=i,i=7,p=1'),
['second', 'other', 'anonymous', 'sixel']),
('one image', kitty('a=d,d=i,i=7'), ['other', 'anonymous', 'sixel']),
('placement zero', kitty('a=d,d=i,i=7,p=0'),
['other', 'anonymous', 'sixel']),
('placement IDs belong to each image', kitty('a=d,d=i,i=8,p=1'),
['first', 'second', 'anonymous', 'sixel']),
('missing image', kitty('a=d,d=I,i=999'), list(pictures)),
('missing placement', kitty('a=d,d=I,i=7,p=999'), list(pictures)),
('missing image ID', kitty('a=d,d=I'), list(pictures)),
('all images', kitty('a=d,d=a'), ['sixel']),
('default selector', kitty('a=d'), ['sixel']),
('all ignores image and placement IDs', kitty('a=d,d=A,i=7,p=1'),
['sixel']),
('repeated deletion', kitty('a=d,d=I,i=7,p=1') * 2,
['second', 'other', 'anonymous', 'sixel']),
('selector resets', kitty('a=d,d=i,i=7') + kitty('a=d'), ['sixel']),
]
for selector in 'cCpPqQrRxXyYzZ':
cases.append(('unsupported selector ' + selector,
kitty('a=d,d=%s,i=7,p=1,x=1,y=2,z=0' % selector), list(pictures)))
for z in (-2147483648, -1, 1, 2147483647):
cases.append(('unsupported layer ' + str(z),
kitty('a=d,d=I,i=7,p=1,z=%d' % z), list(pictures)))
with image_test(sys.argv[1]) as test:
test.run('set-option', '-g', 'scroll-on-clear', 'off')
client = test.attach()
fallback = test.attach(graphical=False)
for name, command, retained in cases:
test.emit(b'\033[2J' + uploads + display)
original = {point: colour for image in pictures.values()
for point, colour in image.items()}
terminal = snapshot(test, client, original)
cursor = test.run('display-message', '-p', '#{cursor_x},#{cursor_y}')
test.emit(command)
wanted = {point: colour for name in retained
for point, colour in pictures[name].items()}
try:
terminal.feed(client.data)
terminal.check(wanted)
snapshot(test, client, wanted)
assert test.run('display-message', '-p',
'#{cursor_x},#{cursor_y}') == cursor
assert not fallback.images(), 'graphics sent to fallback client'
except AssertionError as e:
raise AssertionError(name) from e
# Deleting the upper placement reveals the lower one.
test.emit(b'\033[2J\033[H' + uploads + kitty(put + ',p=1') +
kitty('a=p,i=8,p=1,C=1,c=1,r=2') + b'\033[1;3H' + kitty(put + ',p=2'))
terminal = snapshot(test, client, rectangle(green) | rectangle(red, 16))
test.emit(kitty('a=d,d=I,i=8,p=1'))
terminal.feed(client.data)
terminal.check(rectangle(red) | rectangle(red, 16))
snapshot(test, client, rectangle(red) | rectangle(red, 16))
test.emit(kitty('a=d,d=A'))
snapshot(test, client, {})
# Deleting an image restores the text which was updated beneath it.
test.emit(b'\033[2J\033[H' + uploads + kitty(put + ',p=1') + b'\033[HX')
assert test.run('capture-pane', '-p') == 'X'
test.emit(kitty('a=d,d=I,i=7'))
snapshot(test, client, {})
for attached in [client, fallback]:
terminal = Terminal()
terminal.feed(attached.data)
assert terminal.cells[0, 0][0] == 'X', terminal.cells[0, 0]
# A visible part selects its whole placement, including the part in history.
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(uploads + b'\033[H' + kitty(put + ',p=1') + b'\033[S')
snapshot(test, client, {point: colour
for point, colour in rectangle(red, yoff=-16).items() if point[1] >= 0})
test.emit(kitty('a=d,d=A'))
snapshot(test, client, {})
test.run('copy-mode')
test.run('send-keys', '-X', 'history-top')
snapshot(test, client, {})
test.run('send-keys', '-X', 'cancel')
# Deletion on the alternate screen leaves the saved primary placements intact.
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(uploads + b'\033[H' + kitty(put + ',p=1') + b'\033[?1049h\033[H' +
kitty(put + ',p=2'))
snapshot(test, client, rectangle(red))
test.emit(kitty('a=d,d=I,i=7'))
snapshot(test, client, {})
test.emit(b'\033[?1049l')
snapshot(test, client, rectangle(red))
test.emit(b'\033[1;3H' + kitty(put + ',p=2'))
snapshot(test, client, rectangle(red) | rectangle(red, 16))
# Deletion by image ID also removes placements entirely in history.
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(uploads + b'\033[H' + kitty(put + ',p=1') + b'\033[16S')
snapshot(test, client, {})
test.emit(kitty('a=d,d=I,i=7'))
test.run('copy-mode')
test.run('send-keys', '-X', 'history-top')
snapshot(test, client, {})
test.run('send-keys', '-X', 'cancel')
PY

View File

@@ -0,0 +1,135 @@
#!/bin/sh
# Image ordering and overlapping Kitty placements through SIXEL and fallback.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_test import image_test, kitty, sixel
from image_terminal import Terminal
red, green, blue, white = (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)
def upload(id, colour):
"""Upload a solid pixel under a Kitty image ID."""
return kitty('a=t,i=%d,f=32,s=1,v=1' % id, bytes((*colour, 255)))
def put(id, controls=''):
"""Place a stored image in two columns without moving the cursor."""
return kitty('a=p,i=%d,C=1,c=2,r=1' % id + controls)
def rectangle(colour, xoff=0, yoff=0, w=16, h=16):
"""Expected image pixels for a solid rectangle."""
return {(x + xoff, y + yoff): colour for y in range(h) for x in range(w)}
def check(test, client, terminal, expected):
"""Check incremental output and a full redraw of the same scene."""
terminal.feed(client.data)
terminal.check(expected)
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
return terminal
with image_test(sys.argv[1]) as test:
test.run('set-option', '-g', 'scroll-on-clear', 'off')
client = test.attach()
fallback = test.attach(graphical=False)
terminal = Terminal()
# The PR's tie-breaker cases: upload order wins over placement and app IDs.
test.emit(upload(200, red) + upload(100, white) + put(100))
terminal = check(test, client, terminal, rectangle(white))
test.emit(put(200))
terminal = check(test, client, terminal, rectangle(white))
# Replacing the older source preserves its place in the drawing order.
test.emit(upload(200, green) + put(200))
terminal = check(test, client, terminal, rectangle(white))
test.emit(kitty('a=d,d=I,i=200') + upload(200, red) + put(200))
terminal = check(test, client, terminal, rectangle(red))
# A retransmit-and-place also inherits the old source's drawing order.
test.emit(b'\033[2J\033[H' + upload(200, red) + upload(100, white) +
put(200) + put(100) + kitty('a=T,i=100,f=32,s=1,v=1,C=1,c=2,r=1',
bytes((*green, 255))))
# 200 was recreated above, so it is newer than 100 despite retransmission.
terminal = check(test, client, terminal, rectangle(red))
# Placements of the same source use placement order, including after reflow.
pixels = bytes((*red, 255, *blue, 255))
test.emit(b'\033[2J\033[H' + kitty('a=t,i=300,f=32,s=2,v=1', pixels) +
put(300, ',p=99,x=1,w=1') + put(300, ',p=1,x=0,w=1'))
terminal = check(test, client, terminal, rectangle(red))
test.run('resize-window', '-x', '10')
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(rectangle(red))
test.run('resize-window', '-x', '40')
# Replacing a named placement puts it after the other source placements.
test.emit(put(300, ',p=99,x=1,w=1'))
terminal = check(test, client, terminal, rectangle(blue))
test.emit(kitty('a=d,d=i,i=300,p=99'))
terminal = check(test, client, terminal, rectangle(red))
# Both protocols use source creation order, even when placed later.
graphics = sixel(16, 16, [(100, 100, 100)], [0] * 256)
test.emit(b'\033[2J\033[H' + upload(400, red) + graphics +
b'\033[H' + put(400))
terminal = check(test, client, Terminal(), rectangle(white))
# Replacing the older Kitty source leaves the newer SIXEL on top.
test.emit(upload(400, green) + put(400))
terminal = check(test, client, terminal, rectangle(white))
test.emit(b'\033[2J\033[H' + graphics + b'\033[H' +
upload(401, red) + put(401))
terminal = check(test, client, Terminal(), rectangle(red))
term = Terminal()
term.feed(fallback.data)
assert term.cells[0, 0][1:3] == (196, 196), term.cells[0, 0]
# Removing the newer Kitty placement reveals the retained SIXEL image.
test.emit(kitty('a=d,d=I,i=401'))
terminal = check(test, client, terminal, rectangle(white))
term = Terminal()
term.feed(fallback.data)
assert term.cells[0, 0][1:3] == (231, 231), term.cells[0, 0]
# Transparency preserves the lower image across redraws and deletion.
pixels = bytes(value for y in range(16) for x in range(16)
for value in (*blue, 255 if x < 8 else 0))
for base, colour in [(upload(500, red) + put(500), red), (graphics, white)]:
test.emit(b'\033[2J\033[H' + base + b'\033[H' +
kitty('a=T,i=501,f=32,s=16,v=16,C=1', pixels))
terminal = check(test, client, Terminal(),
rectangle(blue, w=8) | rectangle(colour, 8, w=8))
test.emit(kitty('a=d,d=I,i=501'))
terminal = check(test, client, terminal, rectangle(colour))
# Rejected layers cannot replace stored pixels or remove placements.
test.emit(b'\033[2J\033[H' + upload(600, red) + put(600))
terminal = check(test, client, Terminal(), rectangle(red))
for action in ('t', 'T', 'p', 'q', 'd'):
test.emit(kitty('a=%s,i=600,p=9,f=32,s=1,v=1,C=1,d=I,z=1' %
action, bytes((*green, 255))))
terminal = check(test, client, terminal, rectangle(red))
test.emit(kitty('a=p,i=600,p=9,C=1,c=2,r=1,z=0'))
terminal = check(test, client, terminal, rectangle(red))
# Images at the default layer cover text and explicit backgrounds.
test.emit(b'\033[2J\033[H\033[44mX \033[0m\033[H' + put(600))
check(test, client, Terminal(), rectangle(red))
term = Terminal()
term.feed(fallback.data)
assert term.cells[0, 0][1:3] == (196, 196), term.cells[0, 0]
PY

109
regress/image-kitty-lifecycle.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/bin/sh
# Kitty placement recovery, reflow, alternate screens and pane-local IDs.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_test import image_test, kitty
from image_terminal import Terminal
def snapshot(test, client, expected, sx=40, sy=16):
"""Check visible image pixels after an independent redraw."""
test.redraw()
terminal = Terminal(sx=sx, sy=sy)
terminal.feed(client.data)
terminal.check(expected)
red, green, blue = (255, 0, 0), (0, 255, 0), (0, 0, 255)
raw = bytes(c for y in range(32) for x in range(32)
for c in (*(red if x < 16 else blue), 255))
upload = kitty('a=t,i=7,f=32,s=32,v=32', raw)
place = kitty('a=p,i=7,p=9,C=1')
expected = {(x, y): red if x < 16 else blue
for y in range(32) for x in range(32)}
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(upload + place)
for width in (1, 40, 2, 40):
test.resize(client, width, 16)
snapshot(test, client,
{point: colour for point, colour in expected.items()
if point[0] < width * 8}, sx=width)
# Recovering a clipped named placement must still allow its replacement.
test.emit(b'\033[4;5H' + place)
snapshot(test, client,
{(x + 32, y + 48): colour for (x, y), colour in expected.items()})
# An alternate screen has its own grid, while the original grid is retained.
test.emit(b'\033[?1049h')
snapshot(test, client, {})
test.emit(b'\033[H' + kitty('a=T,i=8,p=9,f=32,s=1,v=1,C=1,c=1,r=1',
bytes((*green, 255))))
snapshot(test, client,
{(x, y): green for y in range(16) for x in range(8)})
test.emit(b'\033[?1049l')
snapshot(test, client,
{(x + 32, y + 48): colour for (x, y), colour in expected.items()})
# Kitty image rows retain their width while adjacent wrapped text reflows.
with image_test(sys.argv[1], sx=16, sy=20) as test:
client = test.attach()
text = b'abcdefghijklmnopqrstuvwx'
test.emit(text + b'\r\n' + upload + kitty('a=p,i=7,p=9') +
b'\r\n' + text + b'\r\n')
for width in (8, 16, 8, 16):
test.resize(client, width, 20)
top = (len(text) + width - 1) // width - int(test.run(
'display-message', '-p', '#{history_size}'))
assert top >= 0, top
snapshot(test, client,
{(x, y + top * 16): colour for (x, y), colour in expected.items()},
sx=width, sy=20)
captured = test.run('capture-pane', '-p', '-S', '-').replace('\n', '')
assert captured == text.decode() * 2, captured
# Equal application image and placement IDs in separate panes are independent.
with image_test(sys.argv[1]) as test:
client = test.attach()
first = test.run('display-message', '-p', '#{pane_id}')
second = test.run('split-window', '-d', '-P', '-F', '#{pane_id}',
"stty raw -echo; printf '\033]2;SECOND\007'; exec cat")
test.wait(lambda: test.run('display-message', '-pt', second,
'#{pane_title}') == 'SECOND')
placed = {}
for pane, colour in [(first, red), (second, blue)]:
test.run('select-pane', '-t', pane)
test.emit(kitty('a=T,i=7,p=9,f=32,s=1,v=1,C=1,c=1,r=1',
bytes((*colour, 255))))
x, y = map(int, test.run('display-message', '-pt', pane,
'#{pane_left},#{pane_top}').split(','))
placed.update({(x * 8 + px, y * 16 + py): colour
for py in range(16) for px in range(8)})
snapshot(test, client, placed)
test.run('select-pane', '-t', first)
test.emit(kitty('a=T,i=7,p=9,f=32,s=1,v=1,C=1,c=1,r=1',
bytes((*green, 255))))
placed = {point: green if colour == red else colour
for point, colour in placed.items()}
snapshot(test, client, placed)
test.run('kill-pane', '-t', second)
snapshot(test, client,
{(x, y): green for y in range(16) for x in range(8)})
# Respawning removes the previous application's placements and sources.
test.run('respawn-pane', '-k', '-t', first,
"stty raw -echo; printf '\033]2;RESPAWNED\007'; exec cat")
test.wait(lambda: test.run('display-message', '-p',
'#{pane_title}') == 'RESPAWNED')
snapshot(test, client, {})
test.emit(kitty('a=p,i=7,p=9,C=1'))
snapshot(test, client, {})
PY

View File

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

View File

@@ -0,0 +1,193 @@
#!/bin/sh
# Kitty ordinary placement geometry, cursor movement and replacement.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import base64
import sys
from image_test import image_test
from image_terminal import Terminal
def graphics(control, data=b''):
"""Build a Kitty graphics APC with replies suppressed."""
payload = b';' + base64.b64encode(data) if data else b''
return b'\033_Gq=2,' + control.encode() + payload + b'\033\\'
colours = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)]
raw = bytes(value for y in range(32) for x in range(16)
for value in (*colours[2 * (y >= 16) + (x >= 8)], 255))
upload = graphics('a=t,i=7,f=32,s=16,v=32', raw)
put = 'a=p,i=7,C=1'
def pattern(w=16, h=32, xoff=0, yoff=0):
"""Expected positions and colours for a scaled four-quadrant image."""
return {(x + xoff, y + yoff): colours[2 * (y >= h // 2) + (x >= w // 2)]
for y in range(h) for x in range(w)}
def solid(w, h, colour, xoff=0, yoff=0):
"""Expected positions for a solid rectangle."""
return {(x + xoff, y + yoff): colour for y in range(h) for x in range(w)}
with image_test(sys.argv[1]) as test:
client = test.attach()
fallback = test.attach(graphical=False)
def check(command, expected, cursor='0,0'):
"""Check the displayed image, its redraw and the pane cursor."""
test.emit(b'\033[2J\033[H' + command)
actual = test.run('display-message', '-p', '#{cursor_x},#{cursor_y}')
assert actual == cursor, ('cursor', actual, cursor)
term = Terminal()
term.feed(client.data)
term.check(expected)
test.redraw()
term = Terminal()
term.feed(client.data)
term.check(expected)
assert not fallback.images(), 'graphics sent to fallback client'
# Uploading alone draws nothing; placement reuses the stored pixels.
check(upload, {})
check(graphics(put), pattern())
term = Terminal()
term.feed(fallback.data)
assert term.cells[0, 0][1:3] == (196, 196), term.cells[0, 0]
check(graphics('a=T,i=7,f=32,s=16,v=32', raw), pattern(), cursor='2,1')
check(graphics(put + ',c=4'), pattern(32, 64))
check(graphics(put + ',r=4'), pattern(32, 64))
check(graphics(put + ',c=4,r=1'), pattern(32, 16))
# Crop first, then scale; oversized crop dimensions stop at the source edge.
check(graphics(put + ',y=16,w=8,h=16,c=2,r=1'), solid(16, 16, colours[2]))
check(graphics(put + ',x=8,y=16,w=999,h=999,c=1,r=1'),
solid(8, 16, colours[3]))
# Natural and explicit sizes both honour offsets within the first cell.
check(graphics(put + ',X=4,Y=8'), pattern(xoff=4, yoff=8))
check(graphics(put + ',c=3,r=3,X=4,Y=8'), pattern(20, 40, 4, 8))
check(graphics(put + ',c=1,r=1,X=4'), pattern(4, 16, 4))
check(graphics(put + ',X=4294967295'), pattern(xoff=7))
check(b'\033[1;40H' + graphics(put),
{(312 + x, y): colours[2 * (y >= 16)]
for y in range(32) for x in range(8)}, cursor='39,0')
# Stationary images clip at the bottom; moving images scroll by one row.
check(b'\033[16;1H' + graphics(put),
{(x, 240 + y): colours[x >= 8]
for y in range(16) for x in range(16)}, cursor='0,15')
check(b'\033[16;1H' + graphics('a=p,i=7'), pattern(yoff=224),
cursor='2,15')
# Named placement replacement removes the earlier display.
first = graphics(put + ',p=9')
second = b'\033[4;5H' + graphics(put + ',p=9,c=1,r=1')
check(first + second, pattern(8, 16, 32, 48), cursor='4,3')
# Placement zero creates independent displays; IDs are local to an image.
check(graphics(put) + b'\033[4;5H' + graphics(put),
pattern() | pattern(xoff=32, yoff=48), cursor='4,3')
blue = graphics('a=t,i=8,f=32,s=1,v=1', b'\0\0\xff\xff')
other = b'\033[4;5H' + graphics('a=p,i=8,p=9,C=1,c=1,r=1')
check(first + blue + other, pattern() | solid(8, 16, colours[2], 32, 48),
cursor='4,3')
# Failed placement validation preserves the old source and display.
bad = graphics('a=T,i=7,p=9,f=32,s=1,v=1,x=1,C=1', b'\0\xff\0\xff')
check(first + bad + b'\033[4;5H' + graphics(put + ',c=1,r=1'),
pattern() | pattern(8, 16, 32, 48), cursor='4,3')
check(first + graphics(put + ',p=9,x=16'), pattern())
# Successful source replacement removes all its old placements.
green = graphics('a=t,i=7,f=32,s=1,v=1', b'\0\xff\0\xff')
check(first + b'\033[4;5H' + graphics(put) + green, {}, cursor='4,3')
check(graphics(put + ',c=1,r=1'), solid(8, 16, colours[1]))
# Replacement also removes displays saved by the alternate screen.
check(upload + first + b'\033[?1049h' + green + b'\033[?1049l', {})
check(upload + first + b'\033[?1049h' + second + b'\033[?1049l', {})
# Anonymous upload-and-place does not require a stored source mapping.
check(graphics('a=T,f=32,s=1,v=1,p=9,C=1,c=1,r=1', b'\xff' * 4),
solid(8, 16, colours[3]))
# A placement keeps shared pixels after its anonymous source is released.
check(graphics('a=T,f=32,s=16,v=32,X=4,Y=8,C=1', raw),
pattern(xoff=4, yoff=8))
# Clients sample the original crop independently, including partial spans.
with image_test(sys.argv[1]) as test:
client = test.attach()
source_width, source_height = 32, 32
source = [(255, 0, 0) if (x + y) % 2 else (0, 0, 255)
for y in range(source_height) for x in range(source_width)]
pixels = bytes(value for colour in source for value in (*colour, 255))
# Shrink horizontally and enlarge vertically with transparent offsets.
command = graphics('a=T,f=32,s=32,v=32,x=4,y=4,w=24,h=24,'
'c=2,r=4,X=4,Y=8,C=1', pixels)
test.emit(command)
large = test.attach(cw=24, ch=24)
def expected(cw, ch):
"""Map output coordinates through the full placement to its crop."""
return {(x, y): source[(4 + (y * 16 - 8 * ch) * 24 // (ch * 56)) * 32 +
4 + (x * 8 - 4 * cw) * 24 // (cw * 12)]
for y in range(ch // 2, ch * 4) for x in range(cw // 2, cw * 2)}
def check_clients(clipped=False):
"""Check shared-source scaling and alignment on both clients."""
test.redraw()
terminals = []
for attached, cw, ch in [(client, 8, 16), (large, 24, 24)]:
term = Terminal(cw=cw, ch=ch)
term.feed(attached.data)
wanted = expected(cw, ch)
if clipped:
wanted = {point: colour for point, colour in wanted.items()
if point[0] < cw}
term.check(wanted)
terminals.append((attached, cw, ch, term))
return terminals
terminals = check_clients()
test.emit(b'\033[2;1H\033[X')
for attached, cw, ch, term in terminals:
term.feed(attached.data)
term.check(expected(cw, ch))
check_clients()
# Clip the right-hand span without changing either client's cell pixels.
test.run('resize-window', '-x', '1')
check_clients(clipped=True)
# Changing cell pixels must retain the same partial-span source origin.
test.resize(client, 1, 16, cw=24, ch=24)
test.redraw()
term = Terminal(cw=24, ch=24)
term.feed(client.data)
wanted = {point: colour for point, colour in expected(24, 24).items()
if point[0] < 24}
term.check(wanted)
# Offsets and source crops contribute fractional coverage to text fallback.
with image_test(sys.argv[1]) as test:
test.run('set-option', '-as', 'terminal-features', 'screen-256color:RGB')
client = test.attach(graphical=False)
pixels = b'\xff\0\0\xff' * 16 + b'\0\0\xff\xff' * 16
test.emit(graphics('a=T,f=32,s=8,v=4,x=1,w=6,c=1,r=1,X=4,Y=4,C=1',
pixels))
test.redraw()
term = Terminal()
term.feed(client.data)
char, fg, bg, bold = term.cells[0, 0]
assert char == '\u2584' and not bold, (char, bold)
assert fg == (31, 0, 95) and bg == (63, 0, 0), (fg, bg)
PY

View File

@@ -0,0 +1,53 @@
#!/bin/sh
# Pane prompts remain above Kitty images during damage and full redraws.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import shlex
import sys
from image_test import image_test, kitty
from image_terminal import Terminal
for position, row in [('top', 0), ('bottom', 3)]:
with image_test(sys.argv[1], sy=4) as test:
client = test.attach()
test.run('set-option', '-g', 'status-position', position)
test.emit(kitty('a=t,i=7,f=32,s=1,v=1', b'\xff\0\0\xff'))
test.run('command-prompt', '-P', '-C', '-b', '-l', '-p', 'PROMPT',
'display-message %1')
test.redraw()
terminal = Terminal(sy=4)
terminal.feed(client.data)
expected = {(x, y): (255, 0, 0) for y in range(64)
for x in range(32) if y // 16 != row}
client.clear()
test.marker += 1
title = 'image-%d' % test.marker
data = kitty('a=p,i=7,C=1,c=4,r=4') + \
b'\033]2;' + title.encode() + b'\007'
# Feed the pane directly, bypassing its open prompt.
test.run('pipe-pane', '-I', 'printf %%s ' +
shlex.quote(data.decode('ascii')))
test.wait(lambda: test.run('display-message', '-p',
'#{pane_title}') == title)
client.read(0.2)
test.run('pipe-pane')
terminal.feed(client.data)
terminal.check(expected)
assert ''.join(terminal.cells[x, row][0] for x in range(6)) \
== 'PROMPT', terminal.cells
test.redraw()
terminal = Terminal(sy=4)
terminal.feed(client.data)
terminal.check(expected)
assert ''.join(terminal.cells[x, row][0] for x in range(6)) \
== 'PROMPT', terminal.cells
PY

View File

@@ -0,0 +1,432 @@
#!/bin/sh
# Kitty graphics uploads, chunking, queries, placements and replies.
PATH=/bin:/usr/bin
TERM=screen
LC_ALL=C.UTF-8
export TERM LC_ALL
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
export TEST_TMUX
command -v python3 >/dev/null || exit 101
python3 -B - <<'PY'
import base64
import json
import os
from pathlib import Path
import shlex
import struct
import subprocess
import tempfile
import time
import zlib
tmux = [os.environ['TEST_TMUX'], '-u', '-Limage-protocol' + str(os.getpid()),
'-f/dev/null']
# Run a command on the test server.
def run(*args):
return subprocess.check_output(tmux + list(args), text=True).strip()
# Build a Kitty graphics APC sequence.
def graphics(control, payload=''):
return '\033_G' + control + (';' + payload if payload else '') + '\033\\'
# Read until a following device-attributes reply, including when q=2 is used.
reader = '''
import json, os, select, sys, time, tty
command, output = sys.argv[1:]
tty.setraw(0)
with open(command, 'rb') as f:
while True:
data = f.read(65536)
if not data:
break
while data:
data = data[os.write(1, data):]
os.write(1, b'\\x1b[c')
reply = b''
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if select.select([0], [], [], 0.1)[0]:
reply += os.read(0, 4096)
if b'\\x1b[?' in reply and reply.endswith(b'c'):
break
else:
raise SystemExit('missing device-attributes reply')
with open(output, 'w') as f:
json.dump(reply.split(b'\\x1b[?')[0].decode(), f)
time.sleep(30)
'''
# Check replies, cursor movement and unchanged text and title.
def check(name, command, expected, cursor='5,2', timeout=5):
output = directory / name
command_file = directory / (name + '.input')
with command_file.open('w', encoding='utf-8') as f:
f.write(origin)
if isinstance(command, str):
f.write(command)
else:
f.writelines(command)
pane_command = 'python3 -B ' + shlex.quote(str(helper)) + ' ' + \
shlex.quote(str(command_file)) + ' ' + shlex.quote(str(output))
pane = run('new-window', '-d', '-P', '-F', '#{pane_id}', pane_command)
try:
deadline = time.monotonic() + timeout
while not output.exists():
if time.monotonic() >= deadline:
raise AssertionError(name + ': missing reply capture')
time.sleep(0.05)
reply = json.loads(output.read_text())
assert reply == expected, (name, 'reply', repr(reply), repr(expected))
actual = run('display-message', '-pt', pane, '#{cursor_x},#{cursor_y}')
assert actual == cursor, (name, 'cursor', actual, cursor)
assert run('capture-pane', '-pt', pane) == '', (name, 'text')
title = run('display-message', '-pt', pane, '#{pane_title}')
assert title == 'kitty-test', (name, 'title', title)
return reply
finally:
run('kill-window', '-t', pane)
with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp:
directory = Path(tmp)
helper = directory / 'read-reply.py'
helper.write_text(reader)
origin = '\033]2;kitty-test\007\033[3;6H'
try:
run('new-session', '-d', '-x', '40', '-y', '12')
if run('display-message', '-p', '#{image_support}') == '0':
raise SystemExit(101)
run('set', '-g', 'status', 'off')
pixel = '/wAA/w=='
transmit = 'a=t,i=7,f=32,s=1,v=1'
ok = graphics('i=7', 'OK')
invalid = graphics('i=7', 'EINVAL')
check('transmit', graphics(transmit, pixel), ok)
check('defaults', graphics('i=7,s=1,v=1', pixel), ok)
check('rgb', graphics('a=t,i=7,f=24,s=1,v=1', '/wAA'), ok)
check('unpadded', graphics(transmit, '/wAA/w'), ok)
check('placement-id-reply', graphics(transmit + ',p=9', pixel),
graphics('i=7,p=9', 'OK'))
check('chunks', graphics(transmit + ',m=1', '/wAA') +
graphics('m=0', '/w=='), ok)
check('nonzero-padding', graphics(transmit + ',m=1', '/wAA') +
graphics('m=0', '/z=='), invalid)
check('empty-decoded-chunk', graphics(transmit + ',m=1', ' ') +
graphics('m=0', pixel), ok)
check('empty-final-chunk', graphics(transmit + ',m=1', pixel) +
graphics('m=0'), ok)
check('incomplete', graphics(transmit + ',m=1', '/wAA'), '')
check('failed-chunk', graphics(transmit + ',m=1', '/wAA') +
graphics('m=0', '!!!!') + graphics(transmit, pixel), invalid + ok)
check('quiet-ok', graphics(transmit + ',q=1', pixel), '')
check('quiet-two', graphics(transmit + ',q=2', pixel), '')
check('quiet-one-error', graphics(transmit + ',q=1', '!!!!'), invalid)
check('quiet-two-error', graphics(transmit + ',q=2', '!!!!'), '')
check('quiet-continuation', graphics(transmit + ',q=2,m=1', '/wAA') +
graphics('m=0', '/w=='), '')
check('quiet-control-error', graphics('a=t,q=2,i=7,s=bad'), '')
check('anonymous', graphics('a=t,f=32,s=1,v=1', pixel), '')
check('anonymous-error', graphics('a=t,f=32,s=1,v=1', '!!!!'), '')
check('query', graphics('a=q,f=32,s=1,v=1', pixel),
graphics('i=0', 'OK'))
check('query-chunks', graphics('a=q,i=7,f=32,s=1,v=1,m=1', '/wAA') +
graphics('m=0', '/w=='), ok)
check('replace', graphics(transmit, pixel) +
graphics(transmit, 'AP8A/w=='), ok + ok)
check('transmit-and-place',
graphics('a=T,i=7,f=32,s=1,v=1', pixel), ok, cursor='6,2')
check('transmit-and-place-sized',
graphics('a=T,i=7,f=32,s=1,v=1,c=3,r=2', pixel),
ok, cursor='8,3')
check('place-stored', graphics(transmit + ',q=2', pixel) +
graphics('a=p,i=7'), ok, cursor='6,2')
check('place-no-cursor', graphics(transmit + ',q=2', pixel) +
graphics('a=p,i=7,C=1,c=3,r=2'), ok)
check('place-named', graphics(transmit + ',q=2', pixel) +
graphics('a=p,i=7,p=9,C=1'), graphics('i=7,p=9', 'OK'))
check('place-missing', graphics('a=p,i=7'),
graphics('i=7', 'ENOENT'))
check('query-does-not-store',
graphics('a=q,q=2,i=7,f=32,s=1,v=1', pixel) +
graphics('a=p,i=7'), graphics('i=7', 'ENOENT'))
check('place-chunks', graphics('a=T,i=7,f=32,s=1,v=1,'
'c=3,r=2,C=1,m=1', '/wAA') + graphics('m=0', '/w=='), ok)
for n, geometry in enumerate(['x=1', 'y=1', 'c=65536', 'r=65536',
'c=65534,r=32767']):
check('invalid-placement-' + str(n),
graphics(transmit + ',q=2', pixel) +
graphics('a=p,i=7,C=1,' + geometry), invalid)
check('failed-transmit-keeps-source',
graphics(transmit + ',q=2', pixel) +
graphics('a=T,i=7,f=32,s=1,v=1,x=1,C=1', pixel) +
graphics('a=p,i=7,C=1'), invalid + ok)
check('place-at-bottom', '\033[12;6H' +
graphics('a=T,i=7,f=32,s=1,v=1,c=3,r=2', pixel),
ok, cursor='8,11')
check('stationary-at-bottom', '\033[12;6H' +
graphics('a=T,i=7,f=32,s=1,v=1,c=3,r=2,C=1', pixel),
ok, cursor='5,11')
for n, payload in enumerate(['', ' ', '!!!!', '====', '/w',
'/wAA/wAA']):
check('invalid-payload-' + str(n),
graphics(transmit, payload), invalid)
for control in ['f=12,s=1,v=1', 'f=32,s=0,v=1', 'f=32,s=1,v=0',
'f=32,s=bad,v=1', 'f=32,s=4294967296,v=1',
'f=32,s=1,v=1,o=x']:
check('invalid-control-' + control,
graphics('a=t,i=7,' + control, pixel), invalid)
for action in ['f', 'a', 'c']:
check('unsupported-action-' + action,
graphics('a=%s,i=7' % action), graphics('i=7', 'ENOTSUP'))
for medium in ['f', 't', 's']:
check('unsupported-medium-' + medium,
graphics('a=t,i=7,t=' + medium), graphics('i=7', 'ENOTSUP'))
check('unsupported-number', graphics('a=t,I=7,f=32,s=1,v=1', pixel),
graphics('I=7', 'ENOTSUP'))
check('id-and-number', graphics('a=t,i=7,I=8,f=32,s=1,v=1', pixel),
graphics('i=7,I=8', 'EINVAL'))
check('unknown-extension', graphics(transmit + ',k=9', pixel), ok)
check('unknown-extension-string',
graphics(transmit + ',k=unused', pixel), ok)
check('trailing-comma', graphics(transmit + ',', pixel), ok)
check('maximum-ids',
graphics('a=t,i=4294967295,p=4294967295,f=32,s=1,v=1', pixel),
graphics('i=4294967295,p=4294967295', 'OK'))
unsupported = graphics('i=7', 'ENOTSUP')
check('default-layer', graphics(transmit + ',z=0', pixel), ok)
for z in [-2147483648, -1073741824, -1, 1, 2147483647]:
check('unsupported-layer-' + str(z),
graphics(transmit + ',z=' + str(z), pixel), unsupported)
for action in ['t', 'T', 'p', 'q', 'd']:
check('unsupported-action-layer-' + action,
graphics('a=%s,i=7,p=9,f=32,s=1,v=1,z=-1' % action,
pixel), graphics('i=7,p=9', 'ENOTSUP'))
for quiet in [1, 2]:
check('unsupported-layer-quiet-' + str(quiet),
graphics(transmit + ',q=%d,z=1' % quiet, pixel),
unsupported if quiet == 1 else '')
check('zero-layer-chunks', graphics(transmit + ',z=0,m=1', '/wAA') +
graphics('m=0,z=0', '/w=='), ok)
check('unsupported-layer-aborts-chunks',
graphics(transmit + ',m=1', '/wAA') +
graphics('m=0,z=1', '/w==') + graphics(transmit, pixel),
unsupported + ok)
check('unsupported-layer-does-not-start-chunks',
graphics(transmit + ',m=1,z=1', '/wAA') +
graphics('m=0', '/w==') + graphics(transmit, pixel),
unsupported + ok)
# Reply controls must be read even after an unsupported field.
check('quiet-after-layer',
graphics(transmit + ',z=1,q=2', pixel), '')
check('id-after-layer',
graphics('a=q,z=1,i=7,f=32,s=1,v=1', pixel), unsupported)
check('placement-after-layer',
graphics('z=-1,a=q,i=7,p=9,q=1', pixel),
graphics('i=7,p=9', 'ENOTSUP'))
check('quiet-after-invalid-control',
graphics('a=t,i=7,s=bad,q=2', pixel), '')
check('id-after-invalid-control',
graphics('s=bad,a=t,i=7,p=9', pixel),
graphics('i=7,p=9', 'EINVAL'))
check('id-after-malformed-control',
graphics('broken,a=q,i=7,p=9', pixel),
graphics('i=7,p=9', 'EINVAL'))
check('quiet-after-malformed-control',
graphics('broken,a=t,i=7,q=2', pixel), '')
check('first-error-invalid',
graphics('s=bad,z=1,a=q,i=7,p=9', pixel),
graphics('i=7,p=9', 'EINVAL'))
check('first-error-unsupported',
graphics('z=1,s=bad,a=q,i=7,p=9', pixel),
graphics('i=7,p=9', 'ENOTSUP'))
for control in ['U=1', 'P=99', 'Q=9', 'H=1', 'H=-1', 'V=1', 'V=-1']:
check('unsupported-placement-' + control,
graphics('a=T,i=7,f=32,s=1,v=1,' + control, pixel),
unsupported)
check('quiet-unsupported-placement-' + control,
graphics('a=T,i=7,' + control + ',q=2', pixel), '')
check('zero-placement-extensions', graphics(transmit +
',U=0,P=0,Q=0,H=0,V=0', pixel), ok)
for z in ['-2147483649', '2147483648', '1x', '', 'bad']:
check('invalid-layer-' + z,
graphics(transmit + ',z=' + z, pixel), invalid)
for n, control in enumerate(['s=', 's=-1', 's=1x', 'a=tt', 't=dd',
'o=zz', '=1', 'ss=1', ',s=1',
's=1,,v=1']):
check('malformed-control-' + str(n),
graphics('i=7,' + control, pixel), invalid)
check('failed-control-resets-transfer',
graphics(transmit + ',m=1', '/wAA') +
graphics('m=0,s=bad') + graphics(transmit, pixel), invalid + ok)
check('quiet-resets', graphics(transmit + ',q=2', pixel) +
graphics(transmit, pixel), ok)
check('oversized-dimensions',
graphics('a=t,i=7,f=32,s=4294967295,v=4294967295', pixel),
invalid)
check('delete-aborts-upload', graphics(transmit + ',q=2,m=1', '/wAA') +
graphics('a=d,d=I,q=2') + graphics(transmit, pixel), ok)
# Successful deletion sends no reply and never moves the cursor.
stored = graphics(transmit + ',q=2', pixel)
put = 'a=p,q=2,i=7,C=1'
first = graphics(put + ',p=1')
second = graphics(put + ',p=2')
missing = graphics('i=7', 'ENOENT')
check('delete-soft-image', stored + first + graphics('a=d,d=i,i=7') +
graphics('a=p,i=7,C=1'), ok)
check('delete-hard-image', stored + first + graphics('a=d,d=I,i=7') +
graphics('a=p,i=7,C=1'), missing)
check('delete-one-placement', stored + first + second +
graphics('a=d,d=I,i=7,p=1') + graphics('a=p,i=7,p=2,C=1'),
graphics('i=7,p=2', 'OK'))
check('delete-last-placement', stored + first +
graphics('a=d,d=I,i=7,p=1') + graphics('a=p,i=7,C=1'), missing)
transparent = graphics('a=T,i=7,p=1,q=2,C=1,f=32,s=1,v=1',
'/wAAAA==')
for selector in ['a', 'i', 'A', 'I']:
check('delete-transparent-' + selector, transparent +
graphics('a=d,d=%s,i=7,p=1' % selector) +
graphics('a=p,i=7,C=1'),
missing if selector.isupper() else ok)
check('delete-transparent-retains-other-placement', transparent +
second + graphics('a=d,d=I,i=7,p=1') +
graphics('a=p,i=7,C=1'), ok)
check('delete-retain-unnumbered', stored + first + graphics(put) +
graphics('a=d,d=I,i=7,p=1') + graphics('a=p,i=7,C=1'), ok)
check('delete-missing-placement', stored + first +
graphics('a=d,d=I,i=7,p=99') + graphics('a=p,i=7,C=1'), ok)
check('delete-upload-only', stored + graphics('a=d,d=I,i=7') +
graphics('a=p,i=7,C=1'), missing)
check('delete-soft-all', stored + first + graphics('a=d,d=a') +
graphics('a=p,i=7,C=1'), ok)
check('delete-hard-all', stored + first + graphics('a=d,d=A') +
graphics('a=p,i=7,C=1'), missing)
check('delete-all-retains-unplaced', stored + graphics('a=d,d=A') +
graphics('a=p,i=7,C=1'), ok)
check('delete-history-retains-data', stored + first + '\033[12S' +
graphics('a=d,d=A') + graphics('a=p,i=7,C=1'), ok)
check('delete-history-by-id', stored + first + '\033[12S' +
graphics('a=d,d=I,i=7') + graphics('a=p,i=7,C=1'), missing)
for selector in ['A', 'I']:
check('delete-saved-screen-' + selector, stored + first +
'\033[?1049h' + graphics('a=d,d=' + selector + ',i=7') +
'\033[?1049l' + graphics('a=p,i=7,C=1'), ok)
check('delete-abort-does-not-inherit-id', stored +
graphics(transmit + ',q=2,m=1', '/wAA') +
graphics('a=d,d=I') + graphics('a=p,i=7,C=1'), ok)
check('unsupported-delete', stored + graphics('a=d,d=?,i=7'),
graphics('i=7', 'ENOTSUP'))
check('malformed-delete', stored + graphics('a=d,i=7,d=ii'), invalid)
# Unsupported selectors leave uploaded data and placements intact.
for selector in 'cCpPqQrRxXyYzZ':
control = 'a=d,d=%s,i=7,x=6,y=3,z=0' % selector
check('unsupported-delete-' + selector, stored + first +
graphics(control) + graphics('a=p,i=7,C=1'),
unsupported + ok)
for quiet in [1, 2]:
check('unsupported-delete-quiet-' + str(quiet), stored + first +
graphics('a=d,d=P,i=7,q=%d,x=6,y=3' % quiet) +
graphics('a=p,i=7,C=1'),
(unsupported if quiet == 1 else '') + ok)
check('unsupported-delete-named', stored + first +
graphics('a=d,d=Z,i=7,p=1') + graphics('a=p,i=7,C=1'),
graphics('i=7,p=1', 'ENOTSUP') + ok)
check('unsupported-delete-aborts-upload', stored +
graphics(transmit + ',q=2,m=1', '/wAA') +
graphics('a=d,d=P,i=7') + graphics('a=p,i=7,C=1'),
unsupported + ok)
check('unsupported-delete-does-not-inherit-id', stored +
graphics(transmit + ',m=1', '/wAA') + graphics('a=d,d=P') +
graphics('a=p,i=7,C=1'), ok)
check('unsupported-layer-delete-aborts-upload', stored +
graphics(transmit + ',q=2,m=1', '/wAA') +
graphics('a=d,d=I,i=7,z=1') + graphics('a=p,i=7,C=1'),
unsupported + ok)
check('unsupported-layer-delete-does-not-inherit-id', stored +
graphics(transmit + ',m=1', '/wAA') +
graphics('a=d,d=I,z=-1') + graphics('a=p,i=7,C=1'), ok)
for action in ['t', 'T', 'q']:
check('unsupported-layer-keeps-source-' + action, stored +
graphics('a=%s,i=7,f=32,s=1,v=1,z=1' % action, pixel) +
graphics('a=p,i=7,C=1'), unsupported + ok)
check('unsupported-layer-query-does-not-store',
graphics('a=q,i=7,f=32,s=1,v=1,z=1', pixel) +
graphics('a=p,i=7,C=1'), unsupported + missing)
# Abort immediately when chunked data exceeds 64 MiB, then recover.
block = base64.b64encode(b'\0' * (512 * 1024)).decode()
commands = [graphics(transmit + ',q=1,m=1', block)]
commands.extend([graphics('m=1', block)] * 127)
commands.extend([graphics('m=1', 'AAAAAA=='),
graphics('a=t,i=8,f=32,s=1,v=1', pixel)])
check('accumulated-size-limit', commands,
invalid + graphics('i=8', 'OK'), timeout=30)
# Compressed uploads require a test build with zlib.
compressed = base64.b64encode(zlib.compress(b'\xff\0\0\xff')).decode()
control = transmit + ',o=z'
for format, raw in [(24, b'\xff\0\0'), (32, b'\xff\0\0\xff')]:
payload = base64.b64encode(zlib.compress(raw)).decode()
check('compressed-' + str(format),
graphics('a=t,i=7,f=%d,s=1,v=1,o=z' % format, payload),
ok)
split = len(compressed) // 8 * 4
check('compressed-chunks', graphics(control + ',m=1',
compressed[:split]) + graphics('m=0', compressed[split:]), ok)
check('compressed-truncated', graphics(control, compressed[:-4]),
invalid)
check('compressed-wrong-size',
graphics(control.replace('s=1', 's=2'), compressed), invalid)
large = base64.b64encode(zlib.compress(
b'\0' * (4096 * 4097 * 3))).decode()
check('oversized-rgb',
graphics('a=t,i=7,f=24,s=4096,v=4097,o=z', large), invalid)
# PNG uploads require a test build with libpng.
png = b'\x89PNG\r\n\x1a\n'
for kind, data in [
(b'IHDR', struct.pack('>2I5B', 8, 16, 8, 6, 0, 0, 0)),
(b'IDAT', zlib.compress((b'\0' + b'\xff\0\0\xff' * 8) * 16)),
(b'IEND', b'')]:
png += struct.pack('>I', len(data)) + kind + data + \
struct.pack('>I', zlib.crc32(kind + data))
payload = base64.b64encode(png).decode()
control = 'a=t,i=7,f=100'
check('png', graphics(control, payload), ok)
truncated = base64.b64encode(png[:40]).decode()
check('png-truncated', graphics(control, truncated), invalid)
damaged = bytearray(png)
damaged[29] ^= 1
check('png-bad-header-crc',
graphics(control, base64.b64encode(damaged).decode()), invalid)
split = len(payload) // 8 * 4
check('png-chunks', graphics(control + ',m=1', payload[:split]) +
graphics('m=0', payload[split:]), ok)
payload = base64.b64encode(zlib.compress(png)).decode()
control += ',o=z'
sized = control + ',S=' + str(len(png))
check('compressed-png', graphics(sized, payload), ok)
split = len(payload) // 8 * 4
check('compressed-png-chunks',
graphics(sized + ',m=1', payload[:split]) +
graphics('m=0', payload[split:]), ok)
check('compressed-png-missing-size', graphics(control, payload),
invalid)
check('compressed-png-wrong-size',
graphics(control + ',S=' + str(len(png) + 1), payload),
invalid)
check('compressed-png-oversized',
graphics(control + ',S=67108865', payload), invalid)
check('compressed-png-truncated', graphics(sized, payload[:-4]),
invalid)
check('decoder-controls-reset', graphics(sized + ',q=2', payload) +
graphics('a=T,i=7,s=1,v=1', pixel), ok, cursor='6,2')
finally:
subprocess.run(tmux + ['kill-server'], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
PY

102
regress/image-kitty-transfers.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/bin/sh
# Kitty transfer state, fragmented APCs, interleaved text and failed uploads.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_test import image_test, kitty
from image_terminal import Terminal
def check(test, client, expected):
"""Check immediate output and independently redrawn image pixels."""
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
red, blue = (255, 0, 0), (0, 0, 255)
with image_test(sys.argv[1]) as test:
test.run('set-option', '-g', 'scroll-on-clear', 'off')
client = test.attach()
# Many separately encoded chunks reconstruct exactly the original pixels.
w, h = 64, 32
colours = [red if (x // 8 + y // 8) % 2 else blue
for y in range(h) for x in range(w)]
raw = bytes(c for pixel in colours for c in (*pixel, 255))
command = kitty('a=T,i=7,p=9,C=1,f=32,s=64,v=32,m=1', raw[:128])
for offset in range(128, len(raw), 128):
more = int(offset + 128 < len(raw))
command += kitty('m=%d' % more, raw[offset:offset + 128])
test.emit(command)
check(test, client, {(x, y): colours[y * w + x]
for y in range(h) for x in range(w)})
# A query must neither replace the existing source nor draw its own pixels.
test.emit(kitty('a=q,i=7,f=32,s=1,v=1', b'\0\xff\0\xff') +
b'\033[2J\033[H' + kitty('a=p,i=7,p=9,C=1'))
expected = {(x, y): colours[y * w + x]
for y in range(h) for x in range(w)}
check(test, client, expected)
# Failed decoding must leave the earlier source and its placement intact.
for control, data in [('f=24,s=1,v=1', b'\xff\0'),
('f=32,s=1,v=1,o=z', b'not zlib'),
('f=100', b'not PNG')]:
test.emit(kitty('a=T,i=7,p=9,C=1,' + control, data))
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
test.emit(b'\033[2J\033[H' + kitty('a=p,i=7,p=9,C=1'))
check(test, client, expected)
# Text and cursor movement between chunks do not reset the upload.
test.emit(b'\033[2J\033[H' + kitty(
'a=T,i=7,p=9,f=32,s=1,v=1,c=2,r=2,m=1', b'\xff\0\0'))
assert not client.images(), 'incomplete upload drew an image'
assert test.run('display-message', '-p', '#{cursor_x},#{cursor_y}') == '0,0'
test.emit(b'between\033[3;5H')
test.emit(kitty('m=0', b'\xff'))
check(test, client, {(x, y): red
for y in range(32, 64) for x in range(32, 48)})
assert test.run('display-message', '-p', '#{cursor_x},#{cursor_y}') == '6,3'
assert test.run('capture-pane', '-p') == 'between'
# Split the introducer, controls, payload and terminator across writes.
test.emit(b'\033[2J\033[H')
wire = kitty('a=T,i=7,p=9,C=1,f=32,s=1,v=1,c=1,r=1', b'\0\0\xff\xff')
cuts = [1, 2, 3, wire.index(b';'), wire.index(b';') + 3, len(wire) - 1]
offset = 0
for end in cuts:
test.send(wire[offset:end])
test.wait(lambda: test.run('capture-pane', '-p', '-P') ==
wire[:end].decode())
if end < len(wire) - 1:
assert not client.images(), 'unfinished APC drew an image'
offset = end
# The input parser can dispatch the APC when it receives the ST's ESC.
client.read(0.2)
terminal = Terminal()
terminal.feed(client.data)
test.emit(wire[offset:])
terminal.feed(client.data)
expected = {(x, y): blue for y in range(16) for x in range(8)}
terminal.check(expected)
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
assert test.run('capture-pane', '-p') == '', 'APC bytes appeared as text'
PY

141
regress/image-limits.sh Normal file
View File

@@ -0,0 +1,141 @@
#!/bin/sh
# Image quotas evict uploads and placements across protocols and saved grids.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_test import image_test, kitty, sixel
from image_terminal import Terminal
def upload(image_id, colour):
"""Upload one solid source pixel without placing it."""
return kitty('a=t,f=32,s=1,v=1,i=%d' % image_id, bytes((*colour, 255)))
def place(image_id, placement_id=0):
"""Place one image at the cursor without moving the cursor."""
return kitty('a=p,i=%d,p=%d,c=1,r=1,C=1' % (image_id, placement_id))
def cell(colour, x=0, y=0):
"""Expand a solid image cell into positioned pixels."""
return {(px, py): colour for py in range(y * 16, (y + 1) * 16)
for px in range(x * 8, (x + 1) * 8)}
def snapshot(test, client, expected):
"""Check retained images after a full redraw."""
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
red, green, blue = (255, 0, 0), (0, 255, 0), (0, 0, 255)
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(upload(1, red) +
b''.join(upload(i, green) for i in range(2, 1001)))
test.emit(upload(1001, blue))
# The oldest upload is evicted and its application ID no longer resolves.
test.emit(place(1))
snapshot(test, client, {})
test.emit(place(1001))
snapshot(test, client, cell(blue))
test.emit(b'\033[3;1H' + place(2))
snapshot(test, client, cell(blue))
test.emit(place(1000))
snapshot(test, client, cell(blue) | cell(green, y=2))
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(upload(1, red) + place(1, 1))
test.emit(upload(2, green))
test.emit(kitty('a=d,d=i,i=1,p=1'))
# Losing the last placement keeps the source's original last use order.
test.emit(b''.join(upload(i, blue) for i in range(3, 1002)))
test.emit(place(2))
snapshot(test, client, cell(green))
test.emit(b'\033[3;1H' + place(1))
snapshot(test, client, cell(green))
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(upload(1, red) + b'\033[7;1H' + place(1, 1) +
b'\033[H' + place(1, 2) + b'\033[3;1H' + place(1, 3) +
b'\033[5;1H' + b''.join(place(1, p) for p in range(4, 1000)))
snapshot(test, client,
cell(red) | cell(red, y=2) | cell(red, y=4) | cell(red, y=6))
# Redraw order differs from creation order: the top view is oldest.
test.emit(upload(2, green))
snapshot(test, client, cell(red, y=2) | cell(red, y=4) | cell(red, y=6))
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(upload(1, red) + b''.join(place(1, p) for p in range(1, 1000)))
test.emit(upload(2, green) + place(2, 1))
snapshot(test, client, cell(green))
# The shared source survives; only its two oldest views were evicted.
test.emit(kitty('a=d,d=I,i=2'))
snapshot(test, client, cell(red))
test.emit(b''.join(kitty('a=d,d=i,i=1,p=%d' % p)
for p in range(3, 1000)))
snapshot(test, client, {})
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(sixel(8, 16, [(100, 0, 0)], [0] * (8 * 16)))
# Kitty views share the same limit as the older SIXEL source.
test.emit(b'\033[3;1H' + upload(1, green) +
b''.join(place(1, p) for p in range(1, 1000)))
snapshot(test, client, cell(green, y=2))
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(upload(1, red) + place(1))
test.emit(b'\033[3;1H' + upload(2, green) +
b''.join(place(2, p) for p in range(1, 998)))
snapshot(test, client, cell(red) | cell(green, y=2))
# SIXEL input also evicts Kitty placements and their uploaded image IDs.
test.emit(b'\033[5;1H' +
sixel(8, 16, [(0, 0, 100)], [0] * (8 * 16)))
snapshot(test, client, cell(green, y=2) | cell(blue, y=4))
test.emit(b'\033[7;1H' +
sixel(8, 16, [(0, 0, 100)], [0] * (8 * 16)))
snapshot(test, client, cell(green, y=2) | cell(blue, y=4) | cell(blue, y=6))
test.emit(b'\033[H' + place(1))
snapshot(test, client, cell(green, y=2) | cell(blue, y=4) | cell(blue, y=6))
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(upload(1, red) + place(1))
test.emit(b'\033[?1049h\033[H' + upload(2, green) +
b''.join(place(2, p) for p in range(1, 1000)))
# Eviction reaches the saved primary grid and removes its Kitty ID.
test.emit(b'\033[?1049l')
snapshot(test, client, {})
test.emit(place(1))
snapshot(test, client, {})
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(sixel(8, 16, [(100, 0, 0)], [0] * (8 * 16)))
test.run('copy-mode')
# Keep the copied grid alive in a hidden window while exceeding the quota.
test.run('new-window', '-n', 'quota', '/bin/sh', '-c',
"stty raw -echo; printf '\033]2;QUOTA\007'; exec cat")
test.wait(lambda: test.run('display-message', '-p',
'#{pane_title}') == 'QUOTA')
test.emit(upload(1, green) + b''.join(place(1, p) for p in range(1, 1000)))
test.run('select-window', '-t', 'image:0')
snapshot(test, client, {})
test.run('send-keys', '-X', 'cancel')
snapshot(test, client, {})
PY

View File

@@ -0,0 +1,55 @@
#!/bin/sh
# Height reduction discards image rows without leaving spans behind.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
command -v python3 >/dev/null || exit 101
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_test import image_test, kitty, sixel
from image_terminal import Terminal
def snapshot(test, client, expected, sy):
"""Check image pixels after a full redraw at the current height."""
test.redraw()
terminal = Terminal(sy=sy)
terminal.feed(client.data)
terminal.check(expected)
red, green = (255, 0, 0), (0, 255, 0)
for protocol in ('kitty', 'sixel'):
if protocol == 'kitty':
image = kitty('a=T,f=32,s=1,v=1,c=2,r=5,C=1', bytes((*red, 255)))
lower = kitty('a=T,f=32,s=1,v=1,c=2,r=1,C=1', bytes((*green, 255)))
else:
image = sixel(16, 80, [(100, 0, 0)], [0] * (16 * 80))
lower = sixel(16, 16, [(0, 100, 0)], [0] * (16 * 16))
for alternate in (False, True):
with image_test(sys.argv[1]) as test:
client = test.attach()
if alternate:
test.emit(b'\033[?1049h')
test.emit(b'\033[9;3H' + image + b'\033[14;3H' + lower +
b'\033[H')
expected = {(x, y): red for y in range(8 * 16, 13 * 16)
for x in range(16, 32)}
expected.update({(x, y): green for y in range(13 * 16, 14 * 16)
for x in range(16, 32)})
snapshot(test, client, expected, 16)
# One placement is clipped and the other is discarded entirely.
test.resize(client, 40, 10)
snapshot(test, client, {(x, y): red for y in range(8 * 16, 10 * 16)
for x in range(16, 32)}, 10)
# Discarded rows must not keep the remaining placement fixed.
test.emit(b'\033[8;10r\033[S\033[r')
snapshot(test, client, {(x, y): red for y in range(7 * 16, 9 * 16)
for x in range(16, 32)}, 10)
PY

View File

@@ -10,25 +10,31 @@ export TERM
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_test import image_test, sixel
from image_test import image_test, kitty, sixel
levels = [0, 14, 29, 43, 57, 71, 86, 100]
palette = [(r, g, b) for r in levels for g in levels for b in levels]
expected = [tuple((c * 255 + 50) // 100 for c in p) for p in palette]
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(sixel(32, 16, palette, list(range(512))))
images = client.images()
assert len(images) == 1 and images[0][:2] == (32, 16)
actual = images[0][2]
assert all(c is not None for c in actual), 'dithering introduced holes'
assert 128 <= len(set(actual)) <= 256, len(set(actual))
errors = [abs(a - e) for p, q in zip(actual, expected) for a, e in zip(p, q)]
assert max(errors) <= 80, max(errors)
assert sum(errors) / len(errors) <= 16, sum(errors) / len(errors)
assert max(actual[0]) <= 30 and min(actual[-1]) >= 225, (
'black/white extremes lost', actual[0], actual[-1])
for _ in range(3):
test.redraw()
assert client.images() == images, 'palette or dithering changed on redraw'
raw = bytes(c for rgb in expected for c in (*rgb, 255))
commands = [sixel(32, 16, palette, list(range(512))),
kitty('a=T,i=7,f=32,s=32,v=16,C=1', raw)]
for command in commands:
with image_test(sys.argv[1]) as test:
client = test.attach()
test.emit(command)
images = client.images()
assert len(images) == 1 and images[0][:2] == (32, 16)
actual = images[0][2]
assert all(c is not None for c in actual), 'dithering introduced holes'
assert 128 <= len(set(actual)) <= 256, len(set(actual))
errors = [abs(a - e) for p, q in zip(actual, expected)
for a, e in zip(p, q)]
assert max(errors) <= 80, max(errors)
assert sum(errors) / len(errors) <= 16, sum(errors) / len(errors)
assert max(actual[0]) <= 30 and min(actual[-1]) >= 225, (
'black/white extremes lost', actual[0], actual[-1])
for _ in range(3):
test.redraw()
assert client.images() == images, \
'palette or dithering changed on redraw'
PY

View File

@@ -68,6 +68,20 @@ class Terminal:
self.pixels.update(moved)
self.cells.update(cells)
def shift(self, x, y, n):
"""Move pixels and text when characters are inserted or deleted."""
moved = {}
for (px, py), colour in list(self.pixels.items()):
if x * self.cw <= px < self.sx * self.cw and \
y * self.ch <= py < (y + 1) * self.ch:
if x * self.cw <= px - n * self.cw < self.sx * self.cw:
moved[px - n * self.cw, py] = colour
cells = {(cx - n, cy): cell for (cx, cy), cell in self.cells.items()
if cy == y and x <= cx < self.sx and x <= cx - n < self.sx}
self.erase(x, y, self.sx - x, 1)
self.pixels.update(moved)
self.cells.update(cells)
def csi(self, params, command):
"""Apply cursor movement and erases; ignore terminal mode controls."""
if params.startswith((b'?', b'>', b'=')) or b' ' in params:
@@ -88,6 +102,8 @@ class Terminal:
self.y = max(0, self.y + dy)
elif command == ord('X'):
self.erase(self.x, self.y, n, 1)
elif command in b'@P':
self.shift(self.x, self.y, n if command == ord('P') else -n)
elif command == ord('K'):
start = self.x if args[0] == 0 else 0
end = self.x + 1 if args[0] == 1 else self.sx

View File

@@ -1,5 +1,6 @@
"""Helpers for image tests using an attached client on a PTY."""
import base64
import contextlib
import colorsys
import errno
@@ -14,6 +15,7 @@ import subprocess
import sys
import termios
import time
import zlib
class Client:
@@ -212,6 +214,26 @@ def image_test(tmux, sx=40, sy=16):
test.close()
def kitty(control, data=b''):
"""Build a Kitty graphics APC with replies suppressed."""
return b'\033_Gq=2,' + control.encode() + b';' + \
base64.b64encode(data) + b'\033\\'
def png(w, h, rows, colour_type=6, chunks=()):
"""Build an eight-bit PNG from unfiltered rows and optional chunks."""
assert len(rows) == h, (len(rows), h)
parts = [(b'IHDR', struct.pack('>2I5B', w, h, 8, colour_type, 0, 0, 0))]
parts.extend(chunks)
parts.append((b'IDAT', zlib.compress(b''.join(b'\0' + row for row in rows))))
parts.append((b'IEND', b''))
out = b'\x89PNG\r\n\x1a\n'
for kind, data in parts:
out += struct.pack('>I', len(data)) + kind + data + \
struct.pack('>I', zlib.crc32(kind + data))
return out
def sixel(w, h, palette, pixels, p2=1):
"""Build SIXEL input from a palette and row-major palette indexes."""
assert len(pixels) == w * h