Image regress tests.

This commit is contained in:
Nicholas Marriott
2026-10-14 12:44:53 +01:00
parent 1e294b175f
commit 1de5f662cb
29 changed files with 2014 additions and 13 deletions

View File

@@ -79,7 +79,7 @@ jobs:
- name: build
run: |
sh autogen.sh
./configure ${{ matrix.configure }}
./configure --enable-images ${{ matrix.configure }}
${{ matrix.make }} -j"$(getconf _NPROCESSORS_ONLN)"
- name: test

View File

@@ -496,10 +496,8 @@ sixel_parse(const char *buf, size_t len, u_int p1, u_int p2, u_int xpixel,
si->dx = 0;
break;
default:
if (ch < 0x20)
break;
if (ch < 0x3f || ch > 0x7e)
goto bad;
break;
if (sixel_parse_write(si, ch - 0x3f) != 0) {
log_debug("%s: size limit reached", __func__);
goto bad;

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

@@ -0,0 +1,82 @@
#!/bin/sh
# A SIXEL emitted immediately after creating a floating pane must not erase
# 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
export TERM LC_ALL
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
TMUX="$TEST_TMUX -Libss$$ -f/dev/null"
TMUX2="$TEST_TMUX -Libss-outer$$ -f/dev/null"
FIXTURE=$(pwd)/monkey-2.sixel.txt
cleanup()
{
$TMUX kill-server >/dev/null 2>&1
$TMUX2 kill-server >/dev/null 2>&1
}
fail()
{
echo "$*" >&2
cleanup
exit 1
}
expect_title()
{
if ! grep -q "$1" "$TMP"; then
missing_titles="$missing_titles $2"
fi
}
cleanup
TMP=$(mktemp)
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"
image_check_support $TMUX
$TMUX set -g status off
$TMUX set -g pane-border-status top
$TMUX set -g pane-border-format '#{pane_title}'
$TMUX set -as terminal-features ',*:sixel'
BASE=$($TMUX list-panes -F '#{pane_id}' | head -1)
$TMUX select-pane -t "$BASE" -T TILED0 || fail "set tiled title failed"
$TMUX2 new-session -d -x 100 -y 30 "$TMUX attach -t inner" ||
fail "outer session failed"
sleep 1
OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1)
$TMUX2 capture-pane -p -t "$OUTER" >$TMP || fail "initial capture failed"
grep -q TILED0 $TMP || fail "initial tiled title not visible"
# Keep new-pane and send-keys in one command queue. send-keys targets the new
# active floating pane, exactly as in: new\; newp\; send-keys "cat ..." enter.
$TMUX new-pane -T FLOAT0 sh \; send-keys "cat '$FIXTURE'" Enter ||
fail "queued floating image command failed"
sleep 1
$TMUX2 capture-pane -p -t "$OUTER" >$TMP || fail "capture failed"
expect_title TILED0 tiled-after-sixel
expect_title FLOAT0 floating-after-sixel
# Add another tiled pane, then create another floating pane and immediately
# output the same SIXEL. Every tiled and floating border-status must remain.
$TMUX select-pane -t "$BASE"
TILED1=$($TMUX split-window -h -d -PF '#{pane_id}' sh) ||
fail "split tiled pane failed"
$TMUX select-pane -t "$TILED1" -T TILED1 || fail "set second tiled title failed"
$TMUX new-pane -T FLOAT1 sh \; send-keys "cat '$FIXTURE'" Enter ||
fail "queued second floating image command failed"
sleep 1
$TMUX2 capture-pane -p -t "$OUTER" >$TMP || fail "capture failed"
expect_title TILED0 first-tiled-after-split-sixel
expect_title TILED1 second-tiled-after-split-sixel
expect_title FLOAT1 floating-after-split-sixel
[ -z "$missing_titles" ] || fail "missing pane-border-status titles:$missing_titles"
exit 0

View File

@@ -0,0 +1,131 @@
#!/bin/sh
# 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
export TERM LC_ALL
[ -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
}
# drag COL ROW [ROW ...]
#
# Write one continuous mouse-1 gesture at column COL: a press at the first
# ROW, a drag update at each subsequent ROW in turn, and a release at the
# last ROW - all 1-based positions sent to the outer pane holding the
# inner client. This matches the default MouseDown1Border/MouseDrag1Border
# bindings used to move or resize a floating pane by its border.
#
# This must be one continuous press/drag/.../release gesture, not several
# separate drag() calls chained together: releasing clears the per-pane
# drag callback binding, so a later press has to be re-hit-tested from
# scratch, which only works reliably away from row 0 (see the header
# comment). A drag already under way is not re-hit-tested per motion
# event, so it can safely pass through row 0 as an intermediate waypoint.
drag()
{
col="$1"
shift
row="$1"
shift
seq=$(printf '\033[<0;%s;%sM' "$col" "$row")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.2
while [ $# -gt 1 ]; do
row="$1"
shift
seq=$(printf '\033[<32;%s;%sM' "$col" "$row")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.15
done
row="$1"
seq=$(printf '\033[<0;%s;%sm' "$col" "$row")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 1
}
cleanup
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"'
BASE=$($TMUX list-panes -F '#{pane_id}' | head -1)
[ -n "$BASE" ] || fail "No base pane."
$TMUX select-pane -t "$BASE" -T TILEDTITLE || fail "set base title failed"
# Float a pane well clear of row 0 to start - its own top border must not
# coincide with the base pane's border-status row for the initial mouse
# press to unambiguously hit it (see the header comment). -x/-y are the
# pane's outer size including its 2-cell border frame, so request 2 more
# than the desired 14x3 content area (see layout_floating_args_parse()).
FLOAT=$($TMUX new-pane -d -PF '#{pane_id}' -x 16 -y 5 -X 5 -Y 5 -T FLOATTITLE) ||
fail "new-pane -X -Y failed"
FTOP=$($TMUX display-message -p -t "$FLOAT" '#{pane_top}')
FLEFT=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}')
FWIDTH=$($TMUX display-message -p -t "$FLOAT" '#{pane_width}')
$TMUX2 new-session -d -x 60 -y 20 "$TMUX attach -t inner" || exit 1
sleep 1
OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1)
[ -n "$OUTER" ] || fail "No outer pane."
# Sanity check: the base pane's title is visible now (the float starts
# well clear of row 0).
$TMUX2 capturep -p -t "$OUTER" >$TMP || fail "capture failed"
head -1 $TMP | grep -q TILEDTITLE ||
fail "base title not visible before the drag - test setup is wrong"
# Drag the float up by its top border (an unambiguous row - not row 0) so
# it ends up covering row 0, hiding the base pane's title behind its own
# border, then continue the same drag back down again so it ends clear of
# row 0 once more - one continuous gesture the whole way (see drag()'s
# comment for why).
GRABCOL=$((FLEFT + FWIDTH / 2))
waypoints="$FTOP"
row=$FTOP
while [ "$row" != 1 ]; do
row=$((row - 1))
waypoints="$waypoints $row"
done
while [ "$row" != "$FTOP" ]; do
row=$((row + 1))
waypoints="$waypoints $row"
done
drag $((GRABCOL + 1)) $waypoints
$TMUX2 capturep -p -t "$OUTER" >$TMP || fail "capture failed"
# The base pane's title should be visible again now the floating pane has
# moved off its border-status row. This is expected to fail before the fix
# - see the header comment.
head -1 $TMP | grep -q TILEDTITLE ||
fail "base pane title still blank after floating pane moved away"
exit 0

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

View File

@@ -0,0 +1,58 @@
#!/bin/sh
# 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
export TERM LC_ALL
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
TMUX="$TEST_TMUX -Lifcw$$ -f/dev/null"
TMUX2="$TEST_TMUX -Lifcw-inner$$ -f/dev/null"
$TMUX kill-server 2>/dev/null
$TMUX2 kill-server 2>/dev/null
TMP=$(mktemp)
trap "$TMUX kill-server 2>/dev/null; $TMUX2 kill-server 2>/dev/null; rm -f $TMP" 0 1 15
$TMUX2 new-session -d -x 60 -y 20 "sh -c 'i=0; while [ \$i -lt 30 ]; do printf \"TILE%02d\\n\" \$i; i=\$((i + 1)); done; exec sleep 100'" \
|| exit 1
$TMUX2 new-pane -x20 -y6 -X8 -Y3 -T FLOATTITLE "sh -c 'printf FLOATCONTENT; exec sleep 100'" \
|| exit 1
image_check_support $TMUX2
# The attaching client must be detected as SIXEL-capable - this bug does
# not reproduce on a fallback or Kitty client.
$TMUX2 set -as terminal-features ',*:sixel' || exit 1
$TMUX new-session -d -x 60 -y 20 || exit 1
$TMUX send-keys -l "$TMUX2 attach-session" || exit 1
$TMUX send-keys Enter || exit 1
sleep 1
# Sanity check: the floating pane's content is visible before anything
# disturbs it.
$TMUX capturep -p >$TMP || exit 1
grep -q 'FLOATCONTENT' $TMP || exit 1
# Click the tiled pane (making it active), enter copy-mode scrolled up
# (matching a PageUp binding), move up one line (matching an Up binding),
# then exit copy-mode - the exit is what triggers window_pane_reset_mode(),
# which is what actually caused the erase.
$TMUX2 select-pane -t%0 || exit 1
sleep 1
$TMUX2 copy-mode -u -t%0 || exit 1
sleep 1
$TMUX2 send-keys -t%0 -X cursor-up || exit 1
sleep 1
$TMUX2 send-keys -t%0 -X cancel || exit 1
sleep 1
$TMUX capturep -p >$TMP || exit 1
grep -q 'FLOATCONTENT' $TMP || exit 1
exit 0

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

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

@@ -0,0 +1,49 @@
#!/bin/sh
# Alt-dragging a floating pane must not retransmit images in other panes.
. ./image-noflash-common.inc
$TMUX new-session -d -s inner -x 60 -y 20 "printf '$SIXEL_HEADER'; exec sh" ||
exit 1
$TMUX set -g mouse on || fail "set mouse failed"
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
image_check_support $TMUX
$TMUX set -as terminal-features ',*:sixel' || exit 1
start_capture 60 20
assert_image_reached
: >$TMP
FTOP=$($TMUX display-message -p -t "$FLOAT" '#{pane_top}')
FLEFT=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}')
GRABCOL=$((FLEFT + 3))
GRABROW=$((FTOP + 2))
# Alt-drag (Cb meta bit 8, drag bit 32 added while moving) the floating
# pane by a point inside its body, a few steps in a row.
seq=$(printf '\033[<8;%s;%sM' "$GRABCOL" "$GRABROW")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.2
row=$GRABROW
i=0
while [ $i -lt 4 ]; do
row=$((row + 1))
seq=$(printf '\033[<40;%s;%sM' "$GRABCOL" "$row")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.15
i=$((i + 1))
done
seq=$(printf '\033[<8;%s;%sm' "$GRABCOL" "$row")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 1
NEWTOP=$($TMUX display-message -p -t "$FLOAT" '#{pane_top}')
[ "$NEWTOP" != "$FTOP" ] || fail "sanity: floating pane did not move (still at $FTOP)"
assert_no_retransmit "while Alt-dragging an unrelated floating pane"
exit 0

View File

@@ -0,0 +1,71 @@
# Shared setup for the image-*-noflash.sh tests: each drives some mouse or
# 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
export TERM LC_ALL
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
TMUX="$TEST_TMUX -LtestA$$ -f/dev/null"
TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null"
# A small, distinctive 26x26-pixel SIXEL raster, used across these tests as a
# stand-in image whose retransmission (or lack of it) is being checked for.
SIXEL_HEADER='\033Pq"1;1;26;26#0;2;100;100;100#0!26~-!26~-!26~-!26~-!26B\033\\'
cleanup()
{
$TMUX kill-server >/dev/null 2>&1
$TMUX2 kill-server >/dev/null 2>&1
}
fail()
{
echo "$*" >&2
cleanup
exit 1
}
cleanup
TMP=$(mktemp)
trap "cleanup; rm -f $TMP" 0 1 15
# start_capture SX SY - attach an outer client to the inner session and
# start capturing its raw output into $TMP. Starts the outer session with a
# plain shell, then begins 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.
start_capture()
{
sx=$1
sy=$2
$TMUX2 new-session -d -x "$sx" -y "$sy" || exit 1
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"
$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
}
# Sanity check: the image reached the client at all.
assert_image_reached()
{
grep -qa '"1;1;26;26' $TMP || fail "sanity: image never reached the client"
}
# assert_no_retransmit CONTEXT - no DCS sequence should have been sent since
# $TMP was last cleared. Expected to fail before the fix each test guards -
# see that test's header comment.
assert_no_retransmit()
{
n=$(grep -aoE "$(printf '\033P')[0-9;]*q" $TMP | wc -l)
[ "$n" -eq 0 ] || fail "image was retransmitted ($n times) $1"
}

View File

@@ -0,0 +1,67 @@
#!/bin/sh
# 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
LC_ALL=C.UTF-8
export TERM LC_ALL
[ -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
}
cleanup
TMP=$(mktemp)
trap "cleanup; rm -f $TMP" 0 1 15
# 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'; $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."
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, 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
sleep 0.5
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"
$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"
exit 0

View File

@@ -0,0 +1,49 @@
#!/bin/sh
# 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
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
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)
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

@@ -0,0 +1,46 @@
#!/bin/sh
# 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
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
python3 -B - "$TEST_TMUX" <<'PY'
from pathlib import Path
import re
import sys
from image_test import image_test
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())
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]
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

@@ -0,0 +1,86 @@
#!/bin/sh
# 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
export TERM LC_ALL
[ -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
}
cleanup
TMP=$(mktemp)
trap "cleanup; rm -f $TMP" 0 1 15
FIXTURE=$(pwd)/monkey-2.sixel.txt
# A short (5-row) pane and a wide (30-column) one - wide enough that the
# fixture's width is not clipped, so only the height/scrolloff behaviour is
# exercised, and short enough that most of the image is scrolled off
# screen while it is still being displayed.
$TMUX new-session -d -s inner -x 30 -y 5 "cat '$FIXTURE'; exec sh" || exit 1
sleep 0.5
image_check_support $TMUX
$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 30 -y 5 || 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 image, confounding the
# raster counts 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
[ "$($TMUX display-message -p '#{history_size}')" -gt 0 ] ||
fail "sanity: image did not push anything into history - pane wasn't short enough"
# Enter copy mode and measure the baseline raster count this alone causes
# (redrawing whatever's still on screen), without having scrolled yet.
: >$TMP
$TMUX copy-mode -t inner || fail "copy-mode failed"
sleep 1
baseline=$(grep -oac '"1;1;[0-9]*;[0-9]*' $TMP)
# Scroll all the way to the top of history - this is expected to fail
# before the fix (no more rasters than the baseline ever appear, since the
# scrolled-off rows were left blank) - see the header comment.
: >$TMP
$TMUX send-keys -t inner -X history-top || fail "history-top failed"
sleep 1
after=$(grep -oac '"1;1;[0-9]*;[0-9]*' $TMP)
[ "$after" -gt 0 ] ||
fail "no image raster was sent at all after scrolling to history-top"
[ "$after" -ge "$baseline" ] ||
fail "scrolling to history-top redrew fewer rasters ($after) than just entering copy mode did ($baseline) - unexpected either way"
exit 0

View File

@@ -0,0 +1,41 @@
#!/bin/sh
# Moving a copy-mode selection cursor must not retransmit unchanged images.
. ./image-noflash-common.inc
# The image, at the top of the pane, followed by enough plain lines that the
# cursor can move down through it and past it without the view needing to
# scroll.
$TMUX new-session -d -s inner -x 40 -y 20 \
"printf '$SIXEL_HEADER'; for i in \$(seq 1 15); do echo line\$i; done; exec sh" ||
exit 1
sleep 0.5
image_check_support $TMUX
$TMUX set -as terminal-features ',*:sixel' || exit 1
start_capture 40 20
assert_image_reached
: >$TMP
# Enter copy-mode, scroll to the top (where the image is) and select down
# through it one cursor step at a time - the view does not need to scroll
# for any of this, since the image is already at the top of what is
# visible.
$TMUX copy-mode -t inner || fail "copy-mode failed"
$TMUX send-keys -X history-top || fail "history-top failed"
sleep 0.2
: >$TMP
$TMUX send-keys -X begin-selection || fail "begin-selection failed"
i=0
while [ $i -lt 6 ]; do
$TMUX send-keys -X cursor-down || fail "cursor-down failed"
sleep 0.15
i=$((i + 1))
done
sleep 0.5
assert_no_retransmit "while just moving the selection cursor"
exit 0

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

81
regress/image-sixel-overlap.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/bin/sh
# A SIXEL image drawn over an earlier one replaces the pixels underneath
# for good, so an earlier image that is completely covered must not be kept
# and drawn again on every repaint - an animation would otherwise pile up a
# layer per frame.
#
# Draw two images over each other, then attach a client: it should be sent
# no more than it would be for one image. An image that is only partly
# 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
export TERM LC_ALL
[ -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
}
TMP=$(mktemp)
trap "cleanup; rm -f $TMP" 0 1 15
# Draw the given sixel commands in a new session, attach a sixel client and
# set N to the number of SIXEL images the client was sent (other DCS
# sequences, such as capability queries, do not count).
count_images()
{
cleanup
$TMUX new-session -d -s inner -x 40 -y 10 "printf '$1'; exec sh" ||
exit 1
sleep 0.3
image_check_support $TMUX
$TMUX set -as terminal-features ',*:sixel' || exit 1
$TMUX2 new-session -d -x 40 -y 10 || 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"
$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
N=$(grep -aoE "$(printf '\033')P[0-9;]*q" "$TMP" | wc -l)
}
# One image on its own, as a baseline: a client is sent each image more than
# once, as the terminal features are confirmed after attaching.
count_images '\0337\033P9;1q#1!100~\033\\'
SINGLE=$N
[ "$SINGLE" -gt 0 ] || fail "sanity: client was sent no images"
# The same footprint twice, blue then red.
count_images '\0337\033P9;1q#1!100~\033\\\0338\033P9;1q#2!100~\033\\'
[ "$N" -eq "$SINGLE" ] ||
fail "covered image was kept: client was sent $N images, expected $SINGLE"
# A narrower image over a wider one leaves the wider one partly visible.
count_images '\0337\033P9;1q#1!100~\033\\\0338\033P9;1q#2!40~\033\\'
[ "$N" -gt "$SINGLE" ] ||
fail "partly covered image was lost: client was sent $N images, expected more than $SINGLE"
exit 0

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

@@ -0,0 +1,34 @@
#!/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 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)
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

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

@@ -0,0 +1,60 @@
#!/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))
# 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

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

@@ -0,0 +1,47 @@
#!/bin/sh
# Sizing a new floating pane must not retransmit images in other panes.
# Wait for the initial creation redraw before checking subsequent drag steps.
. ./image-noflash-common.inc
$TMUX new-session -d -s inner -x 60 -y 20 "printf '$SIXEL_HEADER'; exec sh" ||
exit 1
$TMUX set -g mouse on || fail "set mouse failed"
sleep 0.3
image_check_support $TMUX
$TMUX set -as terminal-features ',*:sixel' || exit 1
start_capture 60 20
assert_image_reached
# Ctrl-drag within the pane, well clear of the image, to create and begin
# sizing a new floating pane. This first press+motion pair both creates the
# pane (one legitimate redraw) and makes the initial resize call.
seq=$(printf '\033[<16;30;10M')
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.2
seq=$(printf '\033[<48;35;12M')
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.3
n=$($TMUX list-panes | wc -l)
[ "$n" -eq 2 ] || fail "sanity: floating pane was not created (found $n panes)"
: >$TMP
# Continue the drag - only ongoing resize-motion events from here, which is
# what the fix scopes.
seq=$(printf '\033[<48;38;13M')
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.15
seq=$(printf '\033[<48;40;15M')
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.15
seq=$(printf '\033[<16;40;15m')
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 1
assert_no_retransmit "while sizing a new floating pane"
exit 0

View File

@@ -0,0 +1,71 @@
#!/bin/sh
# Regression test: clicking a pane's name in a second (#{P:}) status line to
# make it active must not retransmit images in other panes of the same
# window.
#
# The default MouseDown1Status binding is "switch-client -t=". When status
# is set to 2 or more, the second status line's default format lists each
# pane individually with a "pane" mouse range, so clicking a pane name there
# resolves that binding's target to a specific pane rather than a session or
# window - but switch-client still runs through server_client_set_session()
# (server-client.c), which used to call server_redraw_client() (forcing a
# full CLIENT_REDRAWWINDOW pass, wiping and retransmitting every image in
# the window) unconditionally, even though neither the client's session nor
# its current window actually changed - only the active pane within the
# already-current window did, which window_set_active_pane() and
# window_redraw_active_switch() already handle narrowly on their own.
#
# This is checked by counting DCS (\033P) sequences in the client's raw
# output while an inactive pane's own image (never touched by the click) is
# present: with the fix, none should appear.
. ./image-noflash-common.inc
# A distinctive SIXEL raster in the left (initially active) pane, and a
# second, plain pane to its right. Two status lines are enabled, so the
# second status line lists both panes individually and clicking a pane's
# name there is possible.
$TMUX new-session -d -s inner -x 60 -y 12 "printf '$SIXEL_HEADER'; exec sh" ||
exit 1
$TMUX set -g mouse on || fail "set mouse failed"
$TMUX set -g status 2 || fail "set status failed"
$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
image_check_support $TMUX
$TMUX set -as terminal-features ',*:sixel' || exit 1
start_capture 60 12
# Sanity check: the image reaches the client, and pane 0 (holding it) is
# active.
assert_image_reached
[ "$($TMUX display-message -p -t inner.0 '#{pane_active}')" = 1 ] ||
fail "sanity: pane 0 not active before the click"
: >$TMP
# Locate pane 1's clickable entry in the second (bottom) status line - the
# default window-pane-status-format starts with the pane index, so its
# entry is identifiable by the literal text "1:[".
STATUSLINE=$($TMUX2 capture-pane -p -t "$OUTER" | tail -1)
COL=$(echo "$STATUSLINE" | awk '{print index($0, "1:[")}')
[ "$COL" -gt 0 ] || fail "sanity: could not find pane 1's status entry"
ROW=12
# Click pane 1's name in the second status line - this should make pane 1
# active without disturbing pane 0's already-displayed image.
seq=$(printf '\033[<0;%s;%sM' "$COL" "$ROW")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 0.2
seq=$(printf '\033[<0;%s;%sm' "$COL" "$ROW")
$TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null
sleep 1
[ "$($TMUX display-message -p -t inner.1 '#{pane_active}')" = 1 ] ||
fail "click did not make pane 1 active"
assert_no_retransmit "after clicking another pane's status entry"
exit 0

187
regress/image_terminal.py Normal file
View File

@@ -0,0 +1,187 @@
"""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:
w, h, pixels = decode_sixel(image[1])
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:
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)

295
regress/image_test.py Normal file
View File

@@ -0,0 +1,295 @@
"""Helpers for image tests using an attached client on a PTY."""
import contextlib
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."""
return [decode_sixel(body) for body in re.findall(
rb"\033P[0-9;]*q(.*?)\033\\", self.data, re.S)]
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):
"""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] * (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+)(?:;2;(\d+);(\d+);(\d+))?", body[pos:])
assert match is not None, body[pos:pos + 80]
c = int(match[1])
if match[2] is not None:
rgb = tuple(int(match[i]) for i in range(2, 5))
assert all(v <= 100 for v in rgb), rgb
palette[c] = tuple((v * 255 + 50) // 100 for v in rgb)
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)

File diff suppressed because one or more lines are too long

View File

@@ -21,7 +21,6 @@
#define TMUX_IMAGE_H
#ifdef ENABLE_IMAGES
//#error image support is not ready yet
struct grid;
struct grid_cell;