Merge image_merge into 4902-image-support

Keep retained indexed SIXEL data and the bounded per-terminal output cache.
Use the cleaned-up decoder and RGBA encoder from image_merge, including
parser memory limits, pixel-size handling and references for pending output.
Match retained SIXEL scaling to the shared image cell footprint and emit
black definitions for undefined colour registers.

Keep Kitty input, output, layers and placeholders. Adapt backend selection,
flags, drawing and cleanup to the callbacks and Iscrl capability. Keep the
pane-status repaint with the requested XXX comment.

Replace the elaborate fallback with image_merge's implementation, removing
its caches, sample structures, quadrant/sextant features and ACS additions.
Keep the image_merge regressions and adapt the Kitty tests to the simpler
fallback. Use skip status 101 and disable Python bytecode for image tests.

Small changes still outside image_merge:
- Guard zero-height synchronized redraws before bit_nset.
- Select the image backend when the tty opens.
- Enable imagescroll by default for Kitty and Ghostty.
- Keep the explanatory manual-window pixel-size comment (comment only).
- Keep the additional SIXEL fixtures and visual test tools.

Validation: image-enabled and disabled builds; 31 image and 13 related
regressions pass normally and under ASAN. All image tests skip without
images. Focused palette, parser, scaling, transparency and Kitty layer
tests also pass after the retained-output adjustments. Diff checks clean.
This commit is contained in:
Nicholas Marriott
2026-10-14 14:28:22 +01:00
57 changed files with 2722 additions and 2295 deletions

View File

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

View File

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

View File

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

10
regress/image-common.inc Normal file
View File

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

108
regress/image-fallback.sh Executable file
View File

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

34
regress/image-feature-redraw.sh Executable file
View File

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

View File

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

125
regress/image-grid-operations.sh Executable file
View File

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

76
regress/image-grid.sh Executable file
View File

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

View File

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

View File

@@ -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}',

View File

@@ -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}')

View File

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

74
regress/image-lifecycle.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

96
regress/image-sixel-output.sh Executable file
View File

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

View File

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

45
regress/image-sixel-palette.sh Executable file
View File

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

68
regress/image-sixel-parse.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

133
regress/image_scroll.py Normal file
View File

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

192
regress/image_terminal.py Normal file
View File

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

307
regress/image_test.py Normal file
View File

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

View File

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