sixel: add sixel-region-scrolling to trust the terminal on scroll

There is no way to query a terminal for whether it moves SIXEL image
content along with the rest of a scrolling region, so tmux always
redrew the image after every scroll rather than assuming the terminal
did it for free the way Kitty placements already are (IMAGE_BACKEND_
SCROLLS). Add a server option, default on, that lets sixel opt into the
same trust; image_backend_flags() now ORs SCROLLS in for the sixel
backend when it's set.

Splitting SCROLLS out surfaced a second, unrelated property the two
image_backend_flags() checks had been conflating: whether a backend
also respects its own cell boundaries rather than bleeding pixels onto
neighbouring rows, which is what the "redraw a pane's border-status
after a GRAPHICAL-only redraw" guard in screen-redraw.c was actually
testing for. Give that its own IMAGE_BACKEND_CLIPPED flag (kitty only)
so turning sixel-region-scrolling on doesn't also disable that
unrelated border-status protection.

Includes a regress test proving the scroll-skip decision is wired
correctly with the option on and off, and a manual visual test
(tools/sixel-scroll-region-test.sh + tools/sixel-ruler.six, a numbered
colour-band SIXEL image) for checking real terminals by eye, since nothing
here can verify what a given terminal actually renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Grant
2026-09-20 22:39:43 +01:00
parent d037c33645
commit efbd09e101
7 changed files with 171 additions and 3 deletions

19
image.c
View File

@@ -100,7 +100,8 @@ static const struct image_backend image_backend_fallback = {
"fallback", IMAGE_BACKEND_SCROLLS, NULL, NULL
};
static const struct image_backend image_backend_kitty = {
"kitty", IMAGE_BACKEND_GRAPHICAL|IMAGE_BACKEND_SCROLLS,
"kitty",
IMAGE_BACKEND_GRAPHICAL|IMAGE_BACKEND_SCROLLS|IMAGE_BACKEND_CLIPPED,
kitty_draw_rect, kitty_free_output_state
};
static const struct image_backend image_backend_sixel = {
@@ -168,8 +169,22 @@ image_draw_flush(struct tty *tty)
int
image_backend_flags(struct tty *tty)
{
int flags;
image_tty_update(tty);
return (tty->image_backend->flags);
flags = tty->image_backend->flags;
/*
* There is no way to ask a terminal whether it moves SIXEL image
* content along with the rest of a scrolling region, so this is an
* assumption the user can turn off with sixel-region-scrolling if
* their terminal gets it wrong.
*/
if (tty->image_backend == &image_backend_sixel &&
options_get_number(global_options, "sixel-region-scrolling"))
flags |= IMAGE_BACKEND_SCROLLS;
return (flags);
}
/* Discard image backend state after a terminal geometry change. */

View File

@@ -538,6 +538,18 @@ const struct options_table_entry options_table[] = {
"paste buffers with an escape sequence ('on' only)."
},
{ .name = "sixel-region-scrolling",
.type = OPTIONS_TABLE_FLAG,
.scope = OPTIONS_TABLE_SERVER,
.default_num = 1,
.text = "Whether to assume the terminal moves SIXEL image content "
"along with the rest of a scrolling region, rather than "
"redrawing the image after every scroll. There is no way "
"to detect this, so if a terminal does not do it, images "
"may be left in the wrong place after scrolling - turn "
"this off in that case."
},
{ .name = "terminal-overrides",
.type = OPTIONS_TABLE_STRING,
.scope = OPTIONS_TABLE_SERVER,

View File

@@ -0,0 +1,95 @@
#!/bin/sh
# Regression test for the sixel-region-scrolling option (options-table.c):
# scrolling a pane that has a SIXEL image in it must not retransmit the
# image when the option is on - tmux trusts the terminal to have moved the
# image along with the rest of the scrolling region, the same way it
# already trusts Kitty placements to move themselves (IMAGE_BACKEND_SCROLLS
# in image.c). With the option off, today's always-redraw-on-scroll
# behaviour is unchanged.
#
# There is no way to query a terminal for whether it actually moves SIXEL
# pixels along with a scroll, so this only proves tmux's own decision to
# skip or redraw is wired correctly - not that any particular terminal
# renders the result correctly. That needs a human, on real terminals.
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
HEADER='\033Pq"1;1;26;26#0;2;100;100;100#0!26~-!26~-!26~-!26~-!26B\033\\'
# A small image near the top of a tall pane, then enough plain output below
# it to force several ordinary (no DECSTBM sub-region) linefeed scrolls,
# each one shifting the image rows along with everything else.
$TMUX new-session -d -s inner -x 40 -y 20 "printf '$HEADER'; exec sh" ||
exit 1
sleep 0.3
[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0
$TMUX set -as terminal-features ',*:sixel' || exit 1
$TMUX2 new-session -d -x 40 -y 20 || exit 1
OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1)
[ -n "$OUTER" ] || fail "No outer pane."
$TMUX2 set -as terminal-features ',*:sixel@' || fail "disable outer sixel failed"
$TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed"
$TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || fail "send attach failed"
$TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed"
sleep 2
grep -qa '"1;1;26;26' "$TMP" || fail "sanity: image never reached the client"
# --- Phase 1: sixel-region-scrolling on (the default) - expect no DCS. ---
$TMUX set -s sixel-region-scrolling on || fail "set option on failed"
sleep 0.5
: >"$TMP"
$TMUX send-keys -t inner -l "yes | head -n 30" || fail "send scroll failed"
$TMUX send-keys -t inner Enter || fail "send enter failed"
sleep 1
n_on=$(grep -ac "$(printf '\033P')" "$TMP")
[ "$n_on" -eq 0 ] ||
fail "image was retransmitted ($n_on times) scrolling with sixel-region-scrolling on"
# --- Phase 2: sixel-region-scrolling off - expect the image back, then
# more scrolling to redraw it. ---
$TMUX send-keys -t inner -l "printf '$HEADER'" || fail "resend image failed"
$TMUX send-keys -t inner Enter || fail "send enter failed"
sleep 1.5
grep -qa '"1;1;26;26' "$TMP" || fail "sanity: image did not reappear before phase 2"
$TMUX set -s sixel-region-scrolling off || fail "set option off failed"
sleep 0.5
: >"$TMP"
$TMUX send-keys -t inner -l "yes | head -n 30" || fail "send scroll failed"
$TMUX send-keys -t inner Enter || fail "send enter failed"
sleep 1
n_off=$(grep -ac "$(printf '\033P')" "$TMP")
[ "$n_off" -gt 0 ] ||
fail "image was not retransmitted scrolling with sixel-region-scrolling off - expected the old always-redraw behaviour"
exit 0

View File

@@ -2063,7 +2063,7 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags)
#ifdef ENABLE_IMAGES
if ((flags & REDRAW_PANE) &&
(image_backend_flags(tty) &
(IMAGE_BACKEND_GRAPHICAL|IMAGE_BACKEND_SCROLLS)) ==
(IMAGE_BACKEND_GRAPHICAL|IMAGE_BACKEND_CLIPPED)) ==
IMAGE_BACKEND_GRAPHICAL)
redraw_damage_window_pane_status(scene->w);
#endif

1
tmux.h
View File

@@ -4316,6 +4316,7 @@ char *regsub(const char *, const char *, const char *, int);
/* image.c */
#define IMAGE_BACKEND_GRAPHICAL 0x1
#define IMAGE_BACKEND_SCROLLS 0x2
#define IMAGE_BACKEND_CLIPPED 0x4
struct image *image_create(u_int, u_int, u_int, u_int, u_int, u_int,
u_char *);

1
tools/sixel-ruler.six Normal file
View File

@@ -0,0 +1 @@
P0;0;0q"1;1;160;200#0;2;0;0;0#1;2;4;0;0#2;2;0;4;0#3;2;0;0;4#4;2;5;0;5#5;2;5;5;5#6;2;8;0;0#7;2;11;0;0#8;2;0;7;0#9;2;0;11;0#10;2;7;7;0#11;2;9;9;0#12;2;0;0;9#13;2;7;0;7#14;2;11;0;11#15;2;0;9;9#16;2;7;7;7#17;2;11;11;11#18;2;15;0;0#19;2;20;0;0#20;2;14;9;0#21;2;18;11;0#22;2;0;14;0#23;2;0;20;0#24;2;0;23;0#25;2;18;18;0#26;2;20;13;0#27;2;20;20;0#28;2;0;0;14#29;2;0;0;16#30;2;15;0;15#31;2;17;0;17#32;2;20;0;20#33;2;24;0;24#34;2;0;16;16#35;2;0;19;19#36;2;14;14;14#37;2;18;18;18#38;2;20;20;20#39;2;24;24;24#40;2;25;0;0#41;2;36;0;0#42;2;40;0;0#43;2;47;0;0#44;2;0;27;0#45;2;0;33;0#46;2;0;36;0#47;2;0;39;0#48;2;0;43;0#49;2;0;45;0#50;2;0;49;0#51;2;27;27;0#52;2;35;35;0#53;2;39;39;0#54;2;0;0;27#55;2;0;0;31#56;2;0;0;36#57;2;0;0;40#58;2;0;0;42#59;2;0;0;45#60;2;0;0;47#61;2;27;0;27#62;2;31;0;31#63;2;34;0;34#64;2;36;0;36#65;2;40;0;40#66;2;42;0;42#67;2;45;0;45#68;2;49;0;49#69;2;0;32;32#70;2;0;41;41#71;2;0;49;49#72;2;28;28;28#73;2;30;30;30#74;2;33;33;33#75;2;35;35;35#76;2;38;38;38#77;2;42;42;42#78;2;45;45;45#79;2;49;49;49#80;2;60;0;0#81;2;64;0;0#82;2;68;0;0#83;2;71;0;0#84;2;74;0;0#85;2;76;0;0#86;2;80;0;0#87;2;89;0;0#88;2;92;0;0#89;2;96;0;0#90;2;100;0;0#91;2;54;35;0#92;2;63;41;0#93;2;68;44;0#94;2;0;50;0#95;2;55;55;0#96;2;71;71;0#97;2;74;74;0#98;2;82;53;0#99;2;91;58;0#100;2;100;65;0#101;2;77;77;0#102;2;80;80;0#103;2;87;87;0#104;2;89;89;0#105;2;93;93;0#106;2;96;96;0#107;2;100;100;0#108;2;0;0;50#109;2;0;0;68#110;2;0;0;71#111;2;0;0;73#112;2;0;0;76#113;2;0;0;78#114;2;0;0;83#115;2;0;0;87#116;2;0;0;89#117;2;0;0;96#118;2;0;0;100#119;2;50;0;50#120;2;58;0;58#121;2;64;0;64#122;2;71;0;71#123;2;78;0;78#124;2;78;0;78#125;2;86;0;86#126;2;93;0;93#127;2;96;0;96#128;2;100;0;100#129;2;0;52;52#130;2;0;55;55#131;2;0;58;58#132;2;0;62;62#133;2;0;73;73#134;2;0;77;77#135;2;0;83;83#136;2;0;89;89#137;2;0;93;93#138;2;0;97;97#139;2;0;100;100#140;2;51;51;51#141;2;64;64;64#142;2;69;69;69#143;2;76;76;76#144;2;80;80;80#145;2;82;82;82#146;2;87;87;87#147;2;89;89;89#148;2;93;93;93#149;2;94;94;94#150;2;100;100;100#90!160~-!4~NBbrrBF!149~$#85!4?_?O#6C??_$#88!4?O#1_???G#40O$#42!5?GC#86G#18C#83_G$#0!5?O#19G#82?G#43O$#89!5?C#80???C-#90!4~o?FNN?_{!148~$#81!4?B#89_!5?B$#84!4?C#0G!4?B$#88!4?G#1C!4?C$#41!5?O_#6_?O#82O$#7!5?B#40O???G$#86!6?G??B$#87!7?O#18_#43G$#85!8?O#80_$#83!9?C-#90!160B$#100!160{-!5~^^NF!151~$#99!5?_??G$#93!6?_#21_#20o$#98!7?O-#100!5~}}??!151~$#91!5?@#26@#20@~$#92!7?}-#100!7NKK!151N$#107!160o$#92!7?B#20B-#107!160~-!4~row[K??r!148~$#102!4?C??A?C#106_$#103!4?G#105@C???@K$#0!5?C!4?K$#25!5?A???O$#95!5?G?_#10_#27A$#51!6?A???A$#52!6?@??_O$#11!7?@@#53@$#101!8?A#97G$#104!8?O-#107!4~rooqqrrv!148~$#95!4?G#0K!5G#105G$#96!4?C#25A#27@@#101@$#104!5?@#53A#102!4C$#97!6?C-#94!160~-!4~FBbrRBB!149~$#50!4?g?O?_?C$#47!4?OC#23G??O#44G$#2!5?G??CG$#0!5?O?C#45G#22_O$#48!5?_?G#24?C_$#9!6?C-#94!4~bBNKK??r!148~$#44!4?G?O?A#24_A$#49!4?CC?O#8_A#50`K$#48!4?O#0O!4?G$#9!5?G???P#23O$#47!5?_?AO#46K$#22!6?_@$#2!7?_@?C-#94!160B$#139!160{-!7~NFF!150~$#70!7?_#15_#35o$#138!7?O#34O#130G$#135!8?G-#139!4~F@?K??NN!148~$#137!4?_A#133@!4?O$#70!4?O#0!6O$#132!4?G#136___??_$#35!5?G???N$#69!5?C#138G!4?_$#15!6?A#34@?_$#71!6?C#134A#129`$#131!8?M-#118!160o$#139!8NKK!150N$#131!8?B#35B-#118!160~-!4~n?_ccCC^!148~$#115!4?O!5?A$#117!5?_!4?G_$#0!5?O!4@_$#55!5?AG#113AAA#56P$#59!5?@O#12GG#28O$#3!5?G#58A??G$#29!5?C#109C#114O#111O#110_-#118!4~xprrrow}!148~$#116!4?C!4?@#0@#115@$#109!4?A#3C?G??A$#114!5?G?C#28G#29C#60C$#54!5?AG#112?C#108G$#57!6?C#111??A-#119!160~-!4~NFBrRbb!149~$#68!4?O?_?_?C$#65!4?_#4OG?C#32CW$#61!5?GC#13C?G$#0!5?_#64OGGO-#119!4~o??KK??r!148~$#66!4?G??O?C$#62!4?A#68_K???_G$#63!4?@#0M??@?K$#64!4?C#31O!4?O$#4!5?@?`_#14OA$#30!6?O??B$#32!6?a??_$#33!6?@#67AA?@C$#65!8?OG-#119!160B$#128!160{-!4~!8N!148~$#126!4?_!6?_$#121!4?O#0!6O#122O$#124!5?!4_#120_#14_-#128!6~NB`w}!149~$#121!6?_#4O#13C#31@#122@$#127!6?O#30_#65G#64A$#67!7?G#119A#125C$#126!7?C#123O-#128!6NKK!152N$#150!160o$#13!6?A#66@$#63!6?@#121A-#150!160~-!4~Z??CC?P!149~$#148!4?c#36_???G#76_$#0!5?C?OO#39oC$#37!5?A#79_???A$#38!5?G#5O@#17@A#140G$#146!5?P#73B#144AG#78D$#75!6?G#147g#142A$#143!6?C#145?_-#150!4~worrroo{!148~$#148!4?C!6?A$#141!4?B#145G???@#149G@$#16!5?C#77C??G#0B$#36!5?B#72G#5G#144CA#74C$#146!7?C#17G#39C-#79!160~-!4~FBrrrBF!149~$#73!4?_!4?CG$#74!4?O!4?O#0_$#78!4?G#16_??C?O$#5!5?W?C?G$#77!5?C?G#75G_$#36!6?C$#38!6?G-#79!4~uCCLK?_}!148~$#78!4?G?G!4?@$#77!4?@_?O@#38G#76O$#0!5?@?a?OB$#37!5?GO???G$#75!5?A???c$#16!5?OA#5?A?C$#17!6?_?_@$#72!6?@??A$#73!8?O-#79!160B-\

View File

@@ -0,0 +1,44 @@
#!/bin/sh
# Visual test for sixel-region-scrolling: does this terminal actually move
# SIXEL pixels along with tmux's own scroll-region escapes (DECSTBM/DECSLRM
# + IND/RI/RIN), or does tmux need to fall back to redrawing the image on
# every scroll?
#
# There is no way to query a terminal for this, so it has to be judged by
# eye. Run this from inside the tmux session you want to test (attached
# from whichever terminal emulator you're checking), and watch the
# numbered colour bands in the floating pane while the counter below it
# scrolls slowly. Run it once with the option on, once with it off, and
# compare:
#
# - "off" is the known-good baseline: tmux redraws the whole image every
# scroll, so the bands must stay perfectly aligned with the counter no
# matter what the terminal does on its own.
# - "on" trusts the terminal to have moved the pixels itself. If it
# looks identical to "off", this terminal is fine. If a band freezes,
# duplicates, tears, or drifts out of sync with the counter, this
# terminal does not scroll SIXEL regions correctly and
# sixel-region-scrolling should stay off for it.
#
# Usage: sh tools/sixel-scroll-region-test.sh [on|off]
set -eu
cd "$(dirname "$0")/.."
MODE=${1:-on}
case "$MODE" in
on|off) ;;
*) echo "usage: $0 [on|off]" >&2; exit 1 ;;
esac
tmux set -s sixel-region-scrolling "$MODE"
tmux set -as terminal-features ',*:sixel'
echo "Testing with sixel-region-scrolling=$MODE. Watch the coloured bands" >&2
echo "in the floating pane - they should stay locked to the counter." >&2
sleep 2
PANE=$(tmux new-pane -d -x 24 -y 22 -PF '#{pane_id}' \
"cat '$(pwd)/tools/sixel-ruler.six'; for i in \$(seq 1 50); do echo line \$i; sleep 0.3; done; echo done - press enter; read _")
tmux select-pane -t "$PANE"