image: extend existing placements when a pane grows wider

image_write() only ever creates spans for as much of an image as fit in
the pane at the time it was placed - the rest of the image's pixels are
still retained (struct image is immutable and kept for as long as any
placement references it), but nothing revisited that clipping decision,
so a pane that was too narrow when an image was displayed stayed clipped
forever, even after growing wide enough to fit the rest. Unlike height,
which recovers via ordinary scrollback, there is no equivalent "scroll
right" - this is the only way the extra width is ever recovered.

image_grid_resize_width() finds every placement referenced in a grid's
rows, works out how far its spans already reach and its origin column,
and extends them (via a new image_extend_row(), factored out of
image_write()'s own span-building loop) up to whichever is smaller: the
image's own full width or the new pane width. window_pane_resize() calls
it after a pane grows wider.
This commit is contained in:
Michael Grant
2026-08-22 21:19:35 +01:00
parent ebb44d4715
commit e40ef98b1e
4 changed files with 221 additions and 0 deletions

106
image.c
View File

@@ -1476,6 +1476,112 @@ image_cell_has_alpha(struct image *im, u_int x, u_int y)
return (0);
}
/* Add spans for one row of a placement between two source columns. */
static void
image_extend_row(struct image_line *line, struct image_placement *placement,
u_int cx, u_int source_y, u_int old_end, u_int new_end)
{
struct image *im = placement->image;
u_int x, run;
for (x = old_end; x < new_end; x += run) {
if (!image_cell_has_alpha(im, x, source_y)) {
run = 1;
continue;
}
for (run = 1; x + run < new_end; run++) {
if (!image_cell_has_alpha(im, x + run, source_y))
break;
}
image_span_add(line, placement, cx + x, run, x, source_y);
}
}
/*
* Extend existing image placements to reveal more of their original width
* after a pane has grown wider.
*
* image_write() (below) only creates spans for as much of an image as fit
* in the pane at the time it was placed - the rest of the image's pixels
* are still retained (struct image is immutable and kept for as long as
* any placement references it), but nothing ever revisits that clipping
* decision, so a pane that was too narrow when an image was displayed
* stays clipped forever, even after growing wide enough to fit the rest.
* Unlike height, which recovers via ordinary scrollback (image_write()
* scrolls rather than clips when a placement is taller than the pane),
* there is no equivalent "scroll right" - this is the only way the extra
* width is ever recovered.
*
* For every grid row with image spans, this finds each distinct placement
* referenced there, works out how far its spans already reach (source_x +
* width) and its origin column (a span's x - source_x, which is the same
* for every span of the same placement), and adds spans for any newly
* revealed columns up to whichever is smaller: the image's own full width
* or the new pane width.
*/
void
image_grid_resize_width(struct grid *gd, u_int new_sx)
{
struct grid_line *gl;
struct image_line *line;
struct image_span *span;
struct image_placement *placement;
struct image_placement *seen[64];
u_int nseen, i, row, cx, end_x, avail;
u_int source_y;
int found;
if (gd->images == NULL)
return;
for (row = 0; row < gd->hsize + gd->sy; row++) {
gl = &gd->linedata[row];
line = gl->images;
if (line == NULL)
continue;
nseen = 0;
TAILQ_FOREACH(span, &line->spans, line_entry) {
found = 0;
for (i = 0; i < nseen; i++) {
if (seen[i] == span->placement) {
found = 1;
break;
}
}
if (!found && nseen < nitems(seen))
seen[nseen++] = span->placement;
}
for (i = 0; i < nseen; i++) {
placement = seen[i];
cx = end_x = source_y = 0;
found = 0;
TAILQ_FOREACH(span, &line->spans, line_entry) {
if (span->placement != placement)
continue;
if (!found) {
cx = span->x - span->source_x;
source_y = span->source_y;
found = 1;
}
if (span->source_x + span->sx > end_x)
end_x = span->source_x + span->sx;
}
if (!found || cx >= new_sx)
continue;
avail = new_sx - cx;
if (avail > placement->image->sx)
avail = placement->image->sx;
if (avail <= end_x)
continue;
image_extend_row(line, placement, cx, source_y, end_x,
avail);
}
}
}
/* Place an image at the cursor using the supplied input semantics. */
static void
image_write(struct screen_write_ctx *ctx, struct image *im, u_int bg,

View File

@@ -0,0 +1,110 @@
#!/bin/sh
# Regression test: an image wider than the pane at the time it was
# displayed must show more of itself once the pane grows wide enough,
# instead of staying clipped to its original width forever.
#
# image_write() (image.c) clips an image's width to whatever fit in the
# pane when it was first displayed, and never revisits that decision -
# unlike height, which recovers naturally through ordinary scrollback,
# there is no "scroll right", so the clipped columns were permanently
# discarded. window_pane_resize() (window.c) now calls
# image_grid_resize_width() after a pane grows wider, which extends each
# existing placement's spans - using the image's own retained, immutable
# pixel data - up to whichever is smaller: the image's full width or the
# new pane width. See tmux-image-redraw-known-bugs.md for the full
# write-up.
#
# This is checked via the SIXEL raster widths reported in the client's raw
# output before and after widening the window. The redraw triggered by the
# resize is damage-based (only the newly-uncovered columns are dirtied), so
# it does not redraw the whole row as one wider raster - it sends the
# already-correct clipped portion's width again untouched, plus a *second*,
# separate raster covering just the newly-added columns. So rather than
# looking for a single wider raster, this checks that the widths seen
# across both redraws, added together, account for the fixture's full
# pixel width - i.e. the previously-clipped remainder actually appeared.
PATH=/bin:/usr/bin
TERM=screen
LC_ALL=C.UTF-8
export TERM LC_ALL
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
TMUX="$TEST_TMUX -LtestA$$ -f/dev/null"
TMUX2="$TEST_TMUX -LtestB$$ -f/dev/null"
cleanup()
{
$TMUX kill-server >/dev/null 2>&1
$TMUX2 kill-server >/dev/null 2>&1
}
fail()
{
echo "$*" >&2
cleanup
exit 1
}
cleanup
TMP=$(mktemp)
trap "cleanup; rm -f $TMP" 0 1 15
FIXTURE=$(pwd)/monkey-2.sixel.txt
FULL=$(grep -oa '"1;1;[0-9]*;[0-9]*' $FIXTURE | head -1 | cut -d';' -f3)
[ -n "$FULL" ] || fail "could not read fixture's own raster width"
# The fixture is 360px wide - narrow enough that a 12-column pane clips it,
# comfortably below the 360px it would take to show in full.
$TMUX new-session -d -s inner -x 12 -y 40 "cat '$FIXTURE'; exec sh" || exit 1
sleep 0.5
[ "$($TMUX display-message -p '#{image_support}')" = 0 ] && exit 0
$TMUX set -as terminal-features ',*:sixel' || exit 1
# 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 sends the image) has already happened, missing it.
$TMUX2 new-session -d -x 12 -y 40 || exit 1
OUTER=$($TMUX2 list-panes -F '#{pane_id}' | head -1)
[ -n "$OUTER" ] || fail "No outer pane."
# The outer client is just another tmux client receiving raw PTY output
# that happens to contain sixel DCS sequences - left alone, it would
# independently decode and place its own second, differently-sized image
# on top of the inner session's, confounding the raster-width measurement
# below. Disable sixel on the outer client so only the inner session's
# placement exists.
$TMUX2 set -as terminal-features ',*:sixel@' || fail "disable outer sixel failed"
$TMUX2 pipe-pane -t "$OUTER" -O "cat >$TMP" || fail "pipe-pane failed"
$TMUX2 send-keys -t "$OUTER" -l "$TMUX attach -t inner" || fail "send attach failed"
$TMUX2 send-keys -t "$OUTER" Enter || fail "send enter failed"
sleep 1
before=$(grep -oa '"1;1;[0-9]*;' $TMP | grep -o '[0-9]*' | sort -n | tail -1)
[ -n "$before" ] || fail "sanity: image never reached the client"
[ "$before" -lt 360 ] || fail "sanity: image was not clipped by the narrow pane ($before)"
: >$TMP
# Widen the window well past the image's full width - resize the outer
# client, since the inner session's displayed size follows whatever
# terminal size its attached client actually has.
$TMUX2 resize-window -t "$OUTER" -x 30 -y 40 || fail "resize-window failed"
sleep 1
$TMUX refresh-client || fail "refresh-client failed"
sleep 1
# The widths seen after the resize, added together (deduplicated - the
# same chunk may legitimately be retransmitted), should account for the
# fixture's full pixel width: this is expected to fail before the fix,
# where the newly-uncovered columns are never redrawn at all and only the
# original clipped width ever appears - see the header comment.
after_total=$(grep -oa '"1;1;[0-9]*;' $TMP | grep -o '[0-9]*' | sort -nu |
awk '{s+=$1} END{print s+0}')
[ "$after_total" -gt 0 ] ||
fail "no image raster was sent at all after widening the window"
[ "$after_total" -ge $((FULL - 20)) ] ||
fail "image stayed clipped at ${before}px after widening the window (widths summed to only ${after_total}px, expected close to ${FULL}px)"
exit 0

1
tmux.h
View File

@@ -4295,6 +4295,7 @@ void image_grid_duplicate_lines(struct grid *, u_int, struct grid *,
u_int, u_int);
void image_grid_copy_area(struct grid *, u_int, u_int, struct grid *,
u_int, u_int, u_int, u_int);
void image_grid_resize_width(struct grid *, u_int);
int image_grid_line_has_images(const struct grid_line *);
int image_grid_check_area(struct grid *, u_int, u_int, u_int,
u_int);

View File

@@ -1670,6 +1670,10 @@ window_pane_resize(struct window_pane *wp, u_int sx, u_int sy)
log_debug("%s: %%%u resize %ux%u", __func__, wp->id, sx, sy);
screen_resize(&wp->base, sx, sy, wp->base.saved_grid == NULL);
#ifdef ENABLE_IMAGES
if (sx > r->osx)
image_grid_resize_width(wp->base.grid, sx);
#endif
wme = TAILQ_FIRST(&wp->modes);
if (wme != NULL && wme->mode->resize != NULL)