Use imagescroll feature.

This commit is contained in:
Nicholas Marriott
2026-10-14 13:55:06 +01:00
parent d78914317c
commit d4d065c76c
11 changed files with 265 additions and 9 deletions

View File

@@ -0,0 +1,16 @@
#!/bin/sh
# The PR's trusted-scroll erase regression: inspect terminal erases directly
# rather than depending on a particular debug log message.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_scroll import check_scroll
check_scroll(sys.argv[1], True, no_erase=True)
PY

View File

@@ -0,0 +1,17 @@
#!/bin/sh
# Adapt the PR's Kitty marker check to SIXEL. Native image scrolling must
# skip the surrounding text repaint as well as image retransmission.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_scroll import check_scroll
check_scroll(sys.argv[1], True)
check_scroll(sys.argv[1], False)
PY

View File

@@ -0,0 +1,20 @@
#!/bin/sh
# The PR's narrow-pane scroll check. Margins permit native scrolling;
# without them imagescroll must not suppress a necessary repaint.
# Different clients must make that decision independently.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_scroll import check_scroll, check_clients
check_scroll(sys.argv[1], True, margins=True)
check_scroll(sys.argv[1], True, margins=False)
check_clients(sys.argv[1])
check_clients(sys.argv[1], narrow=True)
PY

View File

@@ -0,0 +1,21 @@
#!/bin/sh
# The PR's SIXEL imagescroll check, with known cell pixels and no shell race.
# Check the default repaint and an overwrite merged with trusted scroll damage.
PATH=/bin:/usr/bin
TERM=screen
export TERM
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
python3 -B - "$TEST_TMUX" <<'PY'
import sys
from image_scroll import check_scroll, check_capability
for command in ('linefeed', 'up', 'down', 'reverse'):
check_scroll(sys.argv[1], True, command=command)
check_scroll(sys.argv[1], False, command=command)
check_scroll(sys.argv[1], True, mixed=True)
check_capability(sys.argv[1], True)
check_capability(sys.argv[1], False)
PY

133
regress/image_scroll.py Normal file
View File

@@ -0,0 +1,133 @@
"""PTY checks adapted from the PR's image scroll regressions."""
import re
from image_test import image_test, sixel, check_image
from image_terminal import Terminal
MARKER = b'SCROLLMARKER'
WIRE = sixel(16, 16, [(100, 0, 0), (0, 100, 0)],
[x >= 8 for y in range(16) for x in range(16)])
def attach(test, scrolls, margins=None, term='xterm-256color'):
"""Choose scroll features before attaching a client."""
features = [term, 'imagescroll' if scrolls else 'imagescroll@']
if margins is not None:
features.append('margins' if margins else 'margins@')
test.run('set-option', '-as', 'terminal-features', ':'.join(features))
client = test.attach(term=term)
prefix = str(client.pid) + ':'
actual = next(line[len(prefix):] for line in test.run('list-clients',
'-F', '#{client_pid}:#{client_termfeatures}').splitlines()
if line.startswith(prefix)).split(',')
assert ('imagescroll' in actual) == scrolls, actual
if margins is not None:
assert ('margins' in actual) == margins, actual
return client
def place(test):
"""Place marker text and a two-colour image away from the scroll edges."""
test.emit(b'\033[7;1H' + MARKER + b'\033[9;3H' + WIRE + b'\033[16;1H')
for client in test.clients:
images = client.images()
assert len(images) == 1, 'initial image was not sent'
check_image(images[0], 16, 16,
[(255, 0, 0) if x < 8 else (0, 255, 0)
for y in range(16) for x in range(16)])
def check_output(client, replay):
"""Check whether scroll damage replayed the image and surrounding text."""
data = bytes(client.data)
if replay:
assert client.images(), 'image was not replayed after a manual repaint'
assert MARKER in data, 'scroll damage did not repaint surrounding text'
else:
assert not client.images(), 'native scroll replayed the image'
assert MARKER not in data, 'native scroll repainted existing text'
return data
def check_position(test, mixed=False, down=False):
"""A later full redraw must show the scrolled image at its new position."""
test.redraw()
row = 9 if down else 7
expected = {(x, y): (255, 0, 0) if x < 24 else (0, 255, 0)
for y in range(row * 16, (row + 1) * 16) for x in range(16, 32)
if not mixed or x >= 24}
for client in test.clients:
terminal = Terminal()
terminal.feed(client.data)
terminal.check(expected)
def check_scroll(tmux, scrolls, margins=None, no_erase=False, mixed=False,
command='linefeed'):
"""Check one client's native scroll or fallback repaint."""
with image_test(tmux) as test:
test.run('set-option', '-w', 'automatic-rename', 'off')
if margins is not None:
test.run('set-option', '-w', 'pane-scrollbars', 'on')
client = attach(test, scrolls, margins)
place(test)
if no_erase:
test.redraw()
terminal = Terminal()
terminal.feed(client.data)
operation = {'linefeed': b'\n', 'up': b'\033[S', 'down': b'\033[T',
'reverse': b'\033[H\033M'}[command]
if mixed:
# An ordinary overwrite must prevent skipping merged scroll damage.
operation += b'\033[8;3HX'
test.emit(operation)
replay = not scrolls or margins is False or mixed
data = check_output(client, replay)
if no_erase:
# Erasing the newly exposed bottom row is valid. Check that
# none of the moved image pixels were erased or drawn over.
terminal.feed(data)
terminal.check({(x, y): (255, 0, 0) if x < 24 else (0, 255, 0)
for y in range(7 * 16, 8 * 16) for x in range(16, 32)})
if scrolls and margins:
assert re.search(rb'\033\[\d+;\d+s', data), (
'no horizontal scrolling region was set', data)
check_position(test, mixed, command in ('down', 'reverse'))
def check_clients(tmux, narrow=False):
"""Check different client capabilities and shared fallback damage."""
with image_test(tmux) as test:
test.run('set-option', '-w', 'automatic-rename', 'off')
if narrow:
test.run('set-option', '-w', 'pane-scrollbars', 'on')
native = attach(test, True, True)
repaint = attach(test, True, False, term='xterm')
else:
native = attach(test, True)
repaint = attach(test, False, term='xterm')
place(test)
test.emit(b'\n')
# A large fallback repaint adds ordinary window damage, which must
# be drawn on both clients even if the first scrolled natively.
check_output(native, narrow)
check_output(repaint, True)
check_position(test)
def check_capability(tmux, enabled):
"""Iscrl grants image scrolling, and Iscrl@ overrides an enabled feature."""
with image_test(tmux) as test:
test.run('set-option', '-w', 'automatic-rename', 'off')
capability = 'Iscrl' if enabled else 'Iscrl@'
test.run('set-option', '-as', 'terminal-overrides',
'xterm-256color:' + capability)
if not enabled:
test.run('set-option', '-as', 'terminal-features',
'xterm-256color:imagescroll')
client = test.attach()
place(test)
test.emit(b'\n')
check_output(client, not enabled)
check_position(test)

View File

@@ -150,7 +150,10 @@ class Terminal:
if rest[0] == 13:
self.x = 0
elif rest[0] == 10:
self.y += 1
if self.y == self.bottom:
self.scroll(self.top, self.bottom, 1)
else:
self.y += 1
elif rest[0] == 8:
self.x = max(0, self.x - 1)
pos += 1