diff --git a/image-kitty.c b/image-kitty.c index 9f074fb21..19181ace6 100644 --- a/image-kitty.c +++ b/image-kitty.c @@ -1095,6 +1095,7 @@ kitty_parse_image(void **state, const u_char *buf, size_t len, u_int xpixel, uint64_t canvas_width, canvas_height; struct image *im = NULL, *source; struct kitty_source *stored; + int error; if (kc == NULL) { kc = xcalloc(1, sizeof *kc); @@ -1114,24 +1115,23 @@ kitty_parse_image(void **state, const u_char *buf, size_t len, u_int xpixel, ks->medium = 'd'; } ks->more = 0; + error = kitty_control(ks, buf, controllen); + if (ks->image_id == 0) + ks->placement_id = 0; result->image_id = ks->image_id; result->quiet = ks->quiet; result->action = ks->action; result->delete = ks->delete; result->placement_id = ks->placement_id; result->z = ks->z; - if (kitty_control(ks, buf, controllen) != 0 || - ks->medium != 'd' || - (payloadlen != 0 && - kitty_append(ks, semi + 1, payloadlen) != 0)) + if (error != 0) goto fail; - - result->image_id = ks->image_id; - result->quiet = ks->quiet; - result->action = ks->action; - result->delete = ks->delete; - result->placement_id = ks->placement_id; - result->z = ks->z; + if (ks->medium != 'd') + goto fail; + if (payloadlen != 0) { + if (kitty_append(ks, semi + 1, payloadlen) != 0) + goto fail; + } if (ks->more) { kc->transfer = ks; result->status = KITTY_PARSE_MORE; @@ -1257,7 +1257,9 @@ kitty_parse_image(void **state, const u_char *buf, size_t len, u_int xpixel, result->status = KITTY_PARSE_OK; if (ks->action != 'q') result->replace_id = kitty_source_set(kc, ks->image_id, source); - if (ks->action == 'T' && !ks->virtual) { + if (ks->action == 'q') + im = NULL; + else if (ks->action == 'T' && !ks->virtual) { im = kitty_place_image(source, ks, xpixel, ypixel); if (im == NULL) result->status = KITTY_PARSE_ERROR; diff --git a/image.c b/image.c index 6330989e2..b1b5b6341 100644 --- a/image.c +++ b/image.c @@ -1602,6 +1602,7 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg, u_int cx = s->cx, cy = s->cy; u_int x, y, i, run, sx, sy, lines, origin_y = 0; u_int hist_origin_y, region_height, remaining, chunk; + u_int cursor_x = 0; sx = im->sx; if (sx > screen_size_x(s) - cx) @@ -1678,8 +1679,11 @@ image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg, } image_store_prune(gd->images); image_redraw_area(ctx, cx, cy, sx, sy); - if (!(im->flags & IMAGE_FLAG_NO_CURSOR)) - screen_write_cursormove(ctx, 0, cy + sy, 0); + if (!(im->flags & IMAGE_FLAG_NO_CURSOR)) { + if (input == IMAGE_INPUT_KITTY) + cursor_x = cx + sx; + screen_write_cursormove(ctx, cursor_x, cy + sy, 0); + } } /* Place an image received through SIXEL. */ diff --git a/input.c b/input.c index 8a83884ed..63fad16ea 100644 --- a/input.c +++ b/input.c @@ -2803,6 +2803,30 @@ input_enter_apc(struct input_ctx *ictx) } #ifdef ENABLE_IMAGES +/* Reply to a Kitty graphics command with its image and placement IDs. */ +static void +input_reply_kitty(struct input_ctx *ictx, struct kitty_parse_result *result, + const char *message) +{ + if (result->quiet >= 2) + return; + if (result->quiet == 1) { + if (strcmp(message, "OK") == 0) + return; + } + if (result->image_id == 0) { + if (result->action != 'q') + return; + } + if (result->placement_id != 0) { + input_reply(ictx, 0, "\033_Gi=%u,p=%u;%s\033\\", + result->image_id, result->placement_id, message); + } else { + input_reply(ictx, 0, "\033_Gi=%u;%s\033\\", result->image_id, + message); + } +} + /* Handle a Kitty graphics command. */ static int input_handle_kitty(struct input_ctx *ictx, const u_char *buf, size_t len) @@ -2819,14 +2843,10 @@ input_handle_kitty(struct input_ctx *ictx, const u_char *buf, size_t len) if (result.status == KITTY_PARSE_MORE) return (1); if (result.status != KITTY_PARSE_OK) { - if (result.quiet < 2 && result.action != '\0') { - if (result.status == KITTY_PARSE_MISSING) - input_reply(ictx, 0, "\033_Gi=%u;ENOENT\033\\", - result.image_id); - else - input_reply(ictx, 0, "\033_Gi=%u;EINVAL\033\\", - result.image_id); - } + if (result.status == KITTY_PARSE_MISSING) + input_reply_kitty(ictx, &result, "ENOENT"); + else + input_reply_kitty(ictx, &result, "EINVAL"); return (1); } if (result.replace_id != 0) @@ -2839,14 +2859,12 @@ input_handle_kitty(struct input_ctx *ictx, const u_char *buf, size_t len) image_write_kitty(sctx, im, ictx->cell.cell.bg, result.image_id, result.placement_id, result.z); image_free(image_get_id(im)); - if (result.quiet == 0 && result.image_id != 0) - input_reply(ictx, 0, "\033_Gi=%u;OK\033\\", result.image_id); - } else if (result.action == 'd') + } else if (result.action == 'd') { image_clear_kitty(sctx, result.delete, result.image_id, result.placement_id, result.z); - else if ((result.action == 't' || result.action == 'q' || - result.action == 'u') && result.quiet == 0) - input_reply(ictx, 0, "\033_Gi=%u;OK\033\\", result.image_id); + } + if (result.action != 'd') + input_reply_kitty(ictx, &result, "OK"); return (1); } #endif diff --git a/regress/image-kitty-clear.sh b/regress/image-kitty-clear.sh new file mode 100644 index 000000000..e9aad3be2 --- /dev/null +++ b/regress/image-kitty-clear.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# Erasing a line preserves Kitty graphics; erasing the screen removes them. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C +export TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -Lkitty-clear-inner$$ -f/dev/null" +TMUX2="$TEST_TMUX -Lkitty-clear-outer$$ -f/dev/null" +trap "$TMUX kill-server 2>/dev/null; $TMUX2 kill-server 2>/dev/null" 0 1 15 + +$TMUX new-session -d -x 10 -y 4 " + printf '\033_Ga=T,q=2,f=24,s=1,v=1,c=1,r=1,C=1;////\033\\' + read line + printf '\033[H\033[2K' + read line + printf '\033[H\033[2J' + sleep 30" || exit 1 +[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0 +$TMUX set -g status off || exit 1 +$TMUX set -g scroll-on-clear off || exit 1 +$TMUX set -as terminal-features ',*:sixel@' || exit 1 +$TMUX set -as terminal-features ',*:kitty@' || exit 1 +$TMUX set -as terminal-features ',*:RGB' || exit 1 +$TMUX2 new-session -d -x 10 -y 4 "$TMUX attach-session" || exit 1 +$TMUX2 set -g status off || exit 1 +sleep 1 +$TMUX2 capture-pane -peS0 -E0 | grep -q '48;2;255;255;255m' || exit 1 + +$TMUX send-keys Enter || exit 1 +sleep 1 +$TMUX2 capture-pane -peS0 -E0 | grep -q '48;2;255;255;255m' || exit 1 + +$TMUX send-keys Enter || exit 1 +sleep 1 +[ -z "$($TMUX2 capture-pane -peS0 -E3)" ] || exit 1 + +exit 0 diff --git a/regress/image-kitty-protocol.sh b/regress/image-kitty-protocol.sh new file mode 100644 index 000000000..1fde613fb --- /dev/null +++ b/regress/image-kitty-protocol.sh @@ -0,0 +1,130 @@ +#!/bin/sh + +# Kitty cursor movement, acknowledgements and query isolation. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +export TEST_TMUX +command -v python3 >/dev/null || exit 0 + +python3 - <<'PY' +import base64 +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import time +import zlib + +tmux = [os.environ['TEST_TMUX'], '-u', '-Limage-protocol' + str(os.getpid()), + '-f/dev/null'] + +def run(*args): + return subprocess.check_output(tmux + list(args), text=True).strip() + +def graphics(control, payload=''): + return '\033_G' + control + (';' + payload if payload else '') + '\033\\' + +# Read until a following device-attributes reply, including when q=2 is used. +reader = ''' +import json, os, select, sys, termios, time, tty +command, output = sys.argv[1:] +tty.setraw(0) +os.write(1, command.encode() + b'\\x1b[c') +reply = b'' +deadline = time.monotonic() + 3 +while time.monotonic() < deadline: + if select.select([0], [], [], 0.1)[0]: + reply += os.read(0, 4096) + if b'\\x1b[?' in reply and reply.endswith(b'c'): + break +else: + raise SystemExit('missing device-attributes reply') +with open(output, 'w') as f: + json.dump(reply.split(b'\\x1b[?')[0].decode(), f) +time.sleep(30) +''' + +def check(name, command, cursor, expected='', text=None): + output = directory / name + pane_command = 'python3 ' + shlex.quote(str(helper)) + ' ' + \ + shlex.quote(command) + ' ' + shlex.quote(str(output)) + pane = run('new-window', '-d', '-P', '-F', '#{pane_id}', pane_command) + try: + deadline = time.monotonic() + 5 + while not output.exists(): + if time.monotonic() >= deadline: + raise AssertionError(name + ': missing reply capture') + time.sleep(0.05) + reply = json.loads(output.read_text()) + assert reply == expected, (name, 'reply', repr(reply), repr(expected)) + actual = run('display-message', '-pt', pane, '#{cursor_x},#{cursor_y}') + assert actual == cursor, (name, 'cursor', actual, cursor) + if text is not None: + actual = run('capture-pane', '-pt', pane, '-S0', '-E0') + assert actual == text, (name, 'text', actual, text) + finally: + run('kill-window', '-t', pane) + +with tempfile.TemporaryDirectory(prefix='tmux-kitty-protocol-') as tmp: + directory = Path(tmp) + helper = directory / 'read-reply.py' + helper.write_text(reader) + try: + run('new-session', '-d', '-x', '40', '-y', '12') + if run('display-message', '-p', '#{image_support}') == '0': + raise SystemExit(0) + origin = '\033[3;6H' # Column 6, row 3 (zero-based 5,2). + pixel = '/wAA/w==' + placement = 'a=T,q=2,f=32,s=1,v=1,c=3,r=2' + check('placement', origin + graphics(placement, pixel), '8,4') + check('no-cursor', origin + graphics(placement + ',C=1', pixel), '5,2') + check('clipped', '\033[3;39H' + graphics(placement, pixel), '39,4') + check('scrolled', '\033[12;6H' + graphics(placement, pixel), '8,11') + check('transmit', origin + graphics('a=t,q=2,f=32,s=1,v=1,i=7', pixel), '5,2') + check('virtual', origin + graphics(placement + ',U=1,i=7', pixel), '5,2') + check('put', origin + graphics('a=t,q=2,f=32,s=1,v=1,i=7', pixel) + + graphics('a=p,q=2,i=7,c=3,r=2'), '8,4') + check('chunks', graphics(placement + ',m=1', '/wAA') + origin + + graphics('m=0', '/w=='), '8,4') + check('sixel', origin + '\033Pq"1;1;1;1#0;2;100;0;0#0@\033\\', '0,3') + check('put-reply', origin + graphics('a=t,q=2,f=32,s=1,v=1,i=7', pixel) + + graphics('a=p,i=7,p=9,c=3,r=2'), '8,4', graphics('i=7,p=9', 'OK')) + check('missing-reply', origin + graphics('a=p,i=7,p=9'), '5,2', + graphics('i=7,p=9', 'ENOENT')) + check('invalid-reply', origin + graphics('a=T,i=7,p=9,f=32,s=1,v=1', '!!!!'), + '5,2', graphics('i=7,p=9', 'EINVAL')) + check('quiet-error', origin + graphics('a=T,q=2,i=7,f=32,s=1,v=1', '!!!!'), '5,2') + check('quiet-ok', origin + graphics('a=t,q=1,i=7,f=32,s=1,v=1', pixel), '5,2') + check('quiet-one-error', origin + graphics('a=t,q=1,i=7,f=32,s=1,v=1', '!!!!'), + '5,2', graphics('i=7', 'EINVAL')) + check('quiet-control-error', origin + graphics('a=t,q=2,i=7,z=bad'), '5,2') + check('anonymous', origin + graphics('a=t,f=32,s=1,v=1', pixel), '5,2') + check('anonymous-error', origin + graphics('a=t,f=32,s=1,v=1', '!!!!'), '5,2') + check('anonymous-placement', origin + graphics('a=T,p=9,f=32,s=1,v=1,c=3,r=2', + pixel), '8,4') + check('delete-no-reply', origin + graphics('a=t,q=2,i=7,f=32,s=1,v=1', pixel) + + graphics('a=d,d=I,i=7'), '5,2') + for format, raw in [(24, b'\xff\0\0'), (32, b'\xff\0\0\xff')]: + payload = base64.b64encode(zlib.compress(raw)).decode() + check('compressed-' + str(format), origin + + graphics('a=T,q=2,f=%d,s=1,v=1,c=3,r=2,o=z' % format, payload), '8,4') + check('query', origin + graphics('a=q,f=32,s=1,v=1', pixel), '5,2', + graphics('i=0', 'OK')) + check('query-not-stored', origin + graphics('a=q,q=2,i=7,f=32,s=1,v=1', pixel) + + graphics('a=p,i=7'), '5,2', graphics('i=7', 'ENOENT')) + # A query must not change an existing virtual image. Column 2 is + # valid for the original three-column placement, but not the query. + check('query-virtual', graphics(placement + ',U=1,i=7', pixel) + + graphics('a=q,q=2,U=1,i=7,f=32,s=1,v=1,c=1,r=1', pixel) + + '\033[38;2;0;0;7m\U0010eeee\u0305\u030e', '1,0', text='') + finally: + subprocess.run(tmux + ['kill-server'], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) +PY diff --git a/screen-write.c b/screen-write.c index c8f0050ce..723d7cb72 100644 --- a/screen-write.c +++ b/screen-write.c @@ -2242,8 +2242,15 @@ screen_write_clearscreen(struct screen_write_ctx *ctx, u_int bg) ctx->wp != NULL && options_get_number(ctx->wp->options, "scroll-on-clear")) grid_view_clear_history(s->grid, bg); - else + else { +#ifdef ENABLE_IMAGES + for (y = 0; y < sy; y++) { + image_grid_free_line(s->grid, + &s->grid->linedata[s->grid->hsize + y]); + } +#endif grid_view_clear(s->grid, 0, 0, sx, sy, bg); + } screen_write_collect_clear(ctx, 0, sy);