mirror of
https://github.com/tmux/tmux.git
synced 2026-10-14 15:04:50 +00:00
Image regress tests.
This commit is contained in:
2
.github/workflows/regress.yml
vendored
2
.github/workflows/regress.yml
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
82
regress/image-border-status-sixel.sh
Executable file
82
regress/image-border-status-sixel.sh
Executable 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
|
||||
131
regress/image-border-status-wipe.sh
Executable file
131
regress/image-border-status-wipe.sh
Executable 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
10
regress/image-common.inc
Normal 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
108
regress/image-fallback.sh
Executable 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
|
||||
58
regress/image-floating-content-wipe.sh
Executable file
58
regress/image-floating-content-wipe.sh
Executable 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
125
regress/image-grid-operations.sh
Executable 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
76
regress/image-grid.sh
Executable 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
74
regress/image-lifecycle.sh
Executable 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
|
||||
49
regress/image-movepane-drag-noflash.sh
Executable file
49
regress/image-movepane-drag-noflash.sh
Executable 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
|
||||
71
regress/image-noflash-common.inc
Normal file
71
regress/image-noflash-common.inc
Normal 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"
|
||||
}
|
||||
67
regress/image-obscured-structural-op.sh
Executable file
67
regress/image-obscured-structural-op.sh
Executable 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
|
||||
49
regress/image-resize-many-placements.sh
Executable file
49
regress/image-resize-many-placements.sh
Executable 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
|
||||
46
regress/image-resize-width-recovery.sh
Executable file
46
regress/image-resize-width-recovery.sh
Executable 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
|
||||
86
regress/image-scroll-off-recovery.sh
Executable file
86
regress/image-scroll-off-recovery.sh
Executable 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
|
||||
41
regress/image-selection-noflash.sh
Executable file
41
regress/image-selection-noflash.sh
Executable 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
96
regress/image-sixel-output.sh
Executable 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
81
regress/image-sixel-overlap.sh
Executable 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
34
regress/image-sixel-palette.sh
Executable 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
60
regress/image-sixel-parse.sh
Executable 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
|
||||
54
regress/image-sixel-transparency.sh
Executable file
54
regress/image-sixel-transparency.sh
Executable 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
|
||||
47
regress/image-splitwindow-resize-noflash.sh
Executable file
47
regress/image-splitwindow-resize-noflash.sh
Executable 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
|
||||
71
regress/image-status-pane-click-noflash.sh
Executable file
71
regress/image-status-pane-click-noflash.sh
Executable 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
187
regress/image_terminal.py
Normal 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
295
regress/image_test.py
Normal 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)
|
||||
1
regress/monkey-2.sixel.txt
Normal file
1
regress/monkey-2.sixel.txt
Normal file
File diff suppressed because one or more lines are too long
@@ -21,7 +21,6 @@
|
||||
#define TMUX_IMAGE_H
|
||||
|
||||
#ifdef ENABLE_IMAGES
|
||||
//#error image support is not ready yet
|
||||
|
||||
struct grid;
|
||||
struct grid_cell;
|
||||
|
||||
Reference in New Issue
Block a user