From 86f857d1f311697898423a38190ac2f41c1e22f3 Mon Sep 17 00:00:00 2001 From: Michael Grant Date: Tue, 13 Oct 2026 11:09:49 +0200 Subject: [PATCH] Fix output folding across resize and refresh Keep backing row mappings and selection endpoints correct after wrapping. Preserve exit status during reflow and collapsed state during refresh. Clip the gutter to fit narrow panes. Add regression coverage for these cases. --- grid.c | 4 + regress/collapse-output-resize.sh | 89 ++++++++++++ regress/copy-mode-gutter-resize.sh | 70 ++++++++++ window-copy.c | 210 ++++++++++++++++++++++++----- 4 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 regress/collapse-output-resize.sh create mode 100644 regress/copy-mode-gutter-resize.sh diff --git a/grid.c b/grid.c index cd9e8fae5..f86636354 100644 --- a/grid.c +++ b/grid.c @@ -1389,6 +1389,10 @@ grid_reflow_move_osc133(struct grid_line *to, struct grid_line *from, from->flags &= ~flag; *src_col[i] = 0; if (flag == GRID_LINE_END_OUTPUT) { + /* Keep status presence with the D marker. */ + to->flags &= ~GRID_LINE_END_OUTPUT_STATUS; + to->flags |= from->flags & GRID_LINE_END_OUTPUT_STATUS; + from->flags &= ~GRID_LINE_END_OUTPUT_STATUS; dst->exit_status = src->exit_status; src->exit_status = 0; } diff --git a/regress/collapse-output-resize.sh b/regress/collapse-output-resize.sh new file mode 100644 index 000000000..163dde1be --- /dev/null +++ b/regress/collapse-output-resize.sh @@ -0,0 +1,89 @@ +#!/bin/sh + +# Keep output mappings and individual folds across resize and refresh. + +. ./input-common.inc + +first=$(printf '%060d' 0 | tr 0 F) +second=SECOND-OUTPUT +seq="\033]133;A\007P0> \033]133;B\007first\r\n" +seq="${seq}\033]133;C\007${first}\r\n\033]133;D;7\007" +seq="${seq}\033]133;A\007P1> \033]133;B\007second\r\n" +seq="${seq}\033]133;C\007${second}\r\n\033]133;D;0\007" +seq="${seq}\033]133;A\007P2> \033]133;B\007" +commands_seq=$seq + +# Narrow output can create more backing rows than there are source rows. +for hlimit in 2000 1; do + start_pane_hlimit mapping 80 8 "$commands_seq" "$hlimit" + $TMUX copy-mode -t mapping: -U || exit 1 + $TMUX resize-window -t mapping: -x 5 || exit 1 + $TMUX send -t mapping: -X search-backward-text FFFFF || exit 1 + $TMUX set-buffer sentinel || exit 1 + $TMUX send -t mapping: -X copy-output || exit 1 + $TMUX save-buffer "$TMP" || exit 1 + printf '%s\n' "$first" >"$EXP" + cmp "$TMP" "$EXP" || fail "copy-output with history-limit $hlimit" +done + +# Selection endpoints must land on the appropriate wrapped segment. +for keys in emacs vi; do + for length in 60 61; do + payload=$(printf "%0${length}d" 0 | tr 0 F) + selection_seq="\033]133;A\007p> \033]133;B\007first\r\n" + selection_seq="${selection_seq}\033]133;C\007${payload}" + selection_seq="${selection_seq}\033]133;D;7\007\r\n" + selection_seq="${selection_seq}\033]133;A\007p> \033]133;B\007" + for width in 5 20 100; do + start_pane_history selection 80 12 "$selection_seq" + $TMUX set -w -t selection: mode-keys "$keys" || exit 1 + $TMUX copy-mode -t selection: -U || exit 1 + $TMUX resize-window -t selection: -x "$width" || exit 1 + $TMUX send -t selection: -X search-backward-text FFF || exit 1 + $TMUX set-buffer sentinel || exit 1 + $TMUX send -t selection: -X select-output || exit 1 + $TMUX send -t selection: -X copy-selection || exit 1 + $TMUX save-buffer "$TMP" || exit 1 + printf '%s' "$payload" >"$EXP" + cmp "$TMP" "$EXP" || \ + fail "$keys $length cells at width $width" + done + done +done + +# Refresh must keep a mixture of collapsed and expanded commands. +for refresh in refresh-now refresh-on; do + start_cmd refresh 80 12 \ + "printf '$commands_seq'; sleep 1; printf LIVE; sleep 1; printf LIVE; $INPUT_HOLD" + $TMUX copy-mode -t refresh: -c || exit 1 + $TMUX send -t refresh: -X search-backward-text second || exit 1 + $TMUX send -t refresh: -X expand-output || exit 1 + for width in 20 100; do + $TMUX resize-window -t refresh: -x "$width" || exit 1 + $TMUX send -t refresh: -X "$refresh" || exit 1 + sleep 1 + $TMUX send -t refresh: -X history-top || exit 1 + $TMUX capture-pane -t refresh: -Mp >"$TMP" || exit 1 + grep -q FFFFF "$TMP" && fail "$refresh expanded the first command" + grep -q "$second" "$TMP" || \ + fail "$refresh collapsed the second command" + done +done + +# Initial output and commands without C use different fold anchors. +seq='initial-control\r\nHIDDEN-INITIAL\r\n' +seq="${seq}\033]133;A\007p> \033]133;B\007silent\r\n" +seq="${seq}\033]133;D;7\007HIDDEN-SEPARATOR\r\n" +seq="${seq}\033]133;A\007p> \033]133;B\007" +start_pane_history initial 80 12 "$seq" +$TMUX copy-mode -t initial: -c || exit 1 +for width in 20 100; do + $TMUX resize-window -t initial: -x "$width" || exit 1 + $TMUX send -t initial: -X refresh-now || exit 1 + $TMUX send -t initial: -X history-top || exit 1 + $TMUX capture-pane -t initial: -Mp >"$TMP" || exit 1 + grep -q HIDDEN "$TMP" && fail "initial folds lost at width $width" + grep -q initial-control "$TMP" || fail 'initial control line lost' +done + +exit "$exit_status" diff --git a/regress/copy-mode-gutter-resize.sh b/regress/copy-mode-gutter-resize.sh new file mode 100644 index 000000000..2d988cdb0 --- /dev/null +++ b/regress/copy-mode-gutter-resize.sh @@ -0,0 +1,70 @@ +#!/bin/sh + +# Exercise clipped gutter drawing through a real client. + +PATH=/bin:/usr/bin +TERM=screen +export TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -Li$$ -f/dev/null" +OUTER="$TEST_TMUX -Lo$$ -f/dev/null" +TMP=$(mktemp) +exit_status=0 +trap 'rm -f "$TMP"; $INNER kill-server 2>/dev/null; $OUTER kill-server 2>/dev/null' 0 1 15 + +$INNER new-session -d -x 80 -y 12 \ + "printf '\033]133;A\007p> \033]133;B\007failed\r\n\033]133;C\007012345678901234567890123456789012345678901234567890123456789\033]133;D;7\007\r\n\033]133;A\007p> \033]133;B\007'; exec sleep 100" || exit 1 +$INNER set -g status off || exit 1 +$INNER set -g window-size manual || exit 1 +$INNER set -g copy-mode-position-format '' || exit 1 +$INNER set -g copy-mode-exit-status-format \ + '#{?exit_status_present,RC=#{exit_status},}' || exit 1 +$OUTER new-session -d -x 80 -y 12 "$INNER attach" || exit 1 +$OUTER set -g status off || exit 1 +sleep 1 + +# Reflow must move the exit status presence flag with its numeric value. +for width in 20 100; do + $INNER resize-window -x "$width" || exit 1 + $INNER copy-mode -U || exit 1 + $INNER send -X history-top || exit 1 + sleep 0.3 + $OUTER capture-pane -p >"$TMP" || exit 1 + if ! grep -q RC=7 "$TMP"; then + echo "exit status disappeared at width $width" + exit_status=1 + fi + $INNER send -X cancel || exit 1 +done + +# A gutter wider than the pane must not wrap or scroll the displayed rows. +$INNER resize-window -x 20 || exit 1 +$INNER set -g copy-mode-exit-status-format \ + 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' || exit 1 +$INNER copy-mode -c || exit 1 +$INNER send -X history-top || exit 1 +sleep 0.3 +$OUTER capture-pane -p >"$TMP" || exit 1 +if ! grep -q '^XXXXXXXXXXXXXXX' "$TMP"; then + echo 'wide gutter scrolled the displayed rows' + exit_status=1 +fi +$INNER display -p '#{copy_cursor_y}' | \ + awk '$1 < 12 { found = 1 } END { exit !found }' || exit 1 + +# Line numbers must also fit when the gutter leaves no room for them. +$INNER send -X line-numbers-on || exit 1 +for width in 3 1; do + $INNER resize-window -x "$width" || exit 1 + $INNER send -X history-top || exit 1 + sleep 0.3 + $OUTER capture-pane -p >"$TMP" || exit 1 + if [ "$width" = 3 ]; then + grep -q '^1 p' "$TMP" || exit 1 + else + grep -q '^p' "$TMP" || exit 1 + fi +done + +exit "$exit_status" diff --git a/window-copy.c b/window-copy.c index 1da5fdbcc..8817d4dd4 100644 --- a/window-copy.c +++ b/window-copy.c @@ -364,7 +364,7 @@ struct window_copy_mode_data { u_char *source_flags; /* flags per source line */ u_int source_line_count; struct window_copy_line *lines; /* source of each backing line */ - u_int line_count; + u_int line_count; /* allocated backing metadata rows */ u_int sync_added; /* snapshot of backing grid counters */ u_int sync_collected; @@ -549,6 +549,28 @@ window_copy_clone_screen(struct screen *src, struct screen *hint, u_int *cx, return (dst); } +/* Grow backing metadata to cover rows added by wrapping. */ +static void +window_copy_grow_line_info(struct window_copy_mode_data *data, u_int count) +{ + u_int old_count = data->line_count, new_count, y; + + if (count <= old_count) + return; + new_count = count; + if (new_count < 2 * old_count) + new_count = 2 * old_count; + data->lines = xreallocarray(data->lines, new_count, + sizeof *data->lines); + memset(data->lines + old_count, 0, + (new_count - old_count) * sizeof *data->lines); + for (y = old_count; y < new_count; y++) { + data->lines[y].source_line = UINT_MAX; + data->lines[y].output_line = UINT_MAX; + } + data->line_count = new_count; +} + /* Get the source mapping and fold metadata for a backing line. */ static struct window_copy_line * window_copy_get_line_info(struct window_copy_mode_data *data, u_int y) @@ -996,12 +1018,9 @@ window_copy_rebuild_backing(struct window_mode_entry *wme) screen_size_y(&data->screen), screen_hlimit(src)); dst->grid->flags |= GRID_HISTORY; free(data->lines); - data->lines = xcalloc(total, sizeof *data->lines); - data->line_count = total; - for (y = 0; y < total; y++) { - data->lines[y].source_line = UINT_MAX; - data->lines[y].output_line = UINT_MAX; - } + data->lines = NULL; + data->line_count = 0; + window_copy_grow_line_info(data, total); /* Blank lines at the end would only scroll the folded view. */ last = total; while (last > 1) { @@ -1028,6 +1047,9 @@ window_copy_rebuild_backing(struct window_mode_entry *wme) sgl = grid_get_line(sgd, y); osc133 = &sgl->osc133_data; for (x = 0; x <= sgl->cellused; x++) { + /* Wrapped backing rows may outnumber source rows. */ + window_copy_grow_line_info(data, + screen_hsize(dst) + screen_size_y(dst)); dy = dst->grid->hsize + dst->cy; was_hiding = fold.hiding; fold.source_y = y; @@ -4008,6 +4030,101 @@ window_copy_cmd_search_forward_incremental(struct window_copy_cmd_state *cs) return (action); } +/* Copy collapse flags to matching output markers in a refreshed source. */ +static u_char * +window_copy_clone_fold_flags(struct window_mode_entry *wme, struct screen *src) +{ + struct window_copy_mode_data *data = wme->data; + struct grid *old = data->source->grid; + struct grid *gd = src->grid, *rgd; + struct grid_line *gl, *new_gl, *rgl; + struct screen *resized; + u_char *flags; + u_int y, x, new_y, wx = 0, wy = 0; + u_int reflow_y = 0, reflow_col = 0; + u_int reflow_row = 0; + u_int col, flag, collected, first_prompt; + u_int total = gd->hsize + gd->sy; + + flags = xcalloc(total, sizeof *flags); + + /* Expanded views have no old marker positions to preserve. */ + if (!window_copy_any_fold_collapsed(data)) + return (flags); + collected = wme->swp->base.grid->scroll_collected - + data->sync_collected; + + /* Reflow a copy of the old source to locate its surviving markers. */ + resized = window_copy_clone_screen(data->source, src, NULL, NULL, 0); + rgd = resized->grid; + for (y = 0; y < data->source_line_count; y++) { + gl = grid_get_line(old, y); + flag = 0; + col = 0; + if (gl->flags & GRID_LINE_START_OUTPUT) { + flag = GRID_LINE_START_OUTPUT; + col = gl->osc133_data.out_start_col; + } else if (gl->flags & GRID_LINE_END_OUTPUT) { + flag = GRID_LINE_END_OUTPUT; + col = gl->osc133_data.out_end_col; + } + if ((data->source_flags[y] & WINDOW_COPY_FOLD_COLLAPSED) && + flag != 0) { + /* Find this marker's reflowed row and column. */ + while (reflow_y + 1 < rgd->hsize + rgd->sy) { + rgl = grid_get_line(rgd, reflow_y); + if (reflow_row == wy && + ((~rgl->flags & GRID_LINE_WRAPPED) || + wx + col < reflow_col + rgl->cellused)) { + break; + } + if (rgl->flags & GRID_LINE_WRAPPED) + reflow_col += rgl->cellused; + else { + reflow_col = 0; + reflow_row++; + } + reflow_y++; + } + x = wx + col - reflow_col; + new_y = reflow_y; + + /* Skip markers collected from history. */ + if (new_y >= collected && new_y - collected < total) { + new_y -= collected; + new_gl = grid_get_line(gd, new_y); + if (flag == GRID_LINE_START_OUTPUT) + col = new_gl->osc133_data.out_start_col; + else + col = new_gl->osc133_data.out_end_col; + if ((new_gl->flags & flag) && col == x) { + flags[new_y] = + WINDOW_COPY_FOLD_COLLAPSED; + } + } + } + + /* Include marker boundaries after the last cell. */ + if (gl->flags & GRID_LINE_WRAPPED) + wx += gl->cellused; + else { + wx = 0; + wy++; + } + } + screen_free(resized); + free(resized); + + /* Keep the initial fold if both sources retain leading output. */ + first_prompt = window_copy_first_prompt(old); + if (collected == 0 && first_prompt != 0 && first_prompt != UINT_MAX) { + first_prompt = window_copy_first_prompt(gd); + if (first_prompt != 0 && first_prompt != UINT_MAX) + flags[0] = data->source_flags[0]; + } + return (flags); +} + /* * Reconcile the backing screen with the live pane, incrementally if possible * and otherwise by recloning, then reposition the view. When following, jump @@ -4019,23 +4136,34 @@ window_copy_do_refresh(struct window_mode_entry *wme, int follow) { struct window_pane *wp = wme->swp; struct window_copy_mode_data *data = wme->data; + struct screen *source; + u_char *flags; u_int oy_from_top; - int refold; if (data->oy > screen_hsize(data->backing)) data->oy = screen_hsize(data->backing); oy_from_top = screen_hsize(data->backing) - data->oy; if (!window_copy_sync_backing(wme)) { - refold = data->fold_view; - if (refold) - window_copy_destroy_fold_view(wme); - screen_free(data->backing); - free(data->backing); - data->backing = window_copy_clone_screen(&wp->base, - &data->screen, NULL, NULL, wme->swp != wme->wp); - if (refold) - window_copy_create_fold_view(wme); + if (data->fold_view) { + /* Preserve folds before replacing the old source. */ + source = window_copy_clone_screen(&wp->base, + &data->screen, NULL, NULL, wme->swp != wme->wp); + flags = window_copy_clone_fold_flags(wme, source); + screen_free(data->source); + free(data->source); + free(data->source_flags); + data->source = source; + data->source_flags = flags; + data->source_line_count = screen_hsize(source) + + screen_size_y(source); + window_copy_rebuild_backing(wme); + } else { + screen_free(data->backing); + free(data->backing); + data->backing = window_copy_clone_screen(&wp->base, + &data->screen, NULL, NULL, wme->swp != wme->wp); + } } if (follow) { @@ -4363,7 +4491,8 @@ window_copy_find_backing_position(struct window_mode_entry *wme, u_int source_x, { struct window_copy_mode_data *data = wme->data; struct window_copy_line *line; - u_int y, total; + struct grid_line *gl; + u_int x, y, total; if (!data->fold_view) { *backing_x = source_x; @@ -4376,14 +4505,20 @@ window_copy_find_backing_position(struct window_mode_entry *wme, u_int source_x, if (line == NULL || line->source_line != source_y) continue; if (source_x >= line->source_col) { - *backing_x = line->backing_col + source_x - - line->source_col; + x = line->backing_col + source_x - line->source_col; } else { if (line->source_col - source_x > line->backing_col) - return (0); - *backing_x = line->backing_col - (line->source_col - - source_x); + continue; + x = line->backing_col - (line->source_col - source_x); } + + /* A source row may span several backing rows after wrapping. */ + gl = grid_get_line(data->backing->grid, y); + if (x > gl->cellused || + (x == gl->cellused && (gl->flags & GRID_LINE_WRAPPED))) { + continue; + } + *backing_x = x; *backing_y = y; return (1); } @@ -6914,11 +7049,16 @@ static u_int window_copy_left_margin(struct window_mode_entry *wme) { struct window_copy_mode_data *data = wme->data; - u_int width; + u_int width, sx; + sx = screen_size_x(&data->screen); width = window_copy_line_number_width(wme); if (data->fold_view) width += data->output_status_width + 2; + + /* Leave at least one column for text in a narrow pane. */ + if (data->fold_view && width >= sx) + width = sx - 1; return (width); } @@ -7063,11 +7203,17 @@ window_copy_write_gutter(struct window_mode_entry *wme, struct window_copy_mode_data *data = wme->data; struct window_copy_line *line; struct grid_cell control_gc; - u_int backing_y, i, width; + u_int backing_y, i, width, status_width, sx; char control, *status; - /* Reserve the status field, one control column and a trailing space. */ - width = data->output_status_width + 2; + /* Clip the gutter to leave one text column and prevent wrapping. */ + sx = screen_size_x(ctx->s); + if (line_width >= sx - 1) + return; + width = window_copy_left_margin(wme) - line_width; + status_width = 0; + if (width > 2) + status_width = width - 2; /* Find the metadata for the backing row currently displayed at py. */ backing_y = screen_hsize(data->backing) - data->oy + py; @@ -7082,7 +7228,7 @@ window_copy_write_gutter(struct window_mode_entry *wme, /* Draw the control or tree symbol after the reserved status field. */ if (control != ' ') { screen_write_cursormove(ctx, - line_width + data->output_status_width, py, 0); + line_width + status_width, py, 0); memcpy(&control_gc, gc, sizeof control_gc); /* Draw tree symbols with ACS; +/- use ordinary text. */ @@ -7095,11 +7241,13 @@ window_copy_write_gutter(struct window_mode_entry *wme, screen_write_putc(ctx, &control_gc, control); } /* Show a command's exit status only on its fold control row. */ - if (line == NULL || (~line->flags & WINDOW_COPY_LINE_CONTROL)) + if (line == NULL || (~line->flags & WINDOW_COPY_LINE_CONTROL) || + status_width == 0) { return; + } status = window_copy_expand_exit_status(wme, line); screen_write_cursormove(ctx, line_width, py, 0); - format_draw(ctx, gc, data->output_status_width, status, NULL, 0); + format_draw(ctx, gc, status_width, status, NULL, 0); free(status); } @@ -7127,6 +7275,8 @@ window_copy_write_line(struct window_mode_entry *wme, /* Leave room for both line numbers and the fold/status gutter. */ line_width = window_copy_line_number_width(wme); width = window_copy_left_margin(wme); + if (data->fold_view && line_width > width) + line_width = width; if (width >= sx) content_sx = 1; else if (width != 0)