diff --git a/cfg.c b/cfg.c index 78d164363..31294f92a 100644 --- a/cfg.c +++ b/cfg.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cfg.c,v 1.91 2026/08/03 13:38:42 nicm Exp $ */ +/* $OpenBSD: cfg.c,v 1.92 2026/10/02 12:28:07 nicm Exp $ */ /* * Copyright (c) 2008 Nicholas Marriott @@ -31,14 +31,17 @@ int cfg_finished; static char **cfg_causes; static u_int cfg_ncauses; static struct cmdq_item *cfg_item; +static int cfg_started; int cfg_quiet = 1; char **cfg_files; u_int cfg_nfiles; static enum cmd_retval -cfg_client_done(__unused struct cmdq_item *item, __unused void *data) +cfg_client_done(struct cmdq_item *item, __unused void *data) { + if (cmdq_get_client(item)->flags & CLIENT_DEAD) + return (CMD_RETURN_NORMAL); if (!cfg_finished) return (CMD_RETURN_WAIT); return (CMD_RETURN_NORMAL); @@ -53,14 +56,28 @@ cfg_done(__unused struct cmdq_item *item, __unused void *data) cfg_show_causes(NULL); - if (cfg_item != NULL) + if (cfg_item != NULL) { cmdq_continue(cfg_item); + cfg_item = NULL; + } prompt_load_history(); return (CMD_RETURN_NORMAL); } +void +cfg_client_lost(struct client *c) +{ + if (c != cfg_client) + return; + cfg_client = NULL; + if (cfg_item != NULL) { + cmdq_continue(cfg_item); + cfg_item = NULL; + } +} + void start_cfg(void) { @@ -77,12 +94,19 @@ start_cfg(void) * Because start_cfg() is called so early, we can be sure the client's * command queue is currently empty and our callback will be at the * front - we need to get in before MSG_COMMAND. + * + * If the initial client is lost before the configuration finishes, the + * next client to identify is first in the list and ends up here. Block + * it instead, but do not load the files again. */ cfg_client = c = TAILQ_FIRST(&clients); if (c != NULL) { cfg_item = cmdq_get_callback(cfg_client_done, NULL); cmdq_append(c, cfg_item); } + if (cfg_started) + return; + cfg_started = 1; if (cfg_quiet) flags = CMD_PARSE_QUIET; diff --git a/cmd-attach-session.c b/cmd-attach-session.c index 050c7ca38..6557cef32 100644 --- a/cmd-attach-session.c +++ b/cmd-attach-session.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-attach-session.c,v 1.91 2026/07/10 13:38:45 nicm Exp $ */ +/* $OpenBSD: cmd-attach-session.c,v 1.92 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -91,8 +91,10 @@ cmd_attach_session(struct cmdq_item *item, const char *tflag, int dflag, wp = target.wp; if (wl != NULL) { - if (wp != NULL) + if (wp != NULL) { + window_redraw_active_switch(wp->window, wp); window_set_active_pane(wp->window, wp, 1); + } session_set_current(s, wl); if (wp != NULL) cmd_find_from_winlink_pane(current, wl, wp, 0); diff --git a/cmd-copy-mode.c b/cmd-copy-mode.c index e6888ca7e..28a1a742e 100644 --- a/cmd-copy-mode.c +++ b/cmd-copy-mode.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-copy-mode.c,v 1.54 2026/07/14 17:17:17 nicm Exp $ */ +/* $OpenBSD: cmd-copy-mode.c,v 1.55 2026/10/02 12:12:51 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -103,6 +103,8 @@ cmd_copy_mode_exec(struct cmd *self, struct cmdq_item *item) if (args_has(args, 'd')) window_copy_pagedown(wp, 0, args_has(args, 'e')); if (args_has(args, 'S')) { + if (c == NULL) + return (CMD_RETURN_NORMAL); tty_window_offset(&c->tty, &tty_ox, &tty_oy, &tty_sx, &tty_sy); window_copy_scroll(wp, c->tty.mouse_slider_mpos, event->m.y, tty_oy, args_has(args, 'e')); diff --git a/cmd-display-message.c b/cmd-display-message.c index 0e294ef50..ca56574f7 100644 --- a/cmd-display-message.c +++ b/cmd-display-message.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-display-message.c,v 1.66 2026/09/08 08:33:10 nicm Exp $ */ +/* $OpenBSD: cmd-display-message.c,v 1.67 2026/10/02 15:13:07 nicm Exp $ */ /* * Copyright (c) 2009 Tiago Cunha @@ -114,12 +114,10 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) template = DISPLAY_MESSAGE_TEMPLATE; /* - * -c is intended to be the client where the message should be - * displayed if -p is not given. But it makes sense to use it for the - * formats too, assuming it matches the session. If it doesn't, use the - * best client for the session. + * -c is also used for the client formats. If it was not given, tc is + * only the current client, so use it only if it matches the session. */ - if (tc != NULL && tc->session == s) + if (tc != NULL && (args_has(args, 'c') || tc->session == s)) c = tc; else if (s != NULL) c = cmd_find_best_client(s); diff --git a/cmd-join-pane.c b/cmd-join-pane.c index 56f64f878..ae2a6f18e 100644 --- a/cmd-join-pane.c +++ b/cmd-join-pane.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-join-pane.c,v 1.75 2026/09/21 10:33:16 nicm Exp $ */ +/* $OpenBSD: cmd-join-pane.c,v 1.76 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2011 George Nachman @@ -338,7 +338,7 @@ cmd_join_pane_mouse_move(struct client *c, struct mouse_event *m) struct window *w; struct window_pane *wp; struct layout_cell *lc; - int y, ly, x, lx; + int y, ly, x, lx, oxoff, oyoff, osx, osy; wp = cmd_mouse_pane(m, NULL, &wl); if (wp == NULL) { @@ -360,10 +360,16 @@ cmd_join_pane_mouse_move(struct client *c, struct mouse_event *m) ly = m->statusat - 1; if (x != lx || y != ly) { + oxoff = wp->xoff; + oyoff = wp->yoff; + osx = wp->sx; + osy = wp->sy; + lc->g.xoff += x - lx; lc->g.yoff += y - ly; layout_fix_panes(w, NULL); - server_redraw_window(w); + + window_redraw_floating_pane(wp, oxoff, oyoff, osx, osy); server_redraw_window_borders(w); } } diff --git a/cmd-resize-pane.c b/cmd-resize-pane.c index 6f04c2fae..c2cb6c16b 100644 --- a/cmd-resize-pane.c +++ b/cmd-resize-pane.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-resize-pane.c,v 1.69 2026/09/21 10:33:16 nicm Exp $ */ +/* $OpenBSD: cmd-resize-pane.c,v 1.70 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -305,8 +305,8 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c, struct window_pane *wp; struct layout_cell *lc; int y, ly, x, lx, sx, sy, new_sx, new_sy; - int left, right; - int new_xoff, new_yoff, resizes = 0; + int left, right, resizes = 0; + int new_xoff, new_yoff, old_xoff, old_yoff; wp = cmd_mouse_pane(m, NULL, &wl); if (wp == NULL) { @@ -317,6 +317,8 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c, lc = wp->layout_cell; sx = wp->sx; sy = wp->sy; + old_xoff = wp->xoff; + old_yoff = wp->yoff; left = wp->xoff - 1; right = wp->xoff + sx; if (window_pane_scrollbar_reserve(wp) && @@ -416,7 +418,7 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c, } if (resizes != 0) { layout_fix_panes(w, NULL); - server_redraw_window(w); + window_redraw_floating_pane(wp, old_xoff, old_yoff, sx, sy); server_redraw_window_borders(w); } } diff --git a/cmd-split-window.c b/cmd-split-window.c index 73baaf049..c244aa4f0 100644 --- a/cmd-split-window.c +++ b/cmd-split-window.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-split-window.c,v 1.151 2026/09/10 11:02:18 nicm Exp $ */ +/* $OpenBSD: cmd-split-window.c,v 1.152 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -343,6 +343,7 @@ cmd_split_window_mouse_resize(struct client *c, struct mouse_event *m) enum pane_lines lines; u_int sx, sy; int x, y, xoff, yoff, border; + int oxoff, oyoff, osx, osy; if (c->tty.mouse_last_pane == -1) return; @@ -398,8 +399,14 @@ cmd_split_window_mouse_resize(struct client *c, struct mouse_event *m) if (sy < PANE_MINIMUM) sy = PANE_MINIMUM; + oxoff = wp->xoff; + oyoff = wp->yoff; + osx = wp->sx; + osy = wp->sy; + layout_set_size(lc, sx, sy, xoff, yoff); layout_fix_panes(w, NULL); - server_redraw_window(w); + + window_redraw_floating_pane(wp, oxoff, oyoff, osx, osy); server_redraw_window_borders(w); } diff --git a/layout.c b/layout.c index 486134fc8..4f2684fe1 100644 --- a/layout.c +++ b/layout.c @@ -1,4 +1,4 @@ -/* $OpenBSD: layout.c,v 1.101 2026/09/20 08:42:46 nicm Exp $ */ +/* $OpenBSD: layout.c,v 1.102 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -512,16 +512,17 @@ layout_fix_panes(struct window *w, struct window_pane *skip) layout_get_pane_geometry(wp, 0, &xoff, &yoff, &sx, &sy); wp->xoff = xoff; wp->yoff = yoff; - if (window_pane_scrollbar_reserve(wp)) - wp->flags |= PANE_REDRAWSCROLLBAR; window_pane_resize(wp, sx, sy); if (wp->xoff != old_xoff || wp->yoff != old_yoff || wp->sx != old_sx || - wp->sy != old_sy) + wp->sy != old_sy) { + if (window_pane_scrollbar_reserve(wp)) + wp->flags |= PANE_REDRAWSCROLLBAR; changed = 1; + } } if (changed) redraw_invalidate_scene(w); diff --git a/options-table.c b/options-table.c index 0f3623df1..4af612671 100644 --- a/options-table.c +++ b/options-table.c @@ -1,4 +1,4 @@ -/* $OpenBSD: options-table.c,v 1.247 2026/09/21 10:22:31 nicm Exp $ */ +/* $OpenBSD: options-table.c,v 1.248 2026/10/02 15:04:27 nicm Exp $ */ /* * Copyright (c) 2011 Nicholas Marriott @@ -223,7 +223,7 @@ static const char *options_table_copy_mode_line_numbers_list[] = { "#{E:session-status-style}" \ "]" \ "#[push-default]" \ - "#S#{session_alert}" \ + "#S#{q/h:session_alert}" \ "#[pop-default]" \ "#[norange list=on default] " \ "," \ @@ -234,7 +234,7 @@ static const char *options_table_copy_mode_line_numbers_list[] = { "}" \ "]" \ "#[push-default]" \ - "#S*#{session_alert}" \ + "#S*#{q/h:session_alert}" \ "#[pop-default]" \ "#[norange list=on default] " \ "}" diff --git a/regress/attach-session-redraw.sh b/regress/attach-session-redraw.sh new file mode 100755 index 000000000..985461499 --- /dev/null +++ b/regress/attach-session-redraw.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +# Selecting a pane with attach-session from an already attached client must +# redraw pane contents when window-style and window-active-style differ. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lattach-redraw-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lattach-redraw-outer-$$ -f/dev/null" +BEFORE=$DIR/before +AFTER=$DIR/after + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +LEFT=$($INNER new-session -dPF '#{pane_id}' -s inner -x 40 -y 8 \ + "printf 'LEFT'; exec sleep 100") || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g default-terminal screen || exit 1 +$INNER split-window -h -t "$LEFT" "printf 'RIGHT'; exec sleep 100" || exit 1 +$INNER set-option -w -t "$LEFT" window-style bg=red || exit 1 +$INNER set-option -w -t "$LEFT" window-active-style bg=blue || exit 1 +$INNER bind-key -n x attach-session -t "$LEFT" || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 8 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lattach-redraw-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +sleep 1 +$OUTER send-keys -t outer:0.0 x || exit 1 +sleep 1 +[ "$($INNER display-message -p -t inner '#{pane_id}')" = "$LEFT" ] || + fail "attach-session did not select the target pane" +$OUTER capture-pane -pe -t outer:0.0 >"$BEFORE" || exit 1 + +# A forced redraw produces the correct scene. It must be identical to the +# scene drawn immediately by attach-session. +$INNER refresh-client -t "$CLIENT" || exit 1 +sleep 1 +$OUTER capture-pane -pe -t outer:0.0 >"$AFTER" || exit 1 +cmp -s "$BEFORE" "$AFTER" || + fail "attach-session left stale active/inactive pane styles" + +exit 0 diff --git a/regress/cfg-client-free.sh b/regress/cfg-client-free.sh new file mode 100644 index 000000000..9069163f6 --- /dev/null +++ b/regress/cfg-client-free.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +# a client lost while the configuration is loading is freed + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +TMPDIR=$(mktemp -d) || exit 1 +TMUX="$TEST_TMUX -S$TMPDIR/tmux.sock" + +cleanup() +{ + touch $TMPDIR/go + $TMUX kill-server 2>/dev/null + rm -rf "$TMPDIR" +} +trap cleanup 0 1 15 + +# Poll until a command succeeds. +wait_for() +{ + i=0 + while ! "$@"; do + [ $i -eq 100 ] && exit 1 + i=$((i + 1)) + sleep 0.1 + done +} + +# The run-shell holds the configuration until the go file exists. +cat <$TMPDIR/conf +new-session -d -s keep +run-shell 'touch $TMPDIR/started; i=0; while [ ! -f $TMPDIR/go ] && [ \$i -lt 100 ]; do sleep 0.1; i=\$((i + 1)); done' +EOF + +cd $TMPDIR || exit 1 +$TMUX -v -f$TMPDIR/conf new-session -d -s first : [cfg_done/" not in contents, contents + finally: + go.touch() + server.terminate() + try: + server.wait(timeout=3) + except subprocess.TimeoutExpired: + server.kill() + server.wait() +PY diff --git a/regress/cfg-load-once.sh b/regress/cfg-load-once.sh new file mode 100644 index 000000000..b9f4289e1 --- /dev/null +++ b/regress/cfg-load-once.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +# the configuration is loaded once if the client that started it is lost, and +# the next client still waits for it to finish + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +TMPDIR=$(mktemp -d) || exit 1 +TMUX="$TEST_TMUX -S$TMPDIR/tmux.sock" + +cleanup() +{ + touch $TMPDIR/go + $TMUX kill-server 2>/dev/null + rm -rf "$TMPDIR" +} +trap cleanup 0 1 15 + +# Poll until a command succeeds. +wait_for() +{ + i=0 + while ! "$@"; do + [ $i -eq 100 ] && exit 1 + i=$((i + 1)) + sleep 0.1 + done +} + +# The run-shell holds the configuration until the go file exists. +cat <$TMPDIR/conf +set -ga @runs x +new-session -d -s keep +run-shell 'touch $TMPDIR/started; i=0; while [ ! -f $TMPDIR/go ] && [ \$i -lt 100 ]; do sleep 0.1; i=\$((i + 1)); done' +set -g @late 1 +EOF + +cd $TMPDIR || exit 1 +$TMUX -v -f$TMPDIR/conf new-session -d -s first $TMPDIR/out & +pid=$! +wait_for grep -q "name is client-$pid\$" $log +touch $TMPDIR/go +wait $pid || exit 1 + +[ "$(cat $TMPDIR/out)" = "x 1" ] || exit 1 + +exit 0 diff --git a/regress/check-names.sh b/regress/check-names.sh index be8ad617b..8ab68f497 100644 --- a/regress/check-names.sh +++ b/regress/check-names.sh @@ -5,6 +5,15 @@ PATH=/bin:/usr/bin TERM=screen +# The pane's shell must not be the user's own interactive shell: a custom +# PS1/PROMPT_COMMAND that sets the terminal title (as many do, for tmux/xterm +# TERM types) would redraw over the titles this test sets and checks on +# every prompt, regardless of how long it waits first. +shell= +if command -v bash >/dev/null 2>&1; then + shell='bash --noprofile --norc +o history' +fi + [ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" $TMUX kill-server 2>/dev/null @@ -30,7 +39,7 @@ must_equal() [ "$got" = "$want" ] || fail "got '$got', expected '$want'" } -$TMUX new-session -d -x 80 -y 24 || exit 1 +$TMUX new-session -d -x 80 -y 24 -- $shell || exit 1 $TMUX set-option -qg allow-set-title on || exit 1 $TMUX set-option -qg allow-rename on || exit 1 $TMUX set-option -qg automatic-rename off || exit 1 diff --git a/regress/copy-mode-no-client-scroll.sh b/regress/copy-mode-no-client-scroll.sh new file mode 100644 index 000000000..5dc8577f9 --- /dev/null +++ b/regress/copy-mode-no-client-scroll.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +# copy-mode -S can be called from a hook without an attached client. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +OUT=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$OUT +export TMUX_TMPDIR +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null || true + rm -rf "$OUT" +} +trap cleanup EXIT +trap 'exit 1' 1 2 3 15 + +$TMUX new-session -d -s test 'sleep 300' || fail "new-session failed" +$TMUX set -g remain-on-exit on || fail "setting remain-on-exit failed" +$TMUX set-hook -g pane-died 'copy-mode -S' || + fail "setting pane-died hook failed" +$TMUX respawn-pane -k -t test:0 'exit 0' || fail "respawn-pane failed" + +i=0 +while [ "$i" -lt 50 ]; do + $TMUX has-session -t test 2>/dev/null || \ + fail "server died in copy-mode -S" + [ "$($TMUX display-message -p -t test:0 '#{pane_dead}' 2>/dev/null)" = 1 ] && \ + break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "pane-died hook was not triggered" +sleep 0.5 +$TMUX has-session -t test || fail "server died after copy-mode -S" + +exit 0 diff --git a/regress/display-message-client.sh b/regress/display-message-client.sh new file mode 100755 index 000000000..c0f52c257 --- /dev/null +++ b/regress/display-message-client.sh @@ -0,0 +1,66 @@ +#!/bin/sh + +# display-message -c must take the client formats from the given client, even +# when it is attached to a different session from the target pane. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +cleanup +$INNER new-session -d -s one -x40 -y10 'exec sleep 100' || exit 1 +$INNER new-session -d -s two -x40 -y10 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 + +# Each client has a distinct TERM so that client_termname identifies it. +attach() +{ + echo "env -i PATH=/bin:/usr/bin TERM=$1 LC_ALL=C.UTF-8 $TEST_TMUX -LtestI$$ -f/dev/null attach-session -t $2" +} +$OUTER new-session -d -s outer -x40 -y10 "$(attach screen one)" || exit 1 +$OUTER new-window -d "$(attach xterm two)" || exit 1 +$OUTER set-option -g status off || exit 1 + +i=0 +while [ "$i" -lt 50 ]; do + n=$($INNER list-clients -F '#{client_name}' 2>/dev/null|wc -l) + [ "$n" -eq 2 ] && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "clients did not attach" + +c1=$($INNER list-clients -F '#{client_name}' -f '#{==:#{client_termname},screen}') +c2=$($INNER list-clients -F '#{client_name}' -f '#{==:#{client_termname},xterm}') +[ -n "$c1" ] && [ -n "$c2" ] || fail "could not find both clients" + +# The target is in session two, but the client is the one in session one. +term=$($INNER display-message -c "$c1" -t two: -p '#{client_termname}') +[ "$term" = screen ] || fail "client_termname was '$term', not screen" + +# The session formats still come from the target. +session=$($INNER display-message -c "$c1" -t two: -p '#{session_name}') +[ "$session" = two ] || fail "session_name was '$session', not two" + +term=$($INNER display-message -c "$c2" -t one: -p '#{client_termname}') +[ "$term" = xterm ] || fail "client_termname was '$term', not xterm" + +exit 0 diff --git a/regress/floating-pane-border-clients.sh b/regress/floating-pane-border-clients.sh new file mode 100755 index 000000000..917f3b6c1 --- /dev/null +++ b/regress/floating-pane-border-clients.sh @@ -0,0 +1,135 @@ +#!/bin/sh + +# Damage-only redraws must evaluate both active and inactive border styles +# for each client, even though the cached border cells belong to the pane. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lborder-style-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lborder-style-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_clients() +{ + i=0 + while [ "$i" -lt 50 ]; do + count=$($INNER list-clients 2>/dev/null | wc -l) + [ "$count" -eq 2 ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "two inner clients did not attach" +} + +wait_for_marker() +{ + target=$1 + marker=$2 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t "$target" >"$CAPTURE" || exit 1 + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client in $target did not receive $marker" +} + +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $phase (1 .. 2) { + while (!-e "$ENV{TRIGGER}-$phase") { + select undef, undef, undef, 0.01; + } + # Change an unused palette entry and acknowledge it in the pane body. + print "\e]4;200;rgb:11/22/0$phase\a\e[1;1HDAMAGE$phase"; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 60 -y 20 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$INNER set -g pane-border-status top || exit 1 +$INNER set -g pane-border-format 'CLIENT=#{client_name}' || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 35 -y 6 -X 5 -Y 5 \ + 'sleep 100') || exit 1 + +$OUTER new-session -d -s outer -x 121 -y 20 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +LEFT=$($OUTER display-message -p -t outer:0.0 '#{pane_id}') || exit 1 +RIGHT=$($OUTER split-window -h -PF '#{pane_id}' 'sleep 100') || exit 1 +for target in "$LEFT" "$RIGHT"; do + $OUTER respawn-pane -k -t "$target" "$INNER attach-session -t inner" || + exit 1 +done +wait_for_clients + +NAME1=$($OUTER display-message -p -t "$LEFT" '#{pane_tty}') || exit 1 +NAME2=$($OUTER display-message -p -t "$RIGHT" '#{pane_tty}') || exit 1 +STYLE="fg=#{?#{==:#{client_name},$NAME1},red,blue}" +$INNER set -g pane-border-style "$STYLE" || exit 1 +$INNER set -g pane-active-border-style "$STYLE" || exit 1 + +# Capture just the floating pane's rows, excluding the acknowledgement in +# the tiled pane. Exercise each cache by changing the floating pane's focus. +phase=1 +while [ "$phase" -le 2 ]; do + if [ "$phase" -eq 2 ]; then + $INNER select-pane -t "$FLOAT" || exit 1 + fi + $INNER refresh-client -t "$NAME1" || exit 1 + $INNER refresh-client -t "$NAME2" || exit 1 + sleep 0.5 + for target in "$LEFT" "$RIGHT"; do + $OUTER capture-pane -pe -S 5 -E 10 -t "$target" \ + >"$DIR/before-$target" || exit 1 + done + RED=$(printf '\033[31m') + BLUE=$(printf '\033[34m') + grep -Fq "$RED" "$DIR/before-$LEFT" || fail "missing red border" + grep -Fq "$BLUE" "$DIR/before-$RIGHT" || fail "missing blue border" + + : >"$DIR/trigger-$phase" + wait_for_marker "$LEFT" "DAMAGE$phase" + wait_for_marker "$RIGHT" "DAMAGE$phase" + sleep 0.2 + for target in "$LEFT" "$RIGHT"; do + $OUTER capture-pane -pe -S 5 -E 10 -t "$target" \ + >"$CAPTURE" || exit 1 + diff -u "$DIR/before-$target" "$CAPTURE" || + fail "phase $phase changed $target's border style" + done + phase=$((phase + 1)) +done + +exit 0 diff --git a/regress/floating-pane-drag-composite.sh b/regress/floating-pane-drag-composite.sh new file mode 100755 index 000000000..f1fb37faa --- /dev/null +++ b/regress/floating-pane-drag-composite.sh @@ -0,0 +1,88 @@ +#!/bin/sh + +# Each drag motion must draw the floating pane content once. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +cd "$DIR" || exit 1 +INNER="$TEST_TMUX -Ldoublecomp-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Ldoublecomp-outer-$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + cd / + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.15 +} + +$INNER new-session -d -s inner -x 40 -y 15 "printf '\\033[15;1HOUTSIDE'; exec sleep 100" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g status-interval 0 || exit 1 +$INNER set-option -g automatic-rename off || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X 5 -Y 2 \ + "printf 'DRAGMARK'; exec sleep 100") || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 15 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Ldoublecomp-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 +sleep 0.5 + +XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +GRABCOL=$((XOFF + 3)) +BORDERROW=$YOFF + +# Begin capture after focus changes from the initial mouse press have settled. +mouse 0 "$GRABCOL" "$BORDERROW" M +$OUTER pipe-pane -O -t outer:0.0 "cat >'$DIR/output'" || exit 1 +$INNER refresh-client || exit 1 +sleep 0.5 +grep -aq DRAGMARK "$DIR/output" || fail "capture missed floating pane content" +grep -aq OUTSIDE "$DIR/output" || fail "capture missed untouched row" +offset=$(wc -c <"$DIR/output") + +i=0 +steps=6 +while [ "$i" -lt "$steps" ]; do + GRABCOL=$((GRABCOL + 1)) + mouse 32 "$GRABCOL" "$BORDERROW" M + i=$((i + 1)) +done +mouse 0 "$GRABCOL" "$BORDERROW" m +sleep 0.3 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" -eq "$((XOFF + steps))" ] || fail "floating pane did not move six columns" +tail -c +"$((offset + 1))" "$DIR/output" >"$DIR/drag-output" +n=$(perl -0777 -ne '$n = () = /DRAGMARK/g; print "$n\n"' "$DIR/drag-output") +[ "$n" -eq "$steps" ] || + fail "$steps drag motions wrote the floating pane content $n times" + +exit 0 diff --git a/regress/floating-pane-drag-ghost.sh b/regress/floating-pane-drag-ghost.sh new file mode 100755 index 000000000..abc20633d --- /dev/null +++ b/regress/floating-pane-drag-ghost.sh @@ -0,0 +1,108 @@ +#!/bin/sh + +# Regression test for a floating-pane drag bug: cmd_resize_pane_redraw_floating() +# (cmd-resize-pane.c) reported damage for just a dragged floating pane's +# content rectangle, not the one-cell border frame drawn around it (see the +# "floating" case in screen-redraw.c, which draws that frame at +# xoff-1/yoff-1 through xoff+sx/yoff+sy - one cell outside the pane's own +# content area). Damage scoped to only the content area left the frame's +# previous position undrawn as the pane moved, so dragging it left a trail +# of un-erased border frames behind - visible as several "corners" stacked +# up rather than just the pane's current one. +# +# This bug has nothing to do with images - it reproduces with a plain +# floating pane and no image support required. + +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 STARTCOL STARTROW ENDCOL ENDROW +# +# Write a plain (unmodified) SGR button-1 press, drag update and release at +# 1-based positions 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. +drag() +{ + scol="$1" + srow="$2" + ecol="$3" + erow="$4" + + seq=$(printf '\033[<0;%s;%sM' "$scol" "$srow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<32;%s;%sM' "$ecol" "$erow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<0;%s;%sm' "$ecol" "$erow") + $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 +$TMUX set -g mouse on +$TMUX set -g default-command 'sh -c "sleep 100"' + +FLOAT=$($TMUX new-pane -d -PF '#{pane_id}' -x 16 -y 5 -X 5 -Y 5) || + 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: exactly one floating pane, so exactly one top-left corner, +# before dragging anything. +$TMUX2 capturep -p -t "$OUTER" >$TMP || fail "capture failed" +n=$(grep -o '┌' $TMP | wc -l) +[ "$n" -eq 1 ] || fail "sanity: expected 1 corner before drag, found $n" + +# Drag the floating pane by its top border (row FTOP-1, some column within +# its width) down several rows in a few separate steps, then release. A +# single drag() call already does press/motion/release, so call it several +# times in a row to simulate a multi-step real drag. +GRABCOL=$((FLEFT + FWIDTH / 2)) +STARTROW=$FTOP +i=0 +while [ $i -lt 6 ]; do + newrow=$((STARTROW + i + 1)) + drag $((GRABCOL + 1)) $((STARTROW + i)) $((GRABCOL + 1)) $newrow + i=$((i + 1)) +done + +$TMUX2 capturep -p -t "$OUTER" >$TMP || fail "capture failed" + +# Exactly one top-left corner should remain - the pane's current position. +# This is expected to fail before the fix: multiple corners (a trail of +# un-erased frames) would remain from the intermediate drag positions. +n=$(grep -o '┌' $TMP | wc -l) +[ "$n" -eq 1 ] || fail "expected exactly 1 corner after drag, found $n (ghost frames left behind)" + +exit 0 diff --git a/regress/floating-pane-drag-sb-strip.sh b/regress/floating-pane-drag-sb-strip.sh new file mode 100755 index 000000000..fbdebbc82 --- /dev/null +++ b/regress/floating-pane-drag-sb-strip.sh @@ -0,0 +1,160 @@ +#!/bin/sh + +# Regression test: dragging a floating pane across another pane's ordinary +# content must not redraw that other pane's scrollbar, unless the drag +# actually crosses the scrollbar's own strip. +# +# cmd_resize_pane_redraw_floating() (cmd-resize-pane.c) used to flag +# PANE_REDRAWSCROLLBAR on any pane whose whole *body* intersected the +# floating pane's old or new rectangle, rather than just its narrow +# scrollbar strip - so dragging a floating pane back and forth over an +# ordinary tiled pane's content (never touching its scrollbar) still +# needlessly redrew that pane's scrollbar on every motion step. See +# tmux-image-redraw-known-bugs.md for the full write-up. +# +# This is checked by giving the non-dragged pane a distinctive scrollbar +# colour and counting how many times its SGR code appears in the client's +# raw output while the floating pane is dragged vertically over that pane's +# body, well clear of its scrollbar column: with the fix, it should never +# reappear after the initial draw. + +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 STARTCOL STARTROW ENDCOL ENDROW +drag() +{ + scol="$1" + srow="$2" + ecol="$3" + erow="$4" + + seq=$(printf '\033[<0;%s;%sM' "$scol" "$srow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<32;%s;%sM' "$ecol" "$erow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.2 + seq=$(printf '\033[<0;%s;%sm' "$ecol" "$erow") + $TMUX2 send-keys -t "$OUTER" -l "$seq" 2>/dev/null + sleep 0.5 +} + +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 +$TMUX set -g status-right '' || fail "set status-right failed" +$TMUX set -g mouse on || fail "set mouse failed" +$TMUX set -g default-command 'sh -c "sleep 100"' || fail "set default-command failed" +$TMUX set -g pane-scrollbars on || fail "set pane-scrollbars failed" + +$TMUX split-window -h -t inner 'sh -c "sleep 100"' || fail "split-window failed" + +PANES=$($TMUX list-panes -t inner -F '#{pane_id} #{pane_left}') +LEFT=$(echo "$PANES" | sort -k2 -n | head -1 | cut -d' ' -f1) +[ -n "$LEFT" ] || fail "could not identify left pane" + +# A distinctive scrollbar colour for the non-dragged (left) pane only. +$TMUX set -p -t "$LEFT" pane-scrollbars-style 'fg=colour201,bg=colour17' || + fail "set pane-scrollbars-style failed" + +ALEFT=$($TMUX display-message -p -t "$LEFT" '#{pane_left}') +ATOP=$($TMUX display-message -p -t "$LEFT" '#{pane_top}') +AWIDTH=$($TMUX display-message -p -t "$LEFT" '#{pane_width}') +AHEIGHT=$($TMUX display-message -p -t "$LEFT" '#{pane_height}') +[ "$AWIDTH" -gt 15 ] || fail "left pane too narrow for this test ($AWIDTH)" + +# A small floating pane placed well inside the left pane's content area, +# clear of its (right-hand) scrollbar column by several columns. +FLOAT=$($TMUX new-pane -d -PF '#{pane_id}' -x 8 -y 5 \ + -X $((ALEFT + 2)) -Y $((ATOP + 2))) || 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}') +[ $((FLEFT + FWIDTH + 3)) -lt $((ALEFT + AWIDTH)) ] || + fail "sanity: floating pane too close to the scrollbar column" + +# 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 draws the scrollbars) has already happened, missing it. +$TMUX2 new-session -d -x 60 -y 20 || 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 distinctive scrollbar colour reaches the client at all. +grep -qa '48;5;201' $TMP || fail "sanity: scrollbar colour never reached the client" +: >$TMP + +# Drag the floating pane straight up and down by its top border, staying at +# a fixed column the whole time - this never crosses the left pane's +# scrollbar strip, only its ordinary content. +GRABCOL=$((FLEFT + FWIDTH / 2)) +row=$FTOP +i=0 +while [ $i -lt 6 ]; do + newrow=$((row + 1)) + drag $GRABCOL $row $GRABCOL $newrow + row=$newrow + i=$((i + 1)) +done +i=0 +while [ $i -lt 6 ]; do + newrow=$((row - 1)) + drag $GRABCOL $row $GRABCOL $newrow + row=$newrow + i=$((i + 1)) +done + +# The scrollbar colour should never reappear - its geometry never changed, +# and the drag never crossed its column. This is expected to fail before +# the fix - see the header comment. +n=$(grep -ac '48;5;201' $TMP) +[ "$n" -eq 0 ] || + fail "left pane's scrollbar was redrawn $n times while dragging over its body only" + +# Cross the scrollbar and return, comparing each damaged redraw with a full +# refresh to check that covering and uncovering its cells leaves no artifacts. +offset=$((ALEFT + AWIDTH - FLEFT - FWIDTH / 2)) +for delta in "$offset" "$((-offset))"; do + oldleft=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}') + newcol=$((GRABCOL + delta)) + drag "$GRABCOL" "$row" "$newcol" "$row" + GRABCOL=$newcol + newleft=$($TMUX display-message -p -t "$FLOAT" '#{pane_left}') + [ "$newleft" -eq "$((oldleft + delta))" ] || + fail "floating pane did not move across the scrollbar as expected" + before=$($TMUX2 capture-pane -pe -t "$OUTER" -S 0 -E "$((AHEIGHT - 1))") + $TMUX refresh-client || fail "refresh-client failed" + sleep 0.5 + after=$($TMUX2 capture-pane -pe -t "$OUTER" -S 0 -E "$((AHEIGHT - 1))") + [ "$before" = "$after" ] || + fail "scrollbar crossing differs from a full redraw" +done + +exit 0 diff --git a/regress/floating-pane-drag-sync.sh b/regress/floating-pane-drag-sync.sh new file mode 100755 index 000000000..bd63d5adf --- /dev/null +++ b/regress/floating-pane-drag-sync.sh @@ -0,0 +1,93 @@ +#!/bin/sh + +# Synchronized drag output must not cause a full redraw of untouched rows. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +cd "$DIR" || exit 1 +INNER="$TEST_TMUX -Lsyncdefer-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lsyncdefer-outer-$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + cd / + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.15 +} + +$INNER new-session -d -s inner -x 40 -y 15 "printf '\\033[15;1HOUTSIDE'; exec sleep 100" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g status-interval 0 || exit 1 +$INNER set-option -g automatic-rename off || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X 5 -Y 2 \ + "printf 'DRAGMARK'; exec sleep 100") || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 15 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$INNER set-option -as terminal-features ',screen-256color:sync' || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lsyncdefer-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 +sleep 0.5 + +XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +GRABCOL=$((XOFF + 3)) +BORDERROW=$YOFF + +# Begin capture after focus changes from the initial mouse press have settled. +mouse 0 "$GRABCOL" "$BORDERROW" M +$OUTER pipe-pane -O -t outer:0.0 "cat >'$DIR/output'" || exit 1 +$INNER refresh-client || exit 1 +sleep 0.5 +grep -aq DRAGMARK "$DIR/output" || fail "capture missed floating pane content" +grep -aq OUTSIDE "$DIR/output" || fail "capture missed untouched row" +offset=$(wc -c <"$DIR/output") + +i=0 +steps=6 +while [ "$i" -lt "$steps" ]; do + GRABCOL=$((GRABCOL + 1)) + mouse 32 "$GRABCOL" "$BORDERROW" M + i=$((i + 1)) +done +mouse 0 "$GRABCOL" "$BORDERROW" m +sleep 0.3 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" -eq "$((XOFF + steps))" ] || fail "floating pane did not move six columns" +tail -c +"$((offset + 1))" "$DIR/output" >"$DIR/drag-output" +n=$(perl -0777 -ne '$n = () = /DRAGMARK/g; print "$n\n"' "$DIR/drag-output") +[ "$n" -ge "$steps" ] || fail "drag output did not reach the client" +perl -0777 -ne 'exit(/\e\[\?2026h/ ? 0 : 1)' "$DIR/drag-output" || + fail "drag did not use synchronized output" +if grep -aq OUTSIDE "$DIR/drag-output"; then + fail "synchronized drag redrew an untouched row" +fi + +exit 0 diff --git a/regress/floating-pane-drag-wide-character.sh b/regress/floating-pane-drag-wide-character.sh new file mode 100755 index 000000000..cc6854efd --- /dev/null +++ b/regress/floating-pane-drag-wide-character.sh @@ -0,0 +1,200 @@ +#!/bin/sh + +# Damage at a floating pane's vacated edge must always redraw a complete +# grid character. redraw_damage_grow_span_clip() (screen-redraw.c) widens a +# damage rectangle's left edge by one cell whenever it isn't already at the +# span's own edge, to pull in a wide character's base half when the edge +# lands on its padding half - but it did this unconditionally, with no +# check of which half it was actually touching. When the edge instead +# already lands cleanly on a fresh character's base cell, growing left +# walks into the *previous*, unrelated character's padding cell and blanks +# it (tty_draw_line() treats any leading padding cell as proof its own +# range starts mid-character). +# +# This is a general damage-composition bug, not specific to any one kind of +# pane, but the exact column parity needs to be deterministic to actually +# catch it (a lucky parity draws fine). This constructs it by creating the +# floating pane, checking its real position (the border-framing offset +# added to -X is not something to hand-compute), and recreating it one +# column over if necessary until the vacated rectangle's left edge lands on +# a base cell. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lwidechar-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lwidechar-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +BASE=$DIR/base +CAPTURE=$DIR/capture +FLOAT= + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +slice_columns() +{ + # Extract terminal columns [COL1, COL2) from lines [ROW1, ROW2] of + # $1, decoding UTF-8 - the pane's own new position (well clear of + # this range) must not affect the comparison, so this only looks at + # the narrow strip actually vacated, not the whole line. capture-pane + # text has one decoded character per double-width cell pair (every + # character here is width 2), so terminal columns are converted to + # character indices by halving before slicing. + perl -CSD -e ' + my ($row1, $row2, $col1, $col2, $file) = @ARGV; + open my $fh, "<:encoding(UTF-8)", $file or die $!; + my @lines = <$fh>; + my $c1 = int($col1 / 2); + my $c2 = int(($col2 + 1) / 2); + for my $n ($row1 .. $row2) { + my $line = $lines[$n - 1]; + $line =~ s/\R\z//; + print substr($line, $c1, $c2 - $c1), "\n"; + } + ' "$ROW1" "$ROW2" "$COL1" "$COL2" "$1" +} + +wait_old_rows_restored() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + slice_columns "$BASE" >"$DIR/want" + slice_columns "$CAPTURE" >"$DIR/got" + cmp -s "$DIR/want" "$DIR/got" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "wide characters under the floating pane's vacated edge were not restored" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +binmode STDOUT, ':encoding(UTF-8)'; +$| = 1; +for my $row (1 .. 10) { + print "\e[$row;1H", chr(0x754c) x 20; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 40 -y 10 "perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g pane-scrollbars off || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lwidechar-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +wait_outer_has '界界界' +$OUTER capture-pane -p -t outer:0.0 >"$BASE" || exit 1 + +# Create the floating pane, then check its actual resulting position. Try +# adjacent starting columns until the vacated rectangle's left edge +# (xoff - 1) lands on an even (base-cell) column - the odd case is the one +# every earlier manual test happened to land on by chance. +startx=5 +tries=0 +while [ "$tries" -lt 2 ]; do + [ -n "$FLOAT" ] && $INNER kill-pane -t "$FLOAT" 2>/dev/null + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 12 -y 3 -X "$startx" \ + -Y 5 'sh -c "printf FLOATMARK; exec sleep 100"') || exit 1 + sleep 0.2 + XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') + oldleft=$((XOFF - 1)) + if [ $((oldleft % 2)) -eq 0 ]; then + break + fi + startx=$((startx + 1)) + tries=$((tries + 1)) +done +[ $(((XOFF - 1) % 2)) -eq 0 ] || fail "could not find bad-parity starting column" + +wait_outer_has FLOATMARK + +ROW1=$((YOFF + 1)) +ROW2=$((YOFF + 3)) +COL1=$((XOFF - 4)) +COL2=$((XOFF + 4)) + +# Grab the pane's top border a couple of columns in (avoiding the corner +# cells) and drag it well clear of its old rectangle. +GRABCOL=$((XOFF + 3)) +BORDERROW=$((YOFF)) + +seq=$(printf '\033[<0;%s;%sM' "$GRABCOL" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<32;%s;%sM' "$((GRABCOL + 15))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<0;%s;%sm' "$((GRABCOL + 15))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" != "$XOFF" ] || fail "sanity: floating pane did not move (still at $XOFF)" + +wait_old_rows_restored + +exit 0 diff --git a/regress/floating-pane-drag-wide-right.sh b/regress/floating-pane-drag-wide-right.sh new file mode 100755 index 000000000..495126a50 --- /dev/null +++ b/regress/floating-pane-drag-wide-right.sh @@ -0,0 +1,206 @@ +#!/bin/sh + +# Mirror of floating-pane-drag-wide-character.sh for the *right* edge of a +# damage rectangle. redraw_damage_grow_span_clip() (screen-redraw.c) widens +# a damage rectangle's right edge by one cell whenever it isn't already at +# the span's own edge, to pull in a wide character's base half when the +# edge lands on its padding half - but it did this unconditionally, with no +# check of which half it was actually touching. When the edge instead +# already lands cleanly on a fresh character's base cell (a character fully +# outside the range), growing right pulls in just that base cell - +# tty_draw_line() sees it can't fit that character's full width in the +# remaining range (tty_draw_line_get_empty()'s gc->data.width > nx check) +# and clears it, exactly as the left-edge bug cleared a neighbouring +# character's padding half. +# +# As with the left-edge test, the exact column parity needs to be +# deterministic to actually catch it. This constructs it by creating the +# floating pane, checking its real position, and recreating it one column +# over if necessary until the vacated rectangle's right edge lands on a +# base cell. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lwidecharr-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lwidecharr-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +BASE=$DIR/base +CAPTURE=$DIR/capture +FLOAT= +PANEWIDTH=12 + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +slice_columns() +{ + # Extract terminal columns [COL1, COL2) from lines [ROW1, ROW2] of + # $1, decoding UTF-8 - the pane's own new position (well clear of + # this range) must not affect the comparison, so this only looks at + # the narrow strip actually vacated, not the whole line. capture-pane + # text has one decoded character per double-width cell pair (every + # character here is width 2), so terminal columns are converted to + # character indices by halving before slicing. + perl -CSD -e ' + my ($row1, $row2, $col1, $col2, $file) = @ARGV; + open my $fh, "<:encoding(UTF-8)", $file or die $!; + my @lines = <$fh>; + my $c1 = int($col1 / 2); + my $c2 = int(($col2 + 1) / 2); + for my $n ($row1 .. $row2) { + my $line = $lines[$n - 1]; + $line =~ s/\R\z//; + print substr($line, $c1, $c2 - $c1), "\n"; + } + ' "$ROW1" "$ROW2" "$COL1" "$COL2" "$1" +} + +wait_old_rows_restored() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + slice_columns "$BASE" >"$DIR/want" + slice_columns "$CAPTURE" >"$DIR/got" + cmp -s "$DIR/want" "$DIR/got" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "wide characters under the floating pane's vacated right edge were not restored" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +binmode STDOUT, ':encoding(UTF-8)'; +$| = 1; +for my $row (1 .. 10) { + print "\e[$row;1H", chr(0x754c) x 20; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 40 -y 10 "perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g pane-scrollbars off || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lwidecharr-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +wait_outer_has '界界界' +$OUTER capture-pane -p -t outer:0.0 >"$BASE" || exit 1 + +# Create the floating pane, then check its actual resulting position. Try +# adjacent starting columns until the vacated rectangle's right edge +# (xoff + PANEWIDTH + 1, the border-grown exclusive end - see +# window_pane_damage_floating(), window.c) lands on an even (base-cell) +# column. +startx=5 +tries=0 +while [ "$tries" -lt 2 ]; do + [ -n "$FLOAT" ] && $INNER kill-pane -t "$FLOAT" 2>/dev/null + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x "$PANEWIDTH" -y 3 \ + -X "$startx" -Y 5 \ + 'sh -c "printf FLOATMARK; exec sleep 100"') || exit 1 + sleep 0.2 + XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') + oldright=$((XOFF + PANEWIDTH + 1)) + if [ $((oldright % 2)) -eq 0 ]; then + break + fi + startx=$((startx + 1)) + tries=$((tries + 1)) +done +[ $(((XOFF + PANEWIDTH + 1) % 2)) -eq 0 ] || + fail "could not find bad-parity starting column" + +wait_outer_has FLOATMARK + +ROW1=$((YOFF + 1)) +ROW2=$((YOFF + 3)) +COL1=$((XOFF + PANEWIDTH - 2)) +COL2=$((XOFF + PANEWIDTH + 2)) + +# Grab the pane's top border a couple of columns in (avoiding the corner +# cells) and drag it well clear of its old rectangle - far enough right +# that its new position starts past the checked columns above (which sit +# just past the pane's *original* right edge). +GRABCOL=$((XOFF + 3)) +BORDERROW=$((YOFF)) + +seq=$(printf '\033[<0;%s;%sM' "$GRABCOL" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<32;%s;%sM' "$((GRABCOL + 20))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 +seq=$(printf '\033[<0;%s;%sm' "$((GRABCOL + 20))" "$BORDERROW") +$OUTER send-keys -t outer:0.0 -l "$seq" || exit 1 +sleep 0.1 + +NEWXOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +[ "$NEWXOFF" != "$XOFF" ] || fail "sanity: floating pane did not move (still at $XOFF)" + +wait_old_rows_restored + +exit 0 diff --git a/regress/floating-pane-offscreen-alt.sh b/regress/floating-pane-offscreen-alt.sh new file mode 100755 index 000000000..97dbbe68e --- /dev/null +++ b/regress/floating-pane-offscreen-alt.sh @@ -0,0 +1,102 @@ +#!/bin/sh + +# Returning from the alternate screen must redraw the visible part of a +# floating pane clipped at the left edge, the top edge, or both. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Loffscreen-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Loffscreen-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $row (1 .. 5) { + print "\e[$row;1H", 'A' x 15; +} +while (!-e "$ENV{TRIGGER}-alternate") { + select undef, undef, undef, 0.01; +} +print "\e[?1049h"; +for my $row (1 .. 5) { + print "\e[$row;1H", 'B' x 15; +} +while (!-e "$ENV{TRIGGER}-restore") { + select undef, undef, undef, 0.01; +} +print "\e[?1049l"; +sleep 100; +PERL + +$INNER new-session -d -s inner -x 40 -y 10 'sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Loffscreen-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +for position in left top both; do + case "$position" in + left) x=-5; y=2 ;; + top) x=5; y=-2 ;; + both) x=-5; y=-2 ;; + esac + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X "$x" -Y "$y" \ + "TRIGGER='$DIR/$position' perl '$EMITTER'") || exit 1 + [ "$($INNER display-message -p -t "$FLOAT" '#{pane_left},#{pane_top}')" = "$((x + 1)),$((y + 1))" ] || + fail "$position: floating pane has unexpected position" + wait_outer_has AAAAA + cp "$CAPTURE" "$DIR/primary" + : >"$DIR/$position-alternate" + wait_outer_has BBBBB + : >"$DIR/$position-restore" + wait_outer_has AAAAA + cmp -s "$DIR/primary" "$CAPTURE" || + fail "$position: primary screen was not completely restored" + $INNER kill-pane -t "$FLOAT" || exit 1 +done + +exit 0 diff --git a/regress/floating-pane-prompt-wide-character.sh b/regress/floating-pane-prompt-wide-character.sh new file mode 100755 index 000000000..cdd892a54 --- /dev/null +++ b/regress/floating-pane-prompt-wide-character.sh @@ -0,0 +1,179 @@ +#!/bin/sh + +# A damage rectangle's clip range is grown to avoid splitting a wide +# character, but redraw_damage_grow_span_clip() (screen-redraw.c) only ever +# checks the span's own pane *content* grid (wp->screen) for that. For a +# REDRAW_SPAN_PANE span, that same range is then also handed to +# redraw_damage_draw_pane_prompt() to recompose the pane's separately +# rendered prompt (wp->prompt, e.g. from "command-prompt -P") over the +# damaged sub-range - but the prompt is drawn into its own, freshly +# allocated one-line screen, unrelated to the pane's content grid, so a +# range grown (or left ungrown) against the content is not necessarily +# grown correctly for the prompt's own wide characters. +# +# This is invisible when the pane's own content is plain ASCII (as here): +# redraw_damage_grow_span_clip() never finds anything to grow against, so +# the raw, ungrown geometric range is passed straight through to the +# prompt - and if that range's edge lands mid-character in the *prompt's* +# grid, tty_draw_line() clears the character it cuts through +# (tty_draw_line_get_empty()'s gc->data.width > nx check, for a trailing +# base cell with no room left for its padding half). +# +# The trigger is a palette change (OSC 4) in a tiled pane that is one half +# of a vertical split running the full height of the window - occluded +# under the floating pane, but still geometrically triggering a redraw of +# its own rectangle. Positioned so the split boundary falls inside the +# floating pane's own CJK prompt, this reproduces exactly Codex's report: +# "a floating pane containing a CJK prompt across a tiled-pane boundary - +# a palette update in the tiled pane blanks a prompt character." + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +cd "$DIR" || exit 1 +INNER="$TEST_TMUX -Lpromptwide-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lpromptwide-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +CAPTURE=$DIR/capture +FLOAT= + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + cd / + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_prompt_row_intact() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + line=$(sed -n "${PROMPTROW}p" "$CAPTURE") + case $line in + *"$PROMPTTEXT"*) return 0 ;; + esac + sleep 0.1 + i=$((i + 1)) + done + fail "the CJK prompt was not intact after the palette-triggered damage - got: $line" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +my $line = ; +print "\e]4;1;rgb:11/22/33\e\\"; +sleep 100; +PERL + +$INNER new-session -d -s inner -x 60 -y 12 "perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +LEFT=$($INNER list-panes -t inner -F '#{pane_id}') || exit 1 + +# Split so the boundary between the two tiled panes falls at column 17 - +# used below to pick a floating-pane column that lands the boundary +# mid-character inside the prompt. +RIGHT=$($INNER split-window -t inner -h -l 43 -PF '#{pane_id}' \ + 'sleep 100') || exit 1 +RX=$($INNER display-message -p -t "$RIGHT" '#{pane_left}') || exit 1 + +# Create the floating pane, then check its actual resulting position (the +# border-framing offset added to -X is not something to hand-compute). Try +# adjacent starting columns until the split boundary lands on an odd +# (padding-half) column of the prompt's own numbering, and within the +# prompt's 12-column width. +startx=10 +tries=0 +while [ "$tries" -lt 4 ]; do + [ -n "$FLOAT" ] && $INNER kill-pane -t "$FLOAT" 2>/dev/null + FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 24 -y 5 -X "$startx" \ + -Y 2 "sh -c 'i=0; while [ \$i -lt 10 ]; do \ +printf AAAAAAAAAAAAAAAAAAAAAA\\\\n; i=\$((i+1)); done; sleep 100'") || + exit 1 + sleep 0.2 + X1=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + Y1=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') + H1=$($INNER display-message -p -t "$FLOAT" '#{pane_height}') + local=$((RX - X1 - 1)) + if [ "$local" -ge 1 ] && [ "$local" -le 11 ] && + [ $((local % 2)) -eq 1 ]; then + break + fi + startx=$((startx + 1)) + tries=$((tries + 1)) +done +local=$((RX - X1 - 1)) +[ "$local" -ge 1 ] && [ "$local" -le 11 ] && [ $((local % 2)) -eq 1 ] || + fail "could not find bad-parity starting column" + +$OUTER new-session -d -s outer -x 60 -y 12 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lpromptwide-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +wait_outer_has AAAAAAAAAAAAAAAAAAAAAA +CLIENT=$($INNER list-clients -F '#{client_name}') || exit 1 + +$INNER select-pane -t "$FLOAT" || exit 1 +PROMPTTEXT=$(printf '\344\270\255' | perl -CSD -ne 'print $_ x 6') +$INNER command-prompt -b -P -t "$CLIENT" -p "$PROMPTTEXT" \ + 'display-message -- %1' || exit 1 +wait_outer_has "$PROMPTTEXT" + +PROMPTROW=$((Y1 + H1)) + +# Trigger the damage: unblock the emitter so it fires the palette change in +# the left tiled pane, which is occluded under (but geometrically overlaps) +# the floating pane's prompt row. +$INNER send-keys -t "$LEFT" Enter || exit 1 + +wait_prompt_row_intact + +exit 0 diff --git a/regress/floating-pane-status-cross-client.sh b/regress/floating-pane-status-cross-client.sh new file mode 100755 index 000000000..fadb5e5bf --- /dev/null +++ b/regress/floating-pane-status-cross-client.sh @@ -0,0 +1,151 @@ +#!/bin/sh + +# Damage redraws must show each client's own pane status, even though the +# cached status screen is shared between clients. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lstatuscc-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lstatuscc-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_clients() +{ + i=0 + while [ "$i" -lt 50 ]; do + count=$($INNER list-clients 2>/dev/null | wc -l) + [ "$count" -eq 2 ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "two inner clients did not attach" +} + +wait_for_marker() +{ + target=$1 + marker=$2 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t "$target" >"$CAPTURE" || exit 1 + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client in $target did not receive $marker" +} + +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $phase (1 .. 4) { + while (!-e "$ENV{TRIGGER}-$phase") { + select undef, undef, undef, 0.01; + } + # Change an unused palette entry and acknowledge it in the pane body. + print "\e]4;200;rgb:11/22/0$phase\a\e[1;1HDAMAGE$phase"; +} +sleep 100; +PERL + +$INNER new-session -d -s inner -x 60 -y 20 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$INNER set -g pane-border-status top || exit 1 +$INNER set -g pane-border-format 'CLIENT=<#{client_name}>' || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 35 -y 6 -X 5 -Y 5 \ + 'sleep 100') || exit 1 + +$OUTER new-session -d -s outer -x 121 -y 20 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +LEFT=$($OUTER display-message -p -t outer:0.0 '#{pane_id}') || exit 1 +RIGHT=$($OUTER split-window -h -PF '#{pane_id}' 'sleep 100') || exit 1 +for target in "$LEFT" "$RIGHT"; do + $OUTER respawn-pane -k -t "$target" "$INNER attach-session -t inner" || + exit 1 +done +wait_for_clients + +NAME1=$($OUTER display-message -p -t "$LEFT" '#{pane_tty}') || exit 1 +NAME2=$($OUTER display-message -p -t "$RIGHT" '#{pane_tty}') || exit 1 + +# Disable periodic status updates above and trigger damage without a command +# that also requests a status redraw. Each client must keep its own title. +$INNER refresh-client -t "$NAME1" || exit 1 +$INNER refresh-client -t "$NAME2" || exit 1 +sleep 0.5 +for phase in 0 1 2; do + if [ "$phase" -ne 0 ]; then + : >"$DIR/trigger-$phase" + wait_for_marker "$LEFT" "DAMAGE$phase" + wait_for_marker "$RIGHT" "DAMAGE$phase" + sleep 0.2 + fi + for target in "$LEFT" "$RIGHT"; do + if [ "$target" = "$LEFT" ]; then + name=$NAME1 + other=$NAME2 + else + name=$NAME2 + other=$NAME1 + fi + $OUTER capture-pane -p -S 5 -E 10 -t "$target" \ + >"$CAPTURE" || exit 1 + grep -Fq "CLIENT=<$name>" "$CAPTURE" || + fail "phase $phase: missing $name's pane status" + if grep -Fq "CLIENT=<$other>" "$CAPTURE"; then + fail "phase $phase: $name received $other's pane status" + fi + done +done + +# Leave just one client so a cache keyed only by client would remain stale. +$OUTER respawn-pane -k -t "$RIGHT" 'sleep 100' || exit 1 +i=0 +while [ "$($INNER list-clients | wc -l)" -ne 1 ]; do + [ "$i" -lt 50 ] || fail "second client did not detach" + sleep 0.1 + i=$((i + 1)) +done +$INNER set-environment -g TEST_STATUS_VALUE initial || exit 1 +$INNER set -g pane-border-format 'VALUE=#{TEST_STATUS_VALUE}' || exit 1 +$INNER refresh-client -t "$NAME1" || exit 1 +wait_for_marker "$LEFT" VALUE=initial +for phase in 3 4; do + # Changing the environment does not itself request a status redraw. + $INNER set-environment -g TEST_STATUS_VALUE "phase$phase" || exit 1 + : >"$DIR/trigger-$phase" + wait_for_marker "$LEFT" "DAMAGE$phase" + wait_for_marker "$LEFT" "VALUE=phase$phase" +done + +exit 0 diff --git a/regress/floating-pane-status-format.sh b/regress/floating-pane-status-format.sh new file mode 100755 index 000000000..9974b7515 --- /dev/null +++ b/regress/floating-pane-status-format.sh @@ -0,0 +1,102 @@ +#!/bin/sh + +# Resizing a floating pane must refresh session status formats which depend on +# its geometry, not only the pane scene and borders. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lfloating-status-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lfloating-status-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has_status() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + tail -1 "$CAPTURE" | grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client status did not show $marker" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +$INNER new-session -d -s inner -x 50 -y 12 'sleep 100' || exit 1 +FLOAT=$($INNER new-pane -PF '#{pane_id}' -x 10 -y 5 -X 5 -Y 3 \ + 'sleep 100') || fail "could not create floating pane" +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g status on || exit 1 +$INNER set-option -g status-position bottom || exit 1 +$INNER set-option -g status-left 'WIDTH=#{pane_width}' || exit 1 +$INNER set-option -g status-right '' || exit 1 +$INNER set-option -g status-interval 0 || exit 1 + +$OUTER new-session -d -s outer -x 50 -y 12 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lfloating-status-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +OLD_WIDTH=$($INNER display-message -p -t "$FLOAT" '#{pane_width}') +wait_outer_has_status "WIDTH=$OLD_WIDTH" + +RIGHT=$($INNER display-message -p -t "$FLOAT" '#{pane_right}') +TOP=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +X=$((RIGHT + 2)) +Y=$((TOP + 2)) + +# Grab the right frame and enlarge the floating pane. +mouse 0 "$X" "$Y" M +mouse 32 "$((X + 1))" "$Y" M +mouse 32 "$((X + 8))" "$Y" M +mouse 0 "$((X + 8))" "$Y" m + +NEW_WIDTH=$($INNER display-message -p -t "$FLOAT" '#{pane_width}') +[ "$NEW_WIDTH" -ne "$OLD_WIDTH" ] || fail "floating pane was not resized" +wait_outer_has_status "WIDTH=$NEW_WIDTH" + +exit 0 diff --git a/regress/input-nowrap-collect.sh b/regress/input-nowrap-collect.sh new file mode 100644 index 000000000..93496fb68 --- /dev/null +++ b/regress/input-nowrap-collect.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +. ./input-common.inc + +# The shortest collected prefix must leave the final cell at the right edge. +start_pane narrow 2 3 '\033[?7lABC' +check_capture narrow 'AC' +check_cursor narrow '1,0' +check_flags narrow '- AC' + +# REP may fill the final cell, but subsequent text still overwrites it. +start_pane repeat 5 3 '\033[?7lA\033[9bYZ' +check_capture repeat 'AAAAZ' +check_cursor repeat '4,0' +check_flags repeat '- AAAAZ' + +# Style changes must flush collected cells without moving the final cursor. +start_pane styled 5 3 '\033[?7l\033[31mAB\033[32mCDE\033[34mZ' +check_capture styled 'ABCDZ' +check_cursor styled '4,0' +check_raw_matches styled \ + 'C 0,0 data=\(1,1,A\).*fg=red\[1\] ' \ + 'C 0,2 data=\(1,1,C\).*fg=green\[2\] ' \ + 'C 0,4 data=\(1,1,Z\).*fg=blue\[4\] ' + +# Overwriting a wide character's padding must clear the old leading cell. +start_pane padding 8 3 '\033[?7lA\343\201\202B\r\033[2CX' +check_capture padding 'A XB' +check_cursor padding '3,0' +check_raw_matches padding \ + 'C 0,1 data=\(1,1, \) flags=NONE\[0\]' \ + 'C 0,2 data=\(1,1,X\) flags=NONE\[0\]' + +# Insert mode must shift existing cells even when autowrap is disabled. +start_pane insert 5 3 '\033[?7lABCD\r\033[C\033[4hXY\033[4l' +check_capture insert 'AXYBC' +check_cursor insert '3,0' + +# Re-enabling wrap after a collected prefix must retain the wrap boundary. +start_pane toggle 5 3 '\033[?7lABCD\033[?7hEF' +check_capture toggle 'ABCDE +F' +check_cursor toggle '1,1' +check_flags toggle 'W ABCDE +- F' + +exit $exit_status diff --git a/regress/menu-live-output.sh b/regress/menu-live-output.sh new file mode 100644 index 000000000..3e127ea32 --- /dev/null +++ b/regress/menu-live-output.sh @@ -0,0 +1,164 @@ +#!/bin/sh + +# Inspect an attached client's terminal while panes write behind a menu. +# Incremental output must agree with the scene renderer, including floating +# panes and a client viewport smaller than the window. + +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" +TMP=$(mktemp -d) + +cleanup() { + $TMUX kill-server 2>/dev/null + $TMUX2 kill-server 2>/dev/null + rm -f "$TMP/output" "$TMP/float" "$TMP/actual" "$TMP/expected" + rmdir "$TMP" +} +trap cleanup 0 1 15 + +fail() { + echo "$*" >&2 + exit 1 +} + +# A full scene redraw is the reference for each incremental update. Check an +# uncovered marker before refreshing, so a frozen pane cannot pass the test. +check_update() { + printf '%b' "$2" >&3 + printf '\033[1;1H\033[2K%s' "$1" >&3 + sleep 1 + $TMUX capturep -p >"$TMP/actual" || exit 1 + grep -q "^$1" "$TMP/actual" || fail "$1: pane stopped updating" + $TMUX capturep -pe >"$TMP/actual" || exit 1 + $TMUX2 refresh-client || exit 1 + sleep 1 + $TMUX capturep -pe >"$TMP/expected" || exit 1 + if ! cmp -s "$TMP/actual" "$TMP/expected"; then + diff -u "$TMP/expected" "$TMP/actual" >&2 + fail "$1: incremental output differs from scene redraw" + fi +} + +menu() { + $TMUX2 display-menu -b simple -T live -C 0 "$@" \ + alpha a "" beta b "" || exit 1 + sleep 1 + $TMUX capturep -p >"$TMP/actual" || exit 1 + grep -q alpha "$TMP/actual" || fail "menu did not appear" +} + +mkfifo "$TMP/output" "$TMP/float" || exit 1 +$TMUX2 new -d -x40 -y14 "exec cat '$TMP/output'" || exit 1 +$TMUX2 set -g status off || exit 1 +$TMUX2 set -g window-size manual || exit 1 +$TMUX2 resizew -x40 -y14 || exit 1 +$TMUX2 set -as terminal-features ',screen:clipboard' || exit 1 +$TMUX2 set -g set-clipboard on || exit 1 +exec 3>"$TMP/output" + +$TMUX new -d -x40 -y14 "$TMUX2 attach" || exit 1 +$TMUX set -g status off || exit 1 +$TMUX set -g window-size manual || exit 1 +$TMUX resizew -x40 -y14 || exit 1 +$TMUX set -g set-clipboard on || exit 1 +sleep 1 + +menu -x6 -y8 +check_update text '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' +check_update clear-line '\033[6;1H\033[2K' +check_update clear-end-line '\033[6;1H\033[K' +check_update clear-start-line '\033[6;40H\033[1K' +check_update erase-character '\033[6;1H\033[40X' +check_update clear-screen '\033[2J' +check_update clear-end-screen '\033[3;1H\033[J' +check_update clear-start-screen '\033[10;40H\033[1J' +check_update scroll '\033[14;1Hone\r\ntwo\r\nthree\r\nfour\r\n' +check_update reverse-scroll '\033[1;1H\033M' +check_update insert-character '\033[6;1Habcdefghijklmnopqrstuvwxyz\033[6;1H\033[3@' +check_update delete-character '\033[6;1Habcdefghijklmnopqrstuvwxyz\033[6;1H\033[3P' +check_update insert-line '\033[5;1H\033[2L' +check_update delete-line '\033[5;1H\033[2M' +check_update wide-text '\033[6;6H\347\225\214\347\225\214\347\225\214' +check_update overwrite-wide '\033[6;5H\347\225\214\033[6;6Hx' +check_update insert-mode '\033[6;1H\033[4habc\033[4l' +check_update sync '\033[?2026h\033[6;1Habcdefghijklmnopqrstuvwxyz\033[?2026l' +check_update sync-scroll '\033[?2026h\033[14;1Hone\r\ntwo\r\nthree\r\n\033[?2026l' + +# Text on the right of the menu crosses the client viewport edge. A terminal +# fallback which redraws the entire pane line would overwrite the menu. +$TMUX2 resizew -x60 -y14 || exit 1 +sleep 1 +check_update viewport '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwx' +$TMUX2 resizew -x40 -y14 || exit 1 + +# Window coordinates must still be used when the status line is above it. +$TMUX2 set -g status on || exit 1 +$TMUX2 set -g status-position top || exit 1 +$TMUX2 set -g status-format[0] '' || exit 1 +$TMUX2 resizew -y13 || exit 1 +sleep 1 +check_update top-status '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' +$TMUX2 set -g status off || exit 1 +$TMUX2 resizew -y14 || exit 1 + +# Both the menu and floating panes obscure the base pane. The menu also +# obscures incremental output from the floating pane itself. +FLOAT=$($TMUX2 new-pane -dPF '#{pane_id}' -x24 -y8 -X4 -Y2 \ + "exec cat '$TMP/float'") || exit 1 +exec 4>"$TMP/float" +sleep 1 +check_update behind-float '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' +printf '\033[4;1HABCDEFGHIJKLMNOPQRSTUVWX' >&4 +check_update float-text '' +printf '\033[4;1H\033[4hxyz\033[4l' >&4 +check_update float-insert '' +printf '\033[8;1Hone\r\ntwo\r\nthree\r\n' >&4 +check_update float-scroll '' +$TMUX2 kill-pane -t "$FLOAT" || exit 1 + +# Without a menu, the same whole-line and insert-mode paths must continue to +# respect floating panes. This also exercises split visible ranges. +$TMUX send Escape || exit 1 +FLOAT=$($TMUX2 new-pane -dPF '#{pane_id}' -x12 -y6 -X6 -Y3 \ + 'exec sleep 100') || exit 1 +$TMUX2 resizew -x60 -y14 || exit 1 +sleep 1 +check_update float-viewport '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwx' +check_update float-insert-mode '\033[6;1H\033[4habc\033[4l' +$TMUX2 kill-pane -t "$FLOAT" || exit 1 +$TMUX2 resizew -x40 -y14 || exit 1 + +# A menu covering an entire line leaves an empty visible range. Borderless +# menus and menus larger than the window must use their actual screen size. +menu -b none -x0 -y8 +check_update borderless '\033[7;1Habcdefghijklmnopqrstuvwxyz0123456789' +$TMUX send Escape || exit 1 +$TMUX2 display-menu -b simple -T live -C 0 -x0 -y8 \ + 'This menu is wider than the whole window' a '' beta b '' || exit 1 +sleep 1 +check_update full-width '\033[6;1Habcdefghijklmnopqrstuvwxyz0123456789' + +# Nonvisual output is still delivered while a menu is open. +printf '\033]52;c;bWVudS1jbGlwYm9hcmQ=\007' >&3 +sleep 1 +[ "$($TMUX show-buffer 2>/dev/null)" = menu-clipboard ] || + fail "menu blocked the clipboard update" + +# The pane grid must keep updating under the menu, both for capture-pane and +# for the redraw when the menu closes. +printf '\033[2J\033[6;7Hlatest content' >&3 +sleep 1 +$TMUX2 capturep -p -t%0 >"$TMP/actual" || exit 1 +grep -q 'latest content' "$TMP/actual" || fail "underlying pane is stale" +$TMUX send Escape || exit 1 +sleep 1 +$TMUX capturep -p >"$TMP/actual" || exit 1 +grep -q 'latest content' "$TMP/actual" || fail "menu close left stale content" + +exit 0 diff --git a/regress/prompt-words-history.sh b/regress/prompt-words-history.sh index 6aaa5689c..e3a6882dd 100644 --- a/regress/prompt-words-history.sh +++ b/regress/prompt-words-history.sh @@ -131,6 +131,14 @@ printf '%s\n' "$captured" | grep -Fq 'show-buffer' || printf '%s\n' "$captured" | grep -Fq 'show-environment' || fail "ambiguous completion list was incomplete" $OUTER send-keys Escape || exit 1 +# Wait until Escape has closed the prompt before sending another Meta key. +# Otherwise the two can be parsed together as a single escape sequence. +i=0 +while capture | grep -Fq '(word)'; do + [ "$i" -lt 50 ] || fail "completion prompt did not close" + sleep 0.1 + i=$((i + 1)) +done # Add entries to both history rings through real prompts. bind_prompt 'history-command' diff --git a/regress/redraw-damage-accumulate.sh b/regress/redraw-damage-accumulate.sh new file mode 100644 index 000000000..3577cf603 --- /dev/null +++ b/regress/redraw-damage-accumulate.sh @@ -0,0 +1,113 @@ +#!/bin/sh + +# Many wrapped rows crossing a panned viewport produce disjoint damage. +# Every row must survive the rectangle-count limit and subsequent merging. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Laccumulate-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Laccumulate-outer-$$ -f/dev/null" +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 +fail() +{ + echo "$*" >&2 + exit 1 +} +wait_marker() +{ + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$DIR/capture" || exit 1 + grep -q "$1" "$DIR/capture" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client did not receive $1" +} +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; +$| = 1; +for my $row (1 .. 80) { + printf "\e[%d;1HROW%02d-%s", $row, $row, 'abcdefghij' x 6; +} +print "\e[1;1H"; +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} +my $output = ''; +# Each wrapped second row needs a separate damage rectangle. Keep the +# batch below the PTY read size so all 18 arrive in the same input pass. +for my $region (0 .. 17) { + my $top = 1 + $region * 4; + $output .= "\e[$top;1H" . ('A' x 80) . ('B' x 24) . + sprintf('DAMAGE%02d', $region) . ('B' x 48); +} +$output .= "\e[80;21HDONE"; +syswrite STDOUT, $output; +while (!-e "$ENV{TRIGGER}-merge") { + select undef, undef, undef, 0.01; +} +# Returning from the alternate screen requests a full-pane rectangle. A +# later wrapped-row rectangle overlaps it and must not shrink that damage. +$output = "\e[?1049h\e[40;21HALT-VISIBLE\e[?1049l"; +$output .= "\e[1;1H" . ('C' x 80) . ('D' x 24) . 'MERGE00' . ('D' x 49); +$output .= "\e[80;21HMERGED"; +syswrite STDOUT, $output; +sleep 100; +PERL +$INNER new-session -d -s inner -x 80 -y 80 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -as terminal-features ',screen:sync' || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$OUTER new-session -d -s outer -x 40 -y 80 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +$OUTER respawn-pane -k -t outer:0.0 "$INNER attach -t inner" || exit 1 +wait_marker ROW80 +CLIENT=$($OUTER display -p -t outer:0.0 '#{pane_tty}') || exit 1 +$INNER refresh-client -t "$CLIENT" -R 20 || exit 1 +# Keep the emitter inactive so its writes use a synchronized frame. Otherwise +# its own queued output can force a full redraw and hide lost rectangles. +$INNER new-pane -x 6 -y 3 -X 65 -Y 74 'sleep 100' || exit 1 +sleep 0.2 +: >"$DIR/trigger" +wait_marker DONE +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/before" || exit 1 +region=0 +while [ "$region" -lt 18 ]; do + marker=$(printf 'DAMAGE%02d' "$region") + sed -n "$((2 + region * 4))p" "$DIR/before" | grep -q "$marker" || + fail "missing damage for region $region" + region=$((region + 1)) +done +$INNER refresh-client -t "$CLIENT" || exit 1 +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/after" || exit 1 +diff -u "$DIR/before" "$DIR/after" || fail "accumulated redraw differs from full redraw" +: >"$DIR/trigger-merge" +wait_marker MERGED +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/before" || exit 1 +sed -n '2p' "$DIR/before" | grep -q MERGE00 || fail "merged damage lost the wrapped row" +$INNER refresh-client -t "$CLIENT" || exit 1 +sleep 0.2 +$OUTER capture-pane -p -t outer:0.0 >"$DIR/after" || exit 1 +diff -u "$DIR/before" "$DIR/after" || fail "merged redraw differs from full redraw" +exit 0 diff --git a/regress/redraw-damage-blocked-client.sh b/regress/redraw-damage-blocked-client.sh new file mode 100644 index 000000000..e9c15ef95 --- /dev/null +++ b/regress/redraw-damage-blocked-client.sh @@ -0,0 +1,140 @@ +#!/bin/sh + +# One terminal stops reading while another consumes floating-pane damage. +# The slow client must catch up after the window's shared damage is cleared. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lblocked-inner-$$ -f/dev/null" +FAST="$TEST_TMUX -Lblocked-fast-$$ -f/dev/null" +SLOW="$TEST_TMUX -Lblocked-slow-$$ -f/dev/null" +STOPPED= +cleanup() +{ + [ -n "$STOPPED" ] && kill -CONT "$STOPPED" 2>/dev/null + $FAST kill-server 2>/dev/null + $SLOW kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 +fail() +{ + echo "$*" >&2 + exit 1 +} +wait_marker() +{ + terminal=$1 + marker=$2 + i=0 + while [ "$i" -lt 100 ]; do + $terminal capture-pane -p >"$DIR/capture" || exit 1 + grep -q "$marker" "$DIR/capture" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client did not receive $marker" +} +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $FAST send-keys -l "$sequence" || exit 1 + sleep 0.2 +} +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; +$| = 1; +print 'READY'; +while (!-e "$ENV{TRIGGER}-flood") { + select undef, undef, undef, 0.01; +} +my $frame = 0; +while (!-e "$ENV{TRIGGER}-stop") { + my $output = ''; + for my $row (1 .. 20) { + $output .= "\e[$row;1H" . join('', map { chr(33 + ($_ + $frame) % 80) } 0 .. 77); + } + print $output; + $frame++; + select undef, undef, undef, 0.01; +} +for my $row (1 .. 20) { + printf "\e[%d;1HFINAL%02d-%s", $row, $row, '0123456789' x 7; +} +print "\e[20;1HDONE"; +sleep 100; +PERL +$INNER new-session -d -s inner -x 160 -y 80 \ + "TRIGGER='$DIR/trigger' perl '$DIR/emitter.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g mouse on || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +FLOAT=$($INNER new-pane -PF '#{pane_id}' -x 16 -y 5 -X 5 -Y 5 \ + 'printf FLOAT; exec sleep 100') || exit 1 +for terminal in "$FAST" "$SLOW"; do + $terminal new-session -d -x 160 -y 80 'sleep 100' || exit 1 + $terminal set -g status off || exit 1 + $terminal set -g window-size manual || exit 1 + $terminal set -g default-terminal screen || exit 1 + $terminal respawn-pane -k "$INNER attach -t inner" || exit 1 + wait_marker "$terminal" READY +done +FASTCLIENT=$($FAST display -p '#{pane_tty}') || exit 1 +SLOWCLIENT=$($SLOW display -p '#{pane_tty}') || exit 1 +SLOWPID=$($SLOW display -p '#{pid}') || exit 1 +before=$($INNER display -p -c "$FASTCLIENT" '#{client_written}') || exit 1 +slowbefore=$($INNER display -p -c "$SLOWCLIENT" '#{client_written}') || exit 1 +# The large terminal keeps this backlog below the discard threshold, so +# automatic recovery from discarded output cannot mask lost damage. +# Stop only our outer server, leaving its inner client attached to a PTY +# whose master is no longer read. This creates real terminal backpressure. +STOPPED=$SLOWPID +kill -STOP "$SLOWPID" || exit 1 +: >"$DIR/trigger-flood" +i=0 +while :; do + written=$($INNER display -p -c "$SLOWCLIENT" '#{client_written}') || exit 1 + fastwritten=$($INNER display -p -c "$FASTCLIENT" '#{client_written}') || exit 1 + # More than a PTY can buffer has been queued for the stopped terminal, + # while the other terminal is still receiving the same output. + [ "$written" -gt "$((slowbefore + 65536))" ] && + [ "$fastwritten" -gt "$((before + 65536))" ] && break + [ "$i" -lt 100 ] || fail "slow client did not become blocked" + sleep 0.1 + i=$((i + 1)) +done +: >"$DIR/trigger-stop" +wait_marker "$FAST" DONE +[ "$($INNER display -p -c "$FASTCLIENT" '#{client_discarded}')" -eq 0 ] || + fail "fast client also became blocked" +mouse 0 12 6 M +mouse 32 42 6 M +mouse 0 42 6 m +[ "$($INNER display -p -t "$FLOAT" '#{pane_left}')" -eq 36 ] || fail "pane did not move" +$FAST capture-pane -p >"$DIR/fast-before" || exit 1 +# Ensure the fast client already restored the vacated frame. +sed -n '6p' "$DIR/fast-before" | grep -q '^FINAL06-0123456789' || + fail "fast client did not restore old footprint" +kill -CONT "$SLOWPID" || exit 1 +STOPPED= +wait_marker "$SLOW" DONE +# Allow queued terminal output and deferred redraws to drain. +sleep 0.5 +[ "$($INNER display -p -c "$SLOWCLIENT" '#{client_discarded}')" -eq 0 ] || + fail "discard recovery could mask lost deferred damage" +$SLOW capture-pane -p >"$DIR/slow-before" || exit 1 +diff -u "$DIR/fast-before" "$DIR/slow-before" || fail "slow client did not catch up" +$INNER refresh-client -t "$FASTCLIENT" || exit 1 +sleep 0.2 +$FAST capture-pane -p >"$DIR/after" || exit 1 +diff -u "$DIR/fast-before" "$DIR/after" || fail "damage redraw differed from full redraw" +exit 0 diff --git a/regress/redraw-damage-only.sh b/regress/redraw-damage-only.sh new file mode 100755 index 000000000..a01824019 --- /dev/null +++ b/regress/redraw-damage-only.sh @@ -0,0 +1,112 @@ +#!/bin/sh + +# Check a redraw callback which has no accompanying client redraw flags. A +# wrapped row crossing a panned viewport cannot use the direct tty path. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Ldamage-only-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Ldamage-only-outer-$$ -f/dev/null" +EMITTER=$DIR/emitter.pl +TRIGGER=$DIR/trigger +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_inner_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t inner:0.0 2>/dev/null | + grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane did not contain $marker" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +for my $row (1 .. 12) { + print "\e[$row;1H", 'o' x 79; +} +print "\e[1;1H"; +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} + +my $second = ('B' x 24) . 'DAMAGE-ONLY' . ('B' x 45); +print "\e[5;1H", ('A' x 80), $second; +sleep 100; +PERL + +$INNER new-session -d -s inner -x 80 -y 12 \ + "TRIGGER='$TRIGGER' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 12 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Ldamage-only-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_client +$INNER refresh-client -t "$CLIENT" -R 20 || exit 1 +wait_outer_has oooooooooo + +: >"$TRIGGER" +wait_inner_has DAMAGE-ONLY +wait_outer_has DAMAGE-ONLY + +exit 0 diff --git a/regress/redraw-damage-viewports.sh b/regress/redraw-damage-viewports.sh new file mode 100644 index 000000000..2cfe1cffc --- /dev/null +++ b/regress/redraw-damage-viewports.sh @@ -0,0 +1,112 @@ +#!/bin/sh + +# Moving and interactively resizing floating panes must restore both clients, +# including a smaller client panned horizontally and vertically. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lviewports-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lviewports-outer-$$ -f/dev/null" + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 +fail() +{ + echo "$*" >&2 + exit 1 +} +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t wide:0.0 -l "$sequence" || exit 1 + sleep 0.2 +} +assert_scene() +{ + # Compare what each terminal actually received with a fresh full redraw. + # Capture both first: refreshing one client must not repair the other. + for target in wide small; do + $OUTER capture-pane -p -t "$target:0.0" >"$DIR/$target-before" || exit 1 + done + $INNER refresh-client -t "$WIDE" || exit 1 + $INNER refresh-client -t "$SMALL" || exit 1 + sleep 0.2 + for target in wide small; do + $OUTER capture-pane -p -t "$target:0.0" >"$DIR/$target-after" || exit 1 + diff -u "$DIR/$target-before" "$DIR/$target-after" || + fail "$1: $target client differed from a full redraw" + done +} + +cat >"$DIR/background.pl" <<'PERL' +$| = 1; +for my $row (1 .. 24) { + printf "\e[%d;1HROW%02d-", $row, $row; + print '0123456789' x 7; +} +sleep 100; +PERL +$INNER new-session -d -s inner -x 80 -y 24 "perl '$DIR/background.pl'" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g automatic-rename off || exit 1 +$INNER set -g status-interval 0 || exit 1 +$INNER set -g mouse on || exit 1 +$INNER set -g default-command 'sleep 100' || exit 1 +$INNER set -g pane-border-lines simple || exit 1 +$OUTER new-session -d -s wide -x 80 -y 24 'sleep 100' || exit 1 +$OUTER set -g status off || exit 1 +$OUTER set -g window-size manual || exit 1 +$OUTER set -g default-terminal screen || exit 1 +$OUTER new-session -d -s small -x 40 -y 12 'sleep 100' || exit 1 +for target in wide small; do + $OUTER respawn-pane -k -t "$target:0.0" "$INNER attach -t inner" || exit 1 +done +i=0 +while [ "$($INNER list-clients | wc -l)" -ne 2 ]; do + [ "$i" -lt 50 ] || fail "two clients did not attach" + sleep 0.1 + i=$((i + 1)) +done +WIDE=$($OUTER display -p -t wide:0.0 '#{pane_tty}') || exit 1 +SMALL=$($OUTER display -p -t small:0.0 '#{pane_tty}') || exit 1 +$INNER refresh-client -t "$SMALL" -R 20 || exit 1 +$INNER refresh-client -t "$SMALL" -D 6 || exit 1 +FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 16 -y 6 -X 25 -Y 9) || exit 1 +sleep 0.3 +assert_scene initial +# Confirm that the two clients really have different viewports. +[ "$(head -1 "$DIR/wide-before" | cut -c1-6)" = ROW01- ] || fail "wrong wide viewport" +[ "$(head -1 "$DIR/small-before" | cut -c1-6)" != ROW01- ] || fail "small client was not panned" + +# Meta-drag the body using move-pane -M, including a partially clipped position. +mouse 8 30 12 M +mouse 40 48 15 M +[ "$($INNER display -p -t "$FLOAT" '#{pane_left}')" -eq 44 ] || fail "Meta-drag did not move pane" +assert_scene move-right +mouse 40 28 10 M +assert_scene move-back +mouse 8 28 10 m + +# Ctrl-drag creates a new floating pane, then changes its size while held. +mouse 16 52 14 M +mouse 48 75 23 M +NEW=$($INNER display -p '#{pane_id}') || exit 1 +[ "$NEW" != "$FLOAT" ] || fail "Ctrl-drag did not create a pane" +[ "$($INNER display -p -t "$NEW" '#{pane_floating_flag}')" -eq 1 ] || fail "new pane is not floating" +assert_scene create +mouse 48 64 19 M +[ "$($INNER display -p -t "$NEW" '#{pane_width}')" -eq 11 ] || fail "Ctrl-drag did not shrink pane" +assert_scene shrink +mouse 16 64 19 m +assert_scene release +exit 0 diff --git a/regress/redraw-multiclient.sh b/regress/redraw-multiclient.sh new file mode 100755 index 000000000..cdcbe01ff --- /dev/null +++ b/regress/redraw-multiclient.sh @@ -0,0 +1,186 @@ +#!/bin/sh + +# Redraw a moved floating pane on both attached clients viewing the same +# window. Window redraw work must not be consumed by only one client. +# +# Uses ASCII pane borders (rather than the default UTF-8 box-drawing) because +# this test nests a real tmux client inside another tmux's pane to get a +# genuine terminal to capture from; that nested-tmux relay has been observed +# to mis-render a cell that previously held a multi-byte UTF-8 border +# character being overwritten later by plain content, on the outer instance's +# own interpretation, independent of anything the inner tmux sends. That is a +# nested-test-harness artifact, not a real tmux bug - confirmed by replaying +# the exact same drag sequence against a real terminal (xterm), where it +# never reproduces. ASCII borders avoid the artifact entirely. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lredraw-multi-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lredraw-multi-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_clients() +{ + i=0 + while [ "$i" -lt 50 ]; do + count=$($INNER list-clients 2>/dev/null | wc -l) + [ "$count" -eq 2 ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "two inner clients did not attach" +} + +wait_outer_has() +{ + target=$1 + marker=$2 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t "$target" >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer pane $target did not show $marker" +} + +wait_float_left() +{ + comparison=$1 + limit=$2 + i=0 + while [ "$i" -lt 50 ]; do + left=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') + if [ "$comparison" = gt ] && [ "$left" -gt "$limit" ]; then + return 0 + fi + if [ "$comparison" = lt ] && [ "$left" -lt "$limit" ]; then + return 0 + fi + sleep 0.1 + i=$((i + 1)) + done + fail "floating pane did not move" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$2" "$3" "$4" "$5") + $OUTER send-keys -t "$1" -l "$sequence" || exit 1 + sleep 0.1 +} + +drag_float() +{ + target=$1 + startcol=$2 + startrow=$3 + endcol=$4 + mouse "$target" 0 "$startcol" "$startrow" M + mouse "$target" 32 "$endcol" "$startrow" M + mouse "$target" 0 "$endcol" "$startrow" m +} + +assert_scene() +{ + target=$1 + base=$2 + firstcol=$3 + lastcol=$4 + + $OUTER capture-pane -p -t "$target" >"$CAPTURE" || exit 1 + + # With ASCII (simple) borders every corner and junction is the same + # '+', so one rectangular floating pane always draws exactly 4 of + # them; more means a stale frame was left behind somewhere. + corners=$(grep -o '+' "$CAPTURE" | wc -l) + [ "$corners" -eq 4 ] || + fail "outer pane $target had $corners floating frames" + + sed -n '6,11p' "$base" | cut -c"$firstcol-$lastcol" >"$DIR/want" + sed -n '6,11p' "$CAPTURE" | cut -c"$firstcol-$lastcol" >"$DIR/got" + cmp -s "$DIR/want" "$DIR/got" || + fail "outer pane $target did not restore the old floating area" +} + +C="sh -c 'i=0; while [ \$i -lt 20 ]; do printf \"\\033[%d;1HBG-ROW-%02d-abcdefghijklmnopqrstuvwxyz0123456789\" \$((i + 1)) \$i; i=\$((i + 1)); done; exec sleep 100'" + +$INNER new-session -d -s inner -x 60 -y 20 "$C" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g default-command 'sleep 100' || exit 1 +$INNER set-option -g pane-border-lines simple || exit 1 + +$OUTER new-session -d -s outer -x 121 -y 20 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen || exit 1 +$OUTER split-window -h -t outer:0.0 'sleep 100' || exit 1 + +PANES=$($OUTER list-panes -t outer:0 -F '#{pane_id} #{pane_left}') +LEFT=$(echo "$PANES" | sort -k2 -n | head -1 | cut -d' ' -f1) +RIGHT=$(echo "$PANES" | sort -k2 -n | tail -1 | cut -d' ' -f1) +[ -n "$LEFT" ] && [ -n "$RIGHT" ] || fail "could not find outer panes" + +$OUTER respawn-pane -k -t "$LEFT" \ + "$TEST_TMUX -Lredraw-multi-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 +$OUTER respawn-pane -k -t "$RIGHT" \ + "$TEST_TMUX -Lredraw-multi-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_for_clients +wait_outer_has "$LEFT" BG-ROW-19 +wait_outer_has "$RIGHT" BG-ROW-19 +$OUTER capture-pane -p -t "$LEFT" >"$DIR/base-left" || exit 1 +$OUTER capture-pane -p -t "$RIGHT" >"$DIR/base-right" || exit 1 + +FLOAT=$($INNER new-pane -dPF '#{pane_id}' -x 16 -y 5 -X 5 -Y 5) || + fail "could not create floating pane" +wait_outer_has "$LEFT" '+' +wait_outer_has "$RIGHT" '+' + +FTOP=$($INNER display-message -p -t "$FLOAT" '#{pane_top}') +FLEFT=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +FWIDTH=$($INNER display-message -p -t "$FLOAT" '#{pane_width}') +GRABCOL=$((FLEFT + FWIDTH / 2 + 1)) + +# Move right through one client and require both clients to restore the old +# left-hand footprint. +drag_float "$LEFT" "$GRABCOL" "$FTOP" $((GRABCOL + 30)) +wait_float_left gt 30 +assert_scene "$LEFT" "$DIR/base-left" 1 20 +assert_scene "$RIGHT" "$DIR/base-right" 1 20 + +# Move back through the other client and check the old right-hand footprint. +FLEFT=$($INNER display-message -p -t "$FLOAT" '#{pane_left}') +GRABCOL=$((FLEFT + FWIDTH / 2 + 1)) +drag_float "$RIGHT" "$GRABCOL" "$FTOP" $((GRABCOL - 30)) +wait_float_left lt 10 +assert_scene "$LEFT" "$DIR/base-left" 35 60 +assert_scene "$RIGHT" "$DIR/base-right" 35 60 + +exit 0 diff --git a/regress/redraw-screen-write.sh b/regress/redraw-screen-write.sh new file mode 100755 index 000000000..0d007f609 --- /dev/null +++ b/regress/redraw-screen-write.sh @@ -0,0 +1,208 @@ +#!/bin/sh + +# Check that full and region screen-write fallbacks update an attached client, +# not only tmux's internal pane grid. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +EMITTER=$DIR/emitter.pl +CAPTURE=$DIR/capture +INNER= +OUTER= +N=0 + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + [ -n "$OUTER" ] && $OUTER kill-server 2>/dev/null + [ -n "$INNER" ] && $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +wait_outer_lacks() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" || return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client still showed $marker" +} + +wait_inner_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t inner:0.0 2>/dev/null | + grep -q "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane did not contain $marker" +} + +wait_inner_lacks() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t inner:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" || return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner pane still contained $marker" +} + +setup() +{ + mode=$1 + [ -n "$OUTER" ] && $OUTER kill-server 2>/dev/null + [ -n "$INNER" ] && $INNER kill-server 2>/dev/null + N=$((N + 1)) + INNER="$TEST_TMUX -Lredraw-write-inner-$$-$N -f/dev/null" + OUTER="$TEST_TMUX -Lredraw-write-outer-$$-$N -f/dev/null" + + $INNER new-session -d -s inner -x 40 -y 12 \ + "MODE=$mode READY='$DIR/ready-$N' TRIGGER='$DIR/trigger-$N' perl '$EMITTER'" || + exit 1 + $INNER set-option -g status off || exit 1 + $INNER set-option -g window-size manual || exit 1 + + $OUTER new-session -d -s outer -x 40 -y 12 'sleep 100' || exit 1 + $OUTER set-option -g status off || exit 1 + $OUTER set-option -g window-size manual || exit 1 + $OUTER set-option -g default-terminal screen || exit 1 + $OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lredraw-write-inner-$$-$N -f/dev/null attach-session -t inner" || + exit 1 +} + +trigger() +{ + : >"$DIR/trigger-$N-${1:-1}" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +$| = 1; +my $mode = $ENV{MODE}; +my $ready = $ENV{READY}; +my $trigger = $ENV{TRIGGER}; + +sub fill_screen { + my ($prefix) = @_; + print "\e[2J\e[H"; + for my $row (0 .. 11) { + printf "\e[%d;1H%s-ROW-%02d", $row + 1, $prefix, $row; + } +} + +if ($mode eq 'ris') { + fill_screen('RIS'); +} elsif ($mode eq 'alternate') { + fill_screen('BASE'); +} elsif ($mode eq 'scroll') { + fill_screen('SCROLL'); +} else { + die "unknown mode $mode\n"; +} + +open my $fh, '>', $ready or die "$ready: $!\n"; +close $fh; +while (!-e "$trigger-1") { + select undef, undef, undef, 0.01; +} + +if ($mode eq 'ris') { + print "\ec"; +} elsif ($mode eq 'alternate') { + print "\e[?1049h"; + fill_screen('ALT'); + while (!-e "$trigger-2") { + select undef, undef, undef, 0.01; + } + print "\e[?1049l"; +} else { + print "\e[12;1H\r\nSCROLL-NEW"; +} +sleep 100; +PERL + +# RIS clears the complete screen. The source pane and attached client must both +# lose every old row. +setup ris +wait_outer_has RIS-ROW-11 +trigger +wait_inner_lacks RIS-ROW +wait_outer_lacks RIS-ROW + +# Leaving the alternate screen restores every row of the base screen. +setup alternate +wait_outer_has BASE-ROW-11 +trigger +wait_inner_has ALT-ROW-11 +wait_outer_has ALT-ROW-11 +wait_outer_lacks BASE-ROW +trigger 2 +wait_inner_has BASE-ROW-11 +wait_outer_has BASE-ROW-11 +wait_outer_lacks ALT-ROW + +# Scrolling a pane which is narrower than the terminal redraws its complete +# region. Check the physical client row by row after the source grid shifts. +setup scroll +$INNER split-window -h -t inner:0 'sleep 100' || exit 1 +wait_outer_has SCROLL-ROW-11 +trigger +wait_inner_has SCROLL-NEW +wait_outer_has SCROLL-NEW +row=1 +while [ "$row" -le 11 ]; do + expected=$(printf 'SCROLL-ROW-%02d' "$row") + actual=$(sed -n "${row}p" "$CAPTURE") + case "$actual" in + "$expected"*) ;; + *) fail "outer row $row was not redrawn as $expected" ;; + esac + row=$((row + 1)) +done +actual=$(sed -n '12p' "$CAPTURE") +case "$actual" in +SCROLL-NEW*) ;; +*) fail "outer bottom row was not redrawn as SCROLL-NEW" ;; +esac + +exit 0 diff --git a/regress/screen-redraw-floating.sh b/regress/screen-redraw-floating.sh index c3b65a970..f87223ae7 100644 --- a/regress/screen-redraw-floating.sh +++ b/regress/screen-redraw-floating.sh @@ -43,8 +43,10 @@ compare() { # new_scene : fresh inner window of the given window size. new_scene() { - $TMUX2 neww -d "sh -c 'printf base; exec sleep 100'" || exit 1 - $TMUX2 selectw -t:\$ || exit 1 + window=$($TMUX2 neww -dP -F '#{window_id}' \ + "sh -c 'printf base; exec sleep 100'") || exit 1 + $TMUX2 selectw -t"$window" || exit 1 + $TMUX2 killw -a -t"$window" || exit 1 $TMUX2 resizew -x$1 -y$2 || exit 1 } diff --git a/regress/swap-pane-zoom-error.sh b/regress/swap-pane-zoom-error.sh new file mode 100644 index 000000000..0d85f72f1 --- /dev/null +++ b/regress/swap-pane-zoom-error.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# swap-pane refusing a floating pane must leave the zoom alone + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +TMP=$(mktemp) +trap "rm -f $TMP; $TMUX kill-server 2>/dev/null" 0 1 15 + +$TMUX new -d +$TMUX splitw -d +$TMUX new-pane -d + +echo $($TMUX lsp -F'#{pane_id}:#{pane_floating_flag}') >$TMP +(echo "%0:0 %1:0 %2:1"|cmp -s - $TMP) || exit 1 + +$TMUX resize-pane -Z -t %0 +[ "$($TMUX display -p '#{window_zoomed_flag}')" = 1 ] || exit 1 + +$TMUX swap-pane -Z -D -t %2 +[ "$?" = 0 ] && exit 1 +[ "$($TMUX display -p '#{window_zoomed_flag}')" = 1 ] || exit 1 + +$TMUX swap-pane -Z -U -t %2 +[ "$?" = 0 ] && exit 1 +[ "$($TMUX display -p '#{window_zoomed_flag}')" = 1 ] || exit 1 + +# A swap that is allowed still happens. +$TMUX swap-pane -D -t %0 || exit 1 +echo $($TMUX lsp -F'#{pane_id}') >$TMP +(echo "%1 %0 %2"|cmp -s - $TMP) || exit 1 + +$TMUX kill-server 2>/dev/null + +exit 0 diff --git a/regress/switch-client-redraw.sh b/regress/switch-client-redraw.sh new file mode 100755 index 000000000..a03c382dd --- /dev/null +++ b/regress/switch-client-redraw.sh @@ -0,0 +1,81 @@ +#!/bin/sh + +# Switching windows in the same session must redraw the attached client. The +# session object is shared, so its current winlink cannot be compared after it +# has already been changed. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lswitch-redraw-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lswitch-redraw-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$CLIENT" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_outer_has() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + grep -q "$marker" "$CAPTURE" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "outer client did not show $marker" +} + +$INNER new-session -d -s inner -x 40 -y 8 \ + "printf '\033[2J\033[HA-WINDOW'; exec sleep 100" || exit 1 +$INNER new-window -d -t inner:1 \ + "printf '\033[2J\033[HB-WINDOW'; exec sleep 100" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 8 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lswitch-redraw-inner-$$ -f/dev/null attach-session -t inner:0" || + exit 1 + +wait_for_client +wait_outer_has A-WINDOW + +$INNER switch-client -c "$CLIENT" -t inner:1.0 || exit 1 +[ "$($INNER display-message -p -t inner '#{window_index}')" -eq 1 ] || + fail "server did not select window 1" +wait_outer_has B-WINDOW + +exit 0 diff --git a/regress/tty-margins-sb.sh b/regress/tty-margins-sb.sh new file mode 100755 index 000000000..6f573af92 --- /dev/null +++ b/regress/tty-margins-sb.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +# With left/right margins, a pane narrowed by a scrollbar can scroll without +# retransmitting its existing rows. Both paths must produce the same screen. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +cat >"$DIR/emitter.pl" <<'PERL' +use strict; +use warnings; +$| = 1; +print join("\r\n", map { "KEEP0$_" } 1 .. 6); +while (!-e $ENV{TRIGGER}) { + select undef, undef, undef, 0.01; +} +print "\r\nNEWROW"; +sleep 100; +PERL + +j=0 +for margins in on off; do + INNER="$TEST_TMUX -LA$j$$ -f/dev/null" + OUTER="$TEST_TMUX -LB$j$$ -f/dev/null" + + $INNER new-session -d -s inner -x 40 -y 6 \ + "TRIGGER='$DIR/trigger-$margins' perl '$DIR/emitter.pl'" || exit 1 + $INNER set -g status off || exit 1 + $INNER set -g status-interval 0 || exit 1 + $INNER set -g automatic-rename off || exit 1 + $INNER set -g window-size manual || exit 1 + $INNER set -g pane-scrollbars on || exit 1 + if [ "$margins" = on ]; then + $INNER set -as terminal-features ',screen-256color:margins' || exit 1 + fi + + $OUTER new-session -d -s outer -x 40 -y 6 'sleep 100' || exit 1 + $OUTER set -g status off || exit 1 + $OUTER set -g window-size manual || exit 1 + $OUTER set -g default-terminal screen-256color || exit 1 + $OUTER respawn-pane -k -t outer:0.0 "$INNER attach -t inner" || exit 1 + sleep 0.5 + $OUTER pipe-pane -O -t outer:0.0 "cat >'$DIR/output-$margins'" || exit 1 + $INNER refresh-client || exit 1 + sleep 0.5 + grep -aq KEEP02 "$DIR/output-$margins" || fail "initial rows not captured" + offset=$(wc -c <"$DIR/output-$margins") + : >"$DIR/trigger-$margins" + i=0 + while [ "$i" -lt 50 ]; do + $OUTER capture-pane -p -t outer:0.0 >"$DIR/screen-$margins" || exit 1 + grep -q NEWROW "$DIR/screen-$margins" && break + sleep 0.1 + i=$((i + 1)) + done + [ "$i" -lt 50 ] || fail "$margins: scroll did not reach the terminal" + sleep 0.2 + tail -c +"$((offset + 1))" "$DIR/output-$margins" >"$DIR/scroll-$margins" + grep -aq NEWROW "$DIR/scroll-$margins" || fail "scroll output not captured" + if [ "$margins" = on ]; then + if grep -aq KEEP02 "$DIR/scroll-$margins"; then + fail "scroll with margins retransmitted an existing row" + fi + else + grep -aq KEEP02 "$DIR/scroll-$margins" || + fail "scroll without margins did not exercise the redraw fallback" + fi + printf 'KEEP02\nKEEP03\nKEEP04\nKEEP05\nKEEP06\nNEWROW\n' >"$DIR/expected" + cmp -s "$DIR/expected" "$DIR/screen-$margins" || + fail "$margins: terminal did not contain the expected scrolled rows" + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + j=$((j + 1)) +done + +exit 0 diff --git a/regress/window-copy-sb-drag-focus-style.sh b/regress/window-copy-sb-drag-focus-style.sh new file mode 100755 index 000000000..77f478955 --- /dev/null +++ b/regress/window-copy-sb-drag-focus-style.sh @@ -0,0 +1,123 @@ +#!/bin/sh + +# Dragging an inactive pane's scrollbar slider changes which pane is +# active (window_copy_scroll(), window-copy.c). Every other place in the +# codebase that changes the active pane pairs it with +# window_redraw_active_switch() (or falls back to a full window redraw) so +# the old/new active panes' window-active-style/window-style colours get +# repainted - window_copy_scroll() was the one caller that did neither, so +# a scrollbar-slider drag changed focus but left both panes' body colours +# stale. This used to be masked by window_set_active_pane() itself doing +# an unconditional full redraw on every active-pane change, until that was +# narrowed to borders/status-only for the common case. +# +# This creates two tiled panes with clearly distinguishable +# window-active-style/window-style backgrounds, puts the *inactive* one in +# copy mode, drags its scrollbar slider, and checks - via an attached +# client's own received bytes, since window-style is applied during +# redraw composition rather than stored in the grid, so capture-pane +# alone would not reflect it - that the newly active pane's marker text +# immediately shows the active-style colour, not the stale one. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +DIR=$(mktemp -d) || exit 1 +INNER="$TEST_TMUX -Lsbfocus-inner-$$ -f/dev/null" +OUTER="$TEST_TMUX -Lsbfocus-outer-$$ -f/dev/null" +CAPTURE=$DIR/capture +ESC=$(printf '\033') + +fail() +{ + echo "$*" >&2 + [ -s "$CAPTURE" ] && cat -A "$CAPTURE" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +get_mark_color() +{ + $OUTER capture-pane -e -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true + LC_ALL=C grep -a "MARK1" "$CAPTURE" | + LC_ALL=C grep -aoE "${ESC}\[4[12]m" | tail -1 +} + +wait_mark_color() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + [ "$(get_mark_color)" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "MARK1's pane never showed the expected background (wanted '$want', got '$(get_mark_color)')" +} + +mouse() +{ + sequence=$(printf '\033[<%s;%s;%s%s' "$1" "$2" "$3" "$4") + $OUTER send-keys -t outer:0.0 -l "$sequence" || exit 1 + sleep 0.1 +} + +$INNER new-session -d -s inner -x 40 -y 10 'sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-option -g pane-scrollbars on || exit 1 +$INNER setw pane-scrollbars-position right || exit 1 +$INNER setw pane-scrollbars-style 'width=1,pad=0' || exit 1 +$INNER set-option -g window-active-style 'bg=red' || exit 1 +$INNER set-option -g window-style 'bg=green' || exit 1 +$INNER split-window -h -t inner -d 'printf MARK1; sleep 100' || exit 1 + +PANE1=$($INNER list-panes -t inner -F '#{pane_id}' | sed -n 2p) +[ "$($INNER display-message -p -t "$PANE1" '#{pane_active}')" = 0 ] || + fail "sanity: MARK1's pane is already active before the drag" + +# Put the inactive pane in copy mode without switching focus (copy-mode -t +# targets a pane without making it active). +$INNER copy-mode -t "$PANE1" || exit 1 + +$OUTER new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +$OUTER set-option -g default-terminal screen-256color || exit 1 +$OUTER respawn-pane -k -t outer:0.0 \ + "$TEST_TMUX -Lsbfocus-inner-$$ -f/dev/null attach-session -t inner" || + exit 1 + +wait_mark_color "${ESC}[42m" + +XOFF=$($INNER display-message -p -t "$PANE1" '#{pane_left}') +YOFF=$($INNER display-message -p -t "$PANE1" '#{pane_top}') +SX=$($INNER display-message -p -t "$PANE1" '#{pane_width}') +SBCOL=$((XOFF + SX + 1)) +SBROW=$((YOFF + 1)) + +# With no scrollback beyond the pane's own content, the slider fills the +# whole scrollbar track, so any point on it (here, its very first row) is +# on the slider. +mouse 0 "$SBCOL" "$SBROW" M +mouse 32 "$SBCOL" "$((SBROW + 1))" M +mouse 0 "$SBCOL" "$((SBROW + 1))" m + +[ "$($INNER display-message -p -t "$PANE1" '#{pane_active}')" = 1 ] || + fail "sanity: dragging the scrollbar slider did not change focus" + +wait_mark_color "${ESC}[41m" + +exit 0 diff --git a/screen-redraw.c b/screen-redraw.c index c65ace871..693a1b899 100644 --- a/screen-redraw.c +++ b/screen-redraw.c @@ -1,4 +1,4 @@ -/* $OpenBSD: screen-redraw.c,v 1.162 2026/09/24 11:19:39 nicm Exp $ */ +/* $OpenBSD: screen-redraw.c,v 1.163 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -204,14 +204,36 @@ struct redraw_scene { u_int oy; }; +/* A damaged rectangle in a window. */ +struct redraw_damage { + u_int x; + u_int y; + u_int sx; + u_int sy; + + TAILQ_ENTRY(redraw_damage) entry; +}; + +/* + * If there are more damage rectangles than this, they are collapsed into + * one. + */ +#define REDRAW_DAMAGE_MAX 16 + /* Cell for building the scene. */ struct redraw_build_cell { struct redraw_span_data data; }; - static struct redraw_build_cell *redraw_cells; static size_t redraw_ncells; +/* + * We can reuse the same pane status lines during one damage redraw, but when + * we enter a new one, the client or format variables may have changed, so we + * need to make them again. The generation is increased so this happens. + */ +static u_int redraw_status_generation; + /* Context for building the scene. */ struct redraw_build_ctx { struct client *c; @@ -1054,6 +1076,13 @@ redraw_free_scene(struct redraw_scene *scene) free(scene); } +/* Does a client's cached scene show this window? */ +int +redraw_client_has_window(struct client *c, struct window *w) +{ + return (c->redraw_scene != NULL && c->redraw_scene->w == w); +} + /* Mark a window's cached redraw scenes as out of date. */ void redraw_invalidate_scene(struct window *w) @@ -1061,6 +1090,104 @@ redraw_invalidate_scene(struct window *w) w->redraw_scene_generation++; } +/* Free all pending damage for a window. */ +void +redraw_free_damage(struct window *w) +{ + struct redraw_damage *rd, *rd1; + + TAILQ_FOREACH_SAFE(rd, &w->damage, entry, rd1) { + TAILQ_REMOVE(&w->damage, rd, entry); + free(rd); + } + w->damage_count = 0; +} + +/* Collapse all pending damage for a window into one rectangle. */ +static void +redraw_collapse_damage(struct window *w) +{ + struct redraw_damage *rd, *rd1, *first; + u_int x0, y0, x1, y1; + + first = TAILQ_FIRST(&w->damage); + if (first == NULL) + return; + x0 = first->x; + y0 = first->y; + x1 = first->x + first->sx; + y1 = first->y + first->sy; + + TAILQ_FOREACH_SAFE(rd, &w->damage, entry, rd1) { + if (rd->x < x0) + x0 = rd->x; + if (rd->y < y0) + y0 = rd->y; + if (rd->x + rd->sx > x1) + x1 = rd->x + rd->sx; + if (rd->y + rd->sy > y1) + y1 = rd->y + rd->sy; + if (rd != first) { + TAILQ_REMOVE(&w->damage, rd, entry); + free(rd); + } + } + + first->x = x0; + first->y = y0; + first->sx = x1 - x0; + first->sy = y1 - y0; + w->damage_count = 1; +} + +/* Record window damage, merging nearby rectangles and limiting the count. */ +void +redraw_damage_window(struct window *w, u_int x, u_int y, u_int sx, u_int sy) +{ + struct redraw_damage *rd; + u_int x0, y0, x1, y1, area, union_area; + + if (x >= w->sx || y >= w->sy) + return; + if (x + sx > w->sx) + sx = w->sx - x; + if (y + sy > w->sy) + sy = w->sy - y; + if (sx == 0 || sy == 0) + return; + + TAILQ_FOREACH(rd, &w->damage, entry) { + if (x > rd->x + rd->sx || rd->x > x + sx || + y > rd->y + rd->sy || rd->y > y + sy) + continue; + + x0 = (x < rd->x) ? x : rd->x; + y0 = (y < rd->y) ? y : rd->y; + x1 = (x + sx > rd->x + rd->sx) ? x + sx : rd->x + rd->sx; + y1 = (y + sy > rd->y + rd->sy) ? y + sy : rd->y + rd->sy; + + area = sx * sy + rd->sx * rd->sy; + union_area = (x1 - x0) * (y1 - y0); + if (union_area > 2 * area) + continue; + + rd->x = x0; + rd->y = y0; + rd->sx = x1 - x0; + rd->sy = y1 - y0; + return; + } + + rd = xcalloc(1, sizeof *rd); + rd->x = x; + rd->y = y; + rd->sx = sx; + rd->sy = sy; + TAILQ_INSERT_TAIL(&w->damage, rd, entry); + if (++w->damage_count > REDRAW_DAMAGE_MAX) + redraw_collapse_damage(w); +} + /* Mark all cached redraw scenes as out of date. */ void redraw_invalidate_all_scenes(void) @@ -1603,6 +1730,24 @@ redraw_set_draw_context(struct redraw_draw_ctx *dctx, dctx->flags |= REDRAW_ISOLATES; } +/* Build a pane prompt. */ +static void +redraw_make_pane_prompt(struct window_pane *wp, struct screen *screen) +{ + struct screen_write_ctx ctx; + struct prompt_draw_data pdd; + + screen_init(screen, wp->sx, 1, 0); + screen_write_start(&ctx, screen); + pdd.ctx = &ctx; + pdd.cursor_x = &wp->prompt_cx; + pdd.area_x = 0; + pdd.area_width = wp->sx; + pdd.prompt_line = 0; + prompt_draw(wp->prompt, &pdd); + screen_write_stop(&ctx); +} + /* Draw a pane's prompt over its content. */ static void redraw_draw_pane_prompt(struct redraw_draw_ctx *dctx, struct window_pane *wp) @@ -1611,8 +1756,6 @@ redraw_draw_pane_prompt(struct redraw_draw_ctx *dctx, struct window_pane *wp) struct client *c = scene->c; struct tty *tty = &c->tty; struct screen screen; - struct screen_write_ctx ctx; - struct prompt_draw_data pdd; int ox = scene->ox, oy = scene->oy; int sx = scene->sx, sy = scene->sy; int line, cy, px, offset, width, wy; @@ -1645,16 +1788,7 @@ redraw_draw_pane_prompt(struct redraw_draw_ctx *dctx, struct window_pane *wp) if (px + width > sx) width = sx - px; - screen_init(&screen, wp->sx, 1, 0); - screen_write_start(&ctx, &screen); - pdd.ctx = &ctx; - pdd.cursor_x = &wp->prompt_cx; - pdd.area_x = 0; - pdd.area_width = wp->sx; - pdd.prompt_line = 0; - prompt_draw(wp->prompt, &pdd); - screen_write_stop(&ctx); - + redraw_make_pane_prompt(wp, &screen); tty_draw_line(tty, &screen, offset, 0, width, px, cy, NULL); screen_free(&screen); } @@ -1684,7 +1818,9 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) redraw = status_prompt_redraw(c); else redraw = status_redraw(c); - if (!redraw && !REDRAW_IS_ALL(flags)) { + if (!redraw && + (~c->flags & CLIENT_REDRAWSTATUSALWAYS) && + !REDRAW_IS_ALL(flags)) { flags &= ~REDRAW_STATUS; if (flags == 0) return; @@ -1842,7 +1978,7 @@ redraw_screen(struct client *c) else { if (c->flags & CLIENT_REDRAWBORDERS) flags |= (REDRAW_PANE_BORDER|REDRAW_PANE_STATUS); - if (c->flags & CLIENT_REDRAWSTATUS) + if (c->flags & (CLIENT_REDRAWSTATUS|CLIENT_REDRAWSTATUSALWAYS)) flags |= (REDRAW_STATUS|REDRAW_PANE_STATUS); if (c->flags & CLIENT_REDRAWMENU) flags |= REDRAW_MENU; @@ -1868,3 +2004,138 @@ redraw_pane_scrollbar(struct client *c, struct window_pane *wp) { redraw_draw(c, wp, REDRAW_PANE_SCROLLBAR); } + +/* Rebuild damaged pane status. */ +static void +redraw_damage_refresh_status(struct redraw_draw_ctx *dctx, + struct window_pane *wp) +{ + struct redraw_span *first; + u_int g = wp->status_generation, width; + + if ((wp->flags & PANE_NEWSTATUS) && g == redraw_status_generation) + return; + width = redraw_pane_status_width(dctx, wp, &first); + if (width != 0) { + window_make_pane_status(wp, dctx->scene->c, width, first); + wp->flags |= PANE_NEWSTATUS; + wp->status_generation = redraw_status_generation; + } +} + +/* Draw a pane's prompt over a damaged span. */ +static void +redraw_damage_draw_pane_prompt(struct redraw_draw_ctx *dctx, + struct redraw_span *span, u_int y) +{ + struct redraw_scene *scene = dctx->scene; + struct window_pane *wp = span->data.p.wp; + struct tty *tty = &scene->c->tty; + struct screen screen; + u_int px = span->data.p.px, width, prompt_y; + + if (wp->prompt == NULL || wp->sx == 0 || wp->sy == 0) + return; + if (dctx->flags & REDRAW_STATUS_TOP) + prompt_y = 0; + else + prompt_y = wp->sy - 1; + if (span->data.p.py != prompt_y) + return; + redraw_make_pane_prompt(wp, &screen); + if (px < screen_size_x(&screen)) { + width = span->width; + if (width > screen_size_x(&screen) - px) + width = screen_size_x(&screen) - px; + tty_draw_line(tty, &screen, px, 0, width, span->x, y, NULL); + } + screen_free(&screen); +} + +/* Draw the spans intersecting a damaged rectangle. */ +static void +redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y, + u_int sx, u_int sy) +{ + struct redraw_scene *scene = dctx->scene; + struct redraw_line *line; + struct redraw_spans *spans; + struct redraw_span *span; + u_int cy, yy, type; + + if (x >= scene->sx || y >= scene->sy) + return; + if (x + sx > scene->sx) + sx = scene->sx - x; + if (y + sy > scene->sy) + sy = scene->sy - y; + if (sx == 0 || sy == 0) + return; + + for (yy = y; yy < y + sy; yy++) { + line = &scene->lines[yy]; + if (dctx->flags & REDRAW_STATUS_TOP) + cy = dctx->status_lines + yy; + else + cy = yy; + for (type = 0; type < REDRAW_SPAN_TYPES; type++) { + spans = &line->spans[type]; + TAILQ_FOREACH(span, spans, entry) { + if (span->x >= x + sx) + continue; + if (span->x + span->width <= x) + continue; + if (type == REDRAW_SPAN_STATUS) { + redraw_damage_refresh_status(dctx, + span->data.st.wp); + } + redraw_draw_span(dctx, span, cy); + if (type == REDRAW_SPAN_PANE) { + redraw_damage_draw_pane_prompt(dctx, + span, cy); + } + } + } + } +} + +/* Draw pending window damage on this client. */ +void +redraw_client_damage(struct client *c) +{ + struct window *w = c->session->curw->window; + struct window_pane *wp; + struct redraw_scene *scene; + struct redraw_draw_ctx dctx; + struct redraw_damage *rd; + u_int ox, oy, sx, sy, x0, y0, x1, y1; + + if (TAILQ_EMPTY(&w->damage)) + return; + redraw_status_generation++; + + scene = redraw_get_scene(c); + if (scene == NULL) + return; + redraw_set_draw_context(&dctx, scene); + redraw_get_window_offset(c, &ox, &oy, &sx, &sy); + + TAILQ_FOREACH(wp, &w->panes, entry) { + wp->border_gc_set = 0; + wp->active_border_gc_set = 0; + } + + tty_sync_start(&c->tty); + tty_update_mode(&c->tty, c->tty.mode & ~CURSOR_MODES, NULL); + + TAILQ_FOREACH(rd, &w->damage, entry) { + x0 = (rd->x > ox) ? rd->x : ox; + y0 = (rd->y > oy) ? rd->y : oy; + x1 = (rd->x + rd->sx < ox + sx) ? rd->x + rd->sx : ox + sx; + y1 = (rd->y + rd->sy < oy + sy) ? rd->y + rd->sy : oy + sy; + if (x0 < x1 && y0 < y1) { + redraw_draw_damage_rectangle(&dctx, x0 - ox, y0 - oy, + x1 - x0, y1 - y0); + } + } +} diff --git a/screen-write.c b/screen-write.c index c3f34ff61..4eb2427f7 100644 --- a/screen-write.c +++ b/screen-write.c @@ -1,4 +1,4 @@ -/* $OpenBSD: screen-write.c,v 1.294 2026/09/28 10:10:16 nicm Exp $ */ +/* $OpenBSD: screen-write.c,v 1.298 2026/10/02 15:20:41 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -122,14 +122,26 @@ screen_write_set_cursor(struct screen_write_ctx *ctx, int cx, int cy) evtimer_add(&w->offset_timer, &tv); } -/* Do a full redraw. */ +/* Redraw lines. */ static void -screen_write_redraw_cb(const struct tty_ctx *ttyctx) +screen_write_redraw_cb(const struct tty_ctx *ttyctx, u_int py, u_int ny) { struct window_pane *wp = ttyctx->arg; + int x0, y0, x1, y1; - if (wp != NULL) - wp->flags |= PANE_REDRAW; + if (wp == NULL) + return; + + x0 = wp->xoff; + y0 = wp->yoff + (int)py; + x1 = x0 + (int)wp->sx; + y1 = y0 + (int)ny; + if (x0 < 0) + x0 = 0; + if (y0 < 0) + y0 = 0; + if (x1 > x0 && y1 > y0) + redraw_damage_window(wp->window, x0, y0, x1 - x0, y1 - y0); } /* Update context for client. */ @@ -177,11 +189,12 @@ screen_write_set_client_cb(struct tty_ctx *ttyctx, struct client *c) return (1); } -/* Return 1 if there is a floating window pane overlapping this pane. */ +/* Return 1 if a menu or floating pane overlaps this pane. */ static int screen_write_pane_is_obscured(struct screen_write_ctx *ctx) { struct window_pane *wp = ctx->wp; + struct menu_data *md; if (ctx->wp == NULL) return (0); @@ -192,6 +205,16 @@ screen_write_pane_is_obscured(struct screen_write_ctx *ctx) } ctx->flags |= SCREEN_WRITE_CHECKED_IF_OBSCURED; + md = wp->window->menu; + if (md != NULL && + (int)menu_x(md) < wp->xoff + (int)wp->sx && + (int)(menu_x(md) + menu_width(md)) > wp->xoff && + (int)menu_y(md) < wp->yoff + (int)wp->sy && + (int)(menu_y(md) + menu_height(md)) > wp->yoff) { + ctx->flags |= SCREEN_WRITE_OBSCURED; + return (1); + } + if (ctx->wp->xoff < 0 || ctx->wp->yoff < 0 || ctx->wp->xoff + ctx->wp->sx > ctx->wp->window->sx || @@ -1376,11 +1399,11 @@ screen_write_sync_scroll_dirty(struct screen_write_ctx *ctx) /* Redraw the scrolled lines for a client which cannot scroll them. */ static void -screen_write_sync_redraw_cb(const struct tty_ctx *ttyctx) +screen_write_sync_redraw_cb(const struct tty_ctx *ttyctx, u_int py, u_int ny) { struct window_pane *wp = ttyctx->arg; - bit_nset(wp->sync_dirty, wp->sync_rupper, wp->sync_rlower); + bit_nset(wp->sync_dirty, py, py + ny - 1); } /* Send the deferred scroll to the client. */ @@ -2272,7 +2295,7 @@ screen_write_fullredraw(struct screen_write_ctx *ctx) screen_write_initctx(ctx, &ttyctx, 1, 0); if (ttyctx.redraw_cb != NULL) - ttyctx.redraw_cb(&ttyctx); + ttyctx.redraw_cb(&ttyctx, 0, ttyctx.sy); } /* Trim collected items. */ @@ -2510,13 +2533,18 @@ screen_write_collect_flush_line(struct screen_write_ctx *ctx, u_int y) ttyctx.n = w_length; tty_write(tty_cmd_clearcharacter, &ttyctx); } else { - screen_write_initctx(ctx, &ttyctx, 0, 0); - ttyctx.cell = &ci->gc; - if (ci->wrapped) - ttyctx.flags |= TTY_CTX_WRAPPED; - ttyctx.data.data = cl->data + w_start; - ttyctx.data.size = w_length; - tty_write(tty_cmd_cells, &ttyctx); + screen_write_initctx(ctx, &ttyctx, 0, 1); + if (ttyctx.flags & TTY_CTX_PANE_OBSCURED) { + ttyctx.n = w_length; + tty_write(tty_cmd_redrawline, &ttyctx); + } else { + ttyctx.cell = &ci->gc; + if (ci->wrapped) + ttyctx.flags |= TTY_CTX_WRAPPED; + ttyctx.data.data = cl->data + w_start; + ttyctx.data.size = w_length; + tty_write(tty_cmd_cells, &ttyctx); + } } items++; written = 1; @@ -2744,6 +2772,10 @@ screen_write_collect_add(struct screen_write_ctx *ctx, * Don't need to check that the attributes and whatnot are still the * same - input_parse will end the collection when anything that isn't * a plain character is encountered. + * + * Without wrapping, collect up to but not including the last column. + * Leave that cell to screen_write_cell so repeated writes overwrite it + * and the cursor remains at the right edge. */ collect = 1; @@ -2753,7 +2785,8 @@ screen_write_collect_add(struct screen_write_ctx *ctx, collect = 0; else if (gc->attr & GRID_ATTR_CHARSET) collect = 0; - else if (~s->mode & MODE_WRAP) + else if ((~s->mode & MODE_WRAP) && + s->cx + ctx->item->used >= sx - 1) collect = 0; else if (s->mode & MODE_INSERT) collect = 0; @@ -2919,6 +2952,11 @@ screen_write_cell(struct screen_write_ctx *ctx, const struct grid_cell *gc) /* Create space for character in insert mode. */ if (s->mode & MODE_INSERT) { screen_write_collect_flush(ctx, 0, __func__); + if (wp != NULL && screen_write_pane_is_obscured(ctx)) { + if (screen_write_should_draw_line(ctx, s->cy)) + screen_write_redraw_line(ctx, &ttyctx, s->cy); + return; + } ttyctx.n = width; if (screen_write_should_draw_line(ctx, s->cy)) tty_write(tty_cmd_insertcharacter, &ttyctx); @@ -3290,7 +3328,7 @@ screen_write_alternateon(struct screen_write_ctx *ctx, struct grid_cell *gc, screen_write_initctx(ctx, &ttyctx, 1, 0); if (ttyctx.redraw_cb != NULL) - ttyctx.redraw_cb(&ttyctx); + ttyctx.redraw_cb(&ttyctx, 0, ttyctx.sy); } /* Turn alternate screen off. */ @@ -3315,5 +3353,5 @@ screen_write_alternateoff(struct screen_write_ctx *ctx, struct grid_cell *gc, screen_write_initctx(ctx, &ttyctx, 1, 0); if (ttyctx.redraw_cb != NULL) - ttyctx.redraw_cb(&ttyctx); + ttyctx.redraw_cb(&ttyctx, 0, ttyctx.sy); } diff --git a/server-client.c b/server-client.c index 2d1a19114..aec1cb23e 100644 --- a/server-client.c +++ b/server-client.c @@ -1,4 +1,4 @@ -/* $OpenBSD: server-client.c,v 1.515 2026/09/28 11:25:51 nicm Exp $ */ +/* $OpenBSD: server-client.c,v 1.517 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -356,7 +356,13 @@ server_client_set_session(struct client *c, struct session *s) tty_update_client_offset(c); status_timer_start(c); server_client_fire_session_changed(c, old); - server_redraw_client(c); + + /* + * Redraw if the session or displayed window changed. Use the + * cached scene because the session's current window is already set. + */ + if (old != s || !redraw_client_has_window(c, s->curw->window)) + server_redraw_client(c); } server_check_unattached(); @@ -369,8 +375,7 @@ server_client_lost(struct client *c) { struct client_file *cf, *cf1; - if (cfg_client == c) - cfg_client = NULL; + cfg_client_lost(c); c->flags |= CLIENT_DEAD; status_prompt_clear(c); @@ -1349,10 +1354,11 @@ server_client_key_callback(struct cmdq_item *item, void *data) m->key = key; /* - * Mouse drag is in progress, so fire the callback (now that - * the mouse event is valid). + * Synchronize direct drag output with the later damage redraw + * before invoking the drag callback. */ if ((key & KEYC_MASK_KEY) == KEYC_DRAGGING) { + tty_sync_start(&c->tty); c->tty.mouse_drag_update(c, m); goto out; } @@ -1779,8 +1785,8 @@ server_client_loop(void) } /* - * Any windows will have been redrawn as part of clients, so clear - * their flags now. + * Clear window redraw state after processing all clients. Deferred + * redraws are preserved in client flags. */ RB_FOREACH(w, windows, &windows) { TAILQ_FOREACH(wp, &w->panes, entry) { @@ -1791,6 +1797,8 @@ server_client_loop(void) wp->flags &= ~(PANE_REDRAW|PANE_REDRAWSCROLLBAR| PANE_ACTIVITY); } + redraw_free_damage(w); + check_window_name(w); } @@ -2340,6 +2348,8 @@ server_client_any_pane_redraw(struct client *c) if (c->flags & CLIENT_REDRAWWINDOW) return (1); + if (!TAILQ_EMPTY(&w->damage)) + return (1); TAILQ_FOREACH(wp, &w->panes, entry) { if (wp->flags & (PANE_REDRAW|PANE_REDRAWSCROLLBAR)) return (1); @@ -2356,6 +2366,7 @@ server_client_check_redraw(struct client *c) struct window *w = s->curw->window; struct window_pane *wp; int needed, tflags, mode = tty->mode; + int damaged = !TAILQ_EMPTY(&w->damage); struct timeval tv = { .tv_usec = 1000 }; static struct event ev; size_t n; @@ -2381,12 +2392,12 @@ server_client_check_redraw(struct client *c) return; } - /* - * If there is outstanding data, defer the redraw until it has been - * consumed. We can just add a timer to get out of the event loop and - * end up back here. - */ + /* Ignore output queued within the current synchronized frame. */ n = EVBUFFER_LENGTH(tty->out); + if ((tty->flags & TTY_SYNCING) && n > tty->sync_offset) + n = tty->sync_offset; + + /* Defer until output drains, preserving damage in client flags. */ if (n != 0 || (tty->flags & TTY_BLOCK)) { if (n != 0) log_debug("%s: redraw deferred (%zu left)", c->name, n); @@ -2398,6 +2409,10 @@ server_client_check_redraw(struct client *c) log_debug("redraw timer started"); evtimer_add(&ev, &tv); } + if (damaged) { + c->flags |= CLIENT_REDRAWWINDOW; + return; + } TAILQ_FOREACH(wp, &w->panes, entry) { if (wp->flags & PANE_REDRAW) { c->flags |= CLIENT_REDRAWWINDOW; @@ -2431,6 +2446,10 @@ server_client_check_redraw(struct client *c) redraw_pane_scrollbar(c, wp); } } + + /* Draw damage here if no client redraw flags will handle it. */ + if (damaged && (c->flags & CLIENT_ALLREDRAWFLAGS) == 0) + redraw_client_damage(c); } /* @@ -2444,6 +2463,7 @@ server_client_check_redraw(struct client *c) } server_client_set_progress_bar(c); redraw_screen(c); + redraw_client_damage(c); } /* Put the tty back how it was. */ diff --git a/spawn.c b/spawn.c index 526cea8f9..df6cef646 100644 --- a/spawn.c +++ b/spawn.c @@ -1,4 +1,4 @@ -/* $OpenBSD: spawn.c,v 1.53 2026/09/21 12:43:36 nicm Exp $ */ +/* $OpenBSD: spawn.c,v 1.54 2026/10/02 13:20:42 nicm Exp $ */ /* * Copyright (c) 2019 Nicholas Marriott @@ -458,14 +458,14 @@ spawn_pane(struct spawn_context *sc, char **cause) new_wp->flags &= ~PANE_EMPTY; /* Store current working directory and change to new one. */ - if (getcwd(path, sizeof path) != NULL) { - if (chdir(new_wp->cwd) == 0) - actual_cwd = new_wp->cwd; - else if (home != NULL && chdir(home) == 0) - actual_cwd = home; - else if (chdir("/") == 0) - actual_cwd = "/"; - } + if (getcwd(path, sizeof path) == NULL) + strlcpy(path, "/", sizeof path); + if (chdir(new_wp->cwd) == 0) + actual_cwd = new_wp->cwd; + else if (home != NULL && chdir(home) == 0) + actual_cwd = home; + else if (chdir("/") == 0) + actual_cwd = "/"; /* Fork the new process. */ new_wp->pid = fdforkpty(ptm_fd, &new_wp->fd, new_wp->tty, NULL, &ws); diff --git a/tmux.h b/tmux.h index 48d577c12..9da7bed83 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1449 2026/09/28 10:10:16 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1451 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -69,6 +69,7 @@ struct options_array_item; struct options_entry; struct prompt; struct window_pane_prompt; +struct redraw_damage; struct redraw_scene; struct redraw_span; struct screen_write_citem; @@ -1412,6 +1413,7 @@ struct window_pane { struct screen base; struct screen status_screen; + u_int status_generation; TAILQ_HEAD(, window_mode_entry) modes; @@ -1442,6 +1444,7 @@ struct window_pane { TAILQ_HEAD(window_panes, window_pane); TAILQ_HEAD(window_panes_zindex, window_pane); RB_HEAD(window_pane_tree, window_pane); +TAILQ_HEAD(redraw_damages, redraw_damage); /* Window structure. */ struct window { @@ -1483,6 +1486,9 @@ struct window { uint64_t redraw_scene_generation; + struct redraw_damages damage; + u_int damage_count; + struct menu_data *menu; u_int menu_last_px; u_int menu_last_py; @@ -1819,6 +1825,8 @@ struct tty { struct event timer; size_t discarded; + size_t sync_offset; + struct termios tio; struct grid_cell cell; @@ -1866,7 +1874,7 @@ struct tty { }; /* Terminal command context. */ -typedef void (*tty_ctx_redraw_cb)(const struct tty_ctx *); +typedef void (*tty_ctx_redraw_cb)(const struct tty_ctx *, u_int, u_int); typedef int (*tty_ctx_set_client_cb)(struct tty_ctx *, struct client *); struct tty_ctx { struct screen *s; @@ -2626,6 +2634,7 @@ extern char **cfg_files; extern u_int cfg_nfiles; extern int cfg_quiet; void start_cfg(void); +void cfg_client_lost(struct client *); int load_cfg(const char *, struct client *, struct cmdq_item *, struct cmd_find_state *, int, struct cmdq_item **); int load_cfg_from_buffer(const void *, size_t, const char *, @@ -3653,8 +3662,12 @@ void redraw_screen(struct client *); void redraw_pane(struct client *, struct window_pane *); void redraw_pane_scrollbar(struct client *, struct window_pane *); void redraw_free_scene(struct redraw_scene *); +int redraw_client_has_window(struct client *, struct window *); void redraw_invalidate_scene(struct window *); void redraw_invalidate_all_scenes(void); +void redraw_damage_window(struct window *, u_int, u_int, u_int, u_int); +void redraw_free_damage(struct window *); +void redraw_client_damage(struct client *); int redraw_get_status_border_cell_type(struct redraw_span **, u_int); /* screen.c */ @@ -3837,6 +3850,8 @@ struct style_range *window_pane_status_get_range(struct window_pane *, u_int, int window_pane_is_floating(struct window_pane *); int window_pane_is_unzoomed_float(struct window_pane *); int window_pane_is_raised(struct window_pane *); +void window_redraw_floating_pane(struct window_pane *, int, int, + int, int); /* window-border.c */ void window_set_fill_cells(struct window *); diff --git a/tty.c b/tty.c index 11330d99d..38fe5bf51 100644 --- a/tty.c +++ b/tty.c @@ -1,4 +1,4 @@ -/* $OpenBSD: tty.c,v 1.482 2026/09/22 06:58:06 nicm Exp $ */ +/* $OpenBSD: tty.c,v 1.483 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -1115,7 +1115,7 @@ tty_redraw_region(struct tty *tty, const struct tty_ctx *ctx) */ if (tty_large_region(tty, ctx) || ctx->flags & TTY_CTX_PANE_OBSCURED) { log_debug("%s: %s large region redraw", __func__, c->name); - ctx->redraw_cb(ctx); + ctx->redraw_cb(ctx, ctx->orupper, ctx->orlower - ctx->orupper + 1); return; } @@ -1517,6 +1517,7 @@ tty_sync_start(struct tty *tty) if (tty->flags & TTY_SYNCING) return; tty->flags |= TTY_SYNCING; + tty->sync_offset = EVBUFFER_LENGTH(tty->out); if (tty_term_has(tty->term, TTYC_SYNC)) { log_debug("%s sync start", tty->client->name); @@ -1923,7 +1924,7 @@ tty_cmd_alignmenttest(struct tty *tty, const struct tty_ctx *ctx) u_int i, j; if (ctx->flags & TTY_CTX_WINDOW_BIGGER) { - ctx->redraw_cb(ctx); + ctx->redraw_cb(ctx, 0, ctx->sy); return; } @@ -1982,7 +1983,7 @@ tty_cmd_cells(struct tty *tty, const struct tty_ctx *ctx) tty->cy == tty->rlower) tty_draw_pane(tty, ctx, ctx->ocy); else - ctx->redraw_cb(ctx); + ctx->redraw_cb(ctx, ctx->ocy, 1); return; } diff --git a/window-copy.c b/window-copy.c index fd42e91bd..c6126de38 100644 --- a/window-copy.c +++ b/window-copy.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-copy.c,v 1.431 2026/09/21 10:43:37 nicm Exp $ */ +/* $OpenBSD: window-copy.c,v 1.432 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -768,6 +768,7 @@ window_copy_scroll(struct window_pane *wp, int sl_mpos, u_int my, struct window_mode_entry *wme = TAILQ_FIRST(&wp->modes); if (wme != NULL) { + window_redraw_active_switch(wp->window, wp); window_set_active_pane(wp->window, wp, 0); window_copy_scroll1(wme, wp, sl_mpos, my, tty_oy, scroll_exit); } diff --git a/window-visible.c b/window-visible.c index fadd02c50..74758d5a1 100644 --- a/window-visible.c +++ b/window-visible.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-visible.c,v 1.5 2026/07/19 17:25:38 nicm Exp $ */ +/* $OpenBSD: window-visible.c,v 1.6 2026/10/02 15:20:41 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -25,7 +25,7 @@ /* * Check if a single character is within a visible range (not obscured by a - * floating pane). + * menu or floating pane). */ int window_position_is_visible(struct visible_ranges *r, u_int px) @@ -43,9 +43,45 @@ window_position_is_visible(struct visible_ranges *r, u_int px) return (0); } +/* Remove width cells starting at px from the visible ranges. */ +static void +window_visible_ranges_remove(struct visible_ranges *r, int px, u_int width) +{ + struct visible_range *ri; + u_int i; + int start, end, right = px + width; + + if (width == 0) + return; + + for (i = 0; i < r->used; i++) { + ri = &r->ranges[i]; + start = ri->px; + end = start + ri->nx; + if (ri->nx == 0 || right <= start || px >= end) + continue; + if (px <= start) { + /* Remove the left part or the whole range. */ + ri->px = right < end ? right : end; + ri->nx = end - ri->px; + } else { + /* Keep the left part, and split off any right part. */ + ri->nx = px - start; + if (right < end) { + server_client_ensure_ranges(r, r->used + 1); + memmove(&r->ranges[i + 2], &r->ranges[i + 1], + (r->used - i - 1) * sizeof *r->ranges); + r->ranges[i + 1].px = right; + r->ranges[i + 1].nx = end - right; + r->used++; + } + } + } +} + /* * Construct ranges array for the line at starting at px,py of width cells of - * base_wp that are unobsructed. All ranges are in window coordinates. + * base_wp that are unobstructed. All ranges are in window coordinates. */ struct visible_ranges * window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, @@ -53,11 +89,10 @@ window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, { struct window_pane *wp; struct window *w; - struct visible_range *ri; + struct menu_data *md; static struct visible_ranges sr = { NULL, 0, 0 }; int found_self, sb_w, sb_pos; - int lb, rb, tb, bb, sx, ex, no_border; - u_int i, s; + int lb, rb, tb, bb, no_border; if (py < 0 || width == 0) goto empty; @@ -95,6 +130,13 @@ window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, r->used = 1; } + /* The menu is above every pane in the window. */ + md = w->menu; + if (md != NULL && + (u_int)py >= menu_y(md) && + (u_int)py - menu_y(md) < menu_height(md)) { + window_visible_ranges_remove(r, menu_x(md), menu_width(md)); + } found_self = 0; TAILQ_FOREACH_REVERSE(wp, &w->z_index, window_panes_zindex, zentry) { @@ -130,89 +172,36 @@ window_visible_ranges(struct window_pane *base_wp, int px, int py, u_int width, else sb_w = sb_pos = 0; - for (i = 0; i < r->used; i++) { - ri = &r->ranges[i]; - if (ri->nx == 0) - continue; - if (no_border) { - lb = wp->xoff; - rb = wp->xoff + (int)wp->sx - 1; - } else if (sb_pos == PANE_SCROLLBARS_LEFT) { - if (wp->xoff > sb_w) - lb = wp->xoff - 1 - sb_w; - else - lb = 0; - } else { /* PANE_SCROLLBARS_RIGHT or none. */ - if (wp->xoff > 0) - lb = wp->xoff - 1; - else - lb = 0; - } - if (!no_border) { - if (sb_pos == PANE_SCROLLBARS_LEFT) - rb = wp->xoff + (int)wp->sx; - else /* PANE_SCROLLBARS_RIGHT or none. */ - rb = wp->xoff + (int)wp->sx + sb_w; - } - if (lb < 0) + if (no_border) { + lb = wp->xoff; + rb = wp->xoff + (int)wp->sx - 1; + } else if (sb_pos == PANE_SCROLLBARS_LEFT) { + if (wp->xoff > sb_w) + lb = wp->xoff - 1 - sb_w; + else + lb = 0; + } else { /* PANE_SCROLLBARS_RIGHT or none. */ + if (wp->xoff > 0) + lb = wp->xoff - 1; + else lb = 0; - if (rb < 0) - continue; - if (no_border && rb >= (int)w->sx) - rb = w->sx - 1; - else if (!no_border && rb > (int)w->sx) - rb = w->sx - 1; - if (lb > rb) - continue; - - sx = ri->px; - ex = sx + ri->nx - 1; - if (lb > sx && lb <= ex && rb > ex) { - /* - * If the left edge of floating pane falls - * inside this range and right edge covers up - * to right of range, then shrink left edge of - * range. - */ - ri->nx = lb - sx; - } else if (rb >= sx && rb <= ex && lb <= sx) { - /* - * Else if the right edge of floating pane falls - * inside of this range and left edge covers - * the left of range, then move px forward to - * right edge of pane. - */ - ri->nx = ex - rb; - ri->px = rb + 1; - } else if (lb > sx && rb <= ex) { - /* - * Else if pane fully inside range then split - * into 2 ranges. - */ - server_client_ensure_ranges(r, r->used + 1); - for (s = r->used; s > i; s--) { - memcpy(&r->ranges[s], &r->ranges[s - 1], - sizeof *r->ranges); - } - ri = &r->ranges[i]; - r->ranges[i + 1].px = rb + 1; - r->ranges[i + 1].nx = ex - rb; - /* ri->px was copied, unchanged. */ - ri->nx = lb - sx; - r->used++; - } else if (lb <= sx && rb > ex) { - /* - * If floating pane completely covers this range - * then delete it (make it 0 length). - */ - ri->nx = 0; - } else { - /* - * The range is already obscured, do - * nothing. - */ - } } + if (!no_border) { + if (sb_pos == PANE_SCROLLBARS_LEFT) + rb = wp->xoff + (int)wp->sx; + else /* PANE_SCROLLBARS_RIGHT or none. */ + rb = wp->xoff + (int)wp->sx + sb_w; + } + if (lb < 0) + lb = 0; + if (rb < 0) + continue; + if (no_border && rb >= (int)w->sx) + rb = w->sx - 1; + else if (!no_border && rb > (int)w->sx) + rb = w->sx - 1; + if (lb <= rb) + window_visible_ranges_remove(r, lb, rb - lb + 1); } return (r); diff --git a/window.c b/window.c index 93b06e1d9..3f715974e 100644 --- a/window.c +++ b/window.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window.c,v 1.383 2026/09/28 10:42:01 nicm Exp $ */ +/* $OpenBSD: window.c,v 1.384 2026/10/02 12:48:52 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -427,6 +427,7 @@ window_create(u_int sx, u_int sy, u_int xpixel, u_int ypixel) TAILQ_INIT(&w->panes); TAILQ_INIT(&w->z_index); TAILQ_INIT(&w->last_panes); + TAILQ_INIT(&w->damage); w->active = NULL; w->lastlayout = -1; @@ -470,6 +471,7 @@ window_destroy(struct window *w) menu_destroy(w); window_destroy_panes(w); + redraw_free_damage(w); if (event_initialized(&w->name_event)) evtimer_del(&w->name_event); @@ -762,6 +764,7 @@ window_activate_pane(struct window *w, struct window_pane *wp, int notify, int raise) { struct window_pane *lastwp; + int full = 0; log_debug("%s: pane %%%u", __func__, wp->id); @@ -770,6 +773,7 @@ window_activate_pane(struct window *w, struct window_pane *wp, int notify, if (wp->flags & PANE_HIDDEN) { if (window_show_pane(wp) != 0) return (0); + full = 1; if (wp == w->active) return (1); } @@ -785,6 +789,7 @@ window_activate_pane(struct window *w, struct window_pane *wp, int notify, window_raise_pane(wp); else window_hide_zoomed(w); + full = 1; } else if (raise) { /* Raise the pane if the option says to. */ switch (options_get_number(w->options, "pane-raise-on-focus")) { @@ -813,7 +818,19 @@ window_activate_pane(struct window *w, struct window_pane *wp, int notify, } tty_update_window_offset(w); - server_redraw_window(w); + + /* + * Showing, hiding or unzooming changes what is visible and needs a + * full window redraw. Otherwise, only the previous and new active + * pane's border and status appearance changed, so avoid redrawing + * unaffected pane content. + */ + if (full) + server_redraw_window(w); + else { + server_redraw_window_borders(w); + server_status_window(w); + } if (notify) window_fire_pane_changed(w, w->active, lastwp); @@ -3259,3 +3276,40 @@ window_pane_is_raised(struct window_pane *wp) return (1); return (window_pane_is_floating(wp)); } + +/* Report damage for a floating pane, including its border and scrollbar. */ +static void +window_damage_floating_pane(struct window_pane *wp, int xoff, int yoff, + int sx, int sy) +{ + struct window *w = wp->window; + int x0, x1, y0, y1, sb_left = 0, sb_right = 0; + struct style *sb_sy = &wp->scrollbar_style; + + if (window_pane_scrollbar_reserve(wp)) { + if (w->sb_pos == PANE_SCROLLBARS_LEFT) + sb_left = sb_sy->width + sb_sy->pad; + else + sb_right = sb_sy->width + sb_sy->pad; + } + x0 = xoff - 1 - sb_left; + x1 = xoff + sx + sb_right; + y0 = yoff - 1; + y1 = yoff + sy; + if (x0 < 0) + x0 = 0; + if (y0 < 0) + y0 = 0; + if (x1 >= x0 && y1 >= y0) + redraw_damage_window(w, x0, y0, x1 - x0 + 1U, y1 - y0 + 1U); +} + +/* Report damage for a floating pane's old and new areas. */ +void +window_redraw_floating_pane(struct window_pane *wp, int oxoff, int oyoff, + int osx, int osy) +{ + window_damage_floating_pane(wp, oxoff, oyoff, osx, osy); + window_damage_floating_pane(wp, wp->xoff, wp->yoff, wp->sx, wp->sy); + server_status_window(wp->window); +}