Compare commits

..

31 Commits

Author SHA1 Message Date
Michael Grant
3a17365322 Merge branch 'margins-terminal-detection' into redraw-damage-rectangles 2026-09-23 09:02:30 +01:00
Michael Grant
6933bc417c tty: drop WT_SESSION-based Windows Terminal detection
nicm is opposed to identifying a terminal via an environment variable, so
remove the WindowsTerminal tty_default_features() entry and the
WT_SESSION check in tty_term_create() entirely, pending some other
accepted identification mechanism for Windows Terminal (it cannot be
identified via the existing XTVERSION path at all - see the previous
commit's now-removed comment for why).

This leaves WezTerm and ghostty's margins grants in place - both are
identified via the existing, already-accepted XTVERSION mechanism, with
DECSLRM support confirmed directly in their own source, independent of
this change.

regress/tty-margins-wt-session.sh tested only the removed mechanism and
is replaced by regress/tty-margins-scrollbar.sh, which checks the same
underlying scrollbar/margins scroll-decision mechanism (still real, and
still what WezTerm's and ghostty's table entries rely on) via the
generic terminal-features option instead of simulating any one
terminal's identification handshake.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:02:13 +01:00
Michael Grant
238067acbf Merge branch 'margins-terminal-detection' into redraw-damage-rectangles
Brings in the WezTerm/ghostty/Windows Terminal DECSLRM (margins) coverage
from margins-terminal-detection (PR #5630), plus whatever independent
upstream progress that branch's master base had accumulated since this
branch last merged from master.

Conflict in screen-redraw.c's redraw_draw_pane_prompt(): this branch's own
earlier refactor (extracting redraw_make_pane_prompt(), reused by both the
normal redraw path and the Finding-2 damage-composition fix) collided with
an independent upstream fix on master (nicm, "Get x and y the right way
round, fixes crash when opening a mode prompt when the client is offset
into the window") that swapped tty_draw_line()'s px/py argument order in
this same function - this branch had diverged before that fix landed and
still had the pre-fix (buggy) order. Resolved by keeping this branch's
own refactor with the corrected argument order, verified against
tty_draw_line()'s signature (px, py - the prompt's screen is always
exactly one row, so py must be 0 and px must be the horizontal offset;
the pre-fix order passed the offset as the row instead, matching the
described crash). The other, structurally identical call site added this
session for Finding 2 (redraw_damage_draw_pane_prompt()) already had the
correct order and needed no change.

Verified: build clean; the new tty-margins-wt-session.sh and this
session's existing wide-character/prompt/cross-client tests all pass
(3x each); full regress suite passes clean twice, with only the same
pre-existing, unrelated failures already characterized this session
(prompt-words-history.sh, and untracked image-*-noflash scratch tests
from unrelated work, absent from this branch's own tracked suite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:49:27 +01:00
Michael Grant
20f76fb069 tty: broaden DECSLRM (margins) terminal-feature coverage
Any pane that doesn't span the terminal's full width - because
pane-scrollbars is on (the scrollbar occupies a column) or the pane is one
of a side-by-side split - needs DECSLRM (left/right margin) support to use
the fast native-scroll path (tty_cmd_linefeed()/scrollup()/scrolldown()/
reverseindex(), tty.c: "(!tty_full_width(tty, ctx) && !tty_use_margin(tty))").
Without it, every single scroll falls back to tty_redraw_region()'s full
manual repaint of the whole region - confirmed via -vv log
("tty_redraw_region: ... large region redraw") - which is a real source of
flicker, and separately implicated in reports of image content not
surviving a scroll in this area of the tree.

The DECSLRM machinery itself (tty_margin_pane()/tty_margin()) is already
built and already called from all four native-scroll dispatch functions.
What was missing was terminal-capability coverage:

  - WezTerm and ghostty are already correctly identified via the existing
    XTVERSION mechanism (tty_keys_extended_device_attributes(),
    tty-keys.c), and both genuinely implement DECSLRM - confirmed directly
    in their own source (WezTerm: decslrm() in csi.rs, documented in their
    escape-sequences reference, a changelog entry fixing a DECSLRM bug
    confirming active use; ghostty: handled in dcs.zig/stream.zig,
    explicitly emitted alongside DECSTBM in their own formatter.zig, with
    its own terminfo entry for it). Neither's entry in
    tty_default_features()'s table (tty-features.c) granted "margins".
    This was a gap, not a detection problem, backed by source-level
    evidence rather than guesswork - kitty and Rio show zero DECSLRM
    references in their own source, so their omission is left as-is.

  - Windows Terminal's DECSLRM support was independently confirmed by
    direct, non-tmux escape-sequence testing, but it can never be
    identified via XTVERSION - the tracking issue for that
    (github.com/microsoft/terminal#18382) was explicitly closed
    not_planned by the maintainer, calling XTVERSION "not extensible or
    helpful for feature detection". Detected instead via the WT_SESSION
    environment variable Windows Terminal sets for every child process -
    a long-standing, stable signal already used for this exact purpose by
    many other tools. Checked in tty_term_create() (tty-term.c) alongside
    the existing COLORTERM-based RGB/256 detection, which already reads a
    named variable out of the attaching client's own environment
    (c->environ, populated via the MSG_IDENTIFY_ENVIRON handshake) the
    same way. Deliberately grants only "margins" for now, not the full
    modern-xterm feature bundle other entries get, since no other
    capability has been verified for it.

New regress/tty-margins-wt-session.sh reproduces the scrollbar-pane
scenario with WT_SESSION injected into the attaching client's environment
and checks, via the -vv log, that no tty_redraw_region() fallback occurs -
plus a sanity phase confirming the same scenario does fall back without
WT_SESSION set, proving the test isn't accidentally trivial. Verified
failing 3/3 against the pre-fix code and passing 5/5 standalone; full
regress suite passes clean twice, with only pre-existing, unrelated
failures (check-names.sh - confirmed identical on unmodified master;
prompt-words-history.sh - already-known flake; four untracked
image-*-noflash scratch tests left over from unrelated work on another
branch, not part of this tree's tracked suite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:37:06 +01:00
Michael Grant
d4adce7e00 screen-redraw: use the prompt's own screen to clip its damage range
redraw_damage_draw_pane_prompt() recomposes a pane's separately rendered
prompt (wp->prompt) over a damaged sub-range of a REDRAW_SPAN_PANE span,
reusing the (x, n) range that redraw_client_damage() already clipped and
grew against the pane's own *content* grid (wp->screen) via
redraw_damage_grow_span_clip(). That growing exists specifically to avoid
splitting a wide character at the range's edge - but the prompt is drawn
into its own, freshly allocated one-line screen with no relationship to
the content grid, so a range that's clean (or correctly grown) for the
content can still land mid-character in the prompt's own grid.

This is invisible whenever the pane's content is plain ASCII: the content
grid has no padding cells to find, so redraw_damage_grow_span_clip() never
grows the range at all, and the raw geometric range - however it landed -
is passed straight through to the prompt. If that range's end lands right
after a base cell whose padding half falls just outside it, tty_draw_line()
has no room left for that cell's second column
(tty_draw_line_get_empty()'s gc->data.width > nx check) and blanks it
entirely, even though the pane's own content never needed the fix at all.

Reproduced with a floating pane's CJK prompt straddling the boundary
between two tiled panes underneath it: a palette change (OSC 4) in one of
the tiled panes triggers a redraw of its own rectangle, which is occluded
by the floating pane but still geometrically overlaps its prompt row,
recomposing a partial range of the prompt that cuts through a character.

Fix: extract the grid-probing tail of redraw_span_cell_is_padding() into
redraw_screen_cell_is_padding(), usable against any screen, and add
redraw_damage_grow_screen_clip() - the same left/right one-step growth as
redraw_damage_grow_span_clip(), but against an explicit screen and
column origin. redraw_damage_draw_pane_prompt() now re-derives its own
(x0, x1) range from the caller's (x, n) by growing it against the
prompt's own screen before drawing, clamped to the span so it can't bleed
into a neighbouring one.

New regress/floating-pane-prompt-wide-character.sh constructs the exact
tiled-pane-boundary scenario above and checks the CJK prompt text is
intact after the trigger. Verified failing 3/3 against the pre-fix code
(a character is blanked) and passing 5/5 standalone plus 2/2 in the full
regress suite (twice, since this touches the same damage-composition path
as every other redraw) against the fix, with no other tests newly broken
(prompt-words-history.sh and the untracked image-support scratch tests
are pre-existing, unrelated failures).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:16:25 +01:00
Michael Grant
04dbc6d89b screen-redraw: don't let one client's pane title leak to another
redraw_damage_refresh_status() force-regenerates a pane's border-status
title when a damage rectangle touches it (window_make_pane_status()'s
own content-diff cache can't tell "physically disturbed" from "never
changed"), guarded by the per-pane PANE_NEWSTATUS flag. But the
rendered content is per-client - window_make_pane_status() formats
pane-border-format using the requesting client's own context, so
fields like #{client_name} genuinely differ per client - while
wp->status_screen/PANE_NEWSTATUS are shared by every client viewing
the pane. With two clients attached to the same session, whichever
client's damage pass ran first rendered its own text and set the flag;
every other client's damage pass in that tick, or any later one, since
nothing else clears the flag on this path, found it already set and
skipped rendering - silently reusing the first client's text.

Fix: a per-pass serial (redraw_status_serial, bumped once per
redraw_client_damage() call - one call is one client's one redraw
pass) instead of a sticky flag. Deduplicates repeated calls within the
same client's same pass exactly as before, but forces a fresh,
correctly-client-formatted render whenever a different client or a
later pass touches the same pane's status - without needing to track
and later invalidate a client pointer with its own lifetime.

Also added a permanent log_debug() line for the actual regenerate,
since this class of bug (a damage pass silently trusting stale
per-pane state that should have been per-client) is otherwise
invisible to any external capture: an unrelated periodic per-client
status refresh reliably repaints each client's title correctly again
within the very same tick, before anything is ever flushed to either
terminal, so the wrong content this bug produces was never actually
observable in a capture-pane-based test - confirmed by direct
instrumentation while building the regression test below, which is
exactly why the fix is verified via this log rather than a capture.

regress/floating-pane-status-cross-client.sh attaches two clients to
one session, each with its own pane-border-format referencing
#{client_name}, and triggers a damage-only palette update (OSC 4) in
a tiled pane whose geometry overlaps a floating pane's own
border-status row - carefully picked so the trigger has no side
effect that would otherwise force a normal, already-correct, full
per-client status re-render in the same pass, which would mask the
result either way. Verified failing 3/3 against the pre-fix code
(neither client's damage pass regenerates at all - both silently
reuse whatever a much earlier full redraw left in the shared buffer)
and passing 5/5 against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:13:53 +01:00
Michael Grant
104a0cee99 server-client: fix damage being composed twice per redraw pass
`(~c->flags & CLIENT_ALLREDRAWFLAGS)` - for a multi-bit mask, ~x & MASK
means "at least one of these bits is unset" (almost always true), not
"none of these bits are set" as the comment and surrounding logic
clearly intend. Every floating-pane drag command unconditionally sets
CLIENT_REDRAWBORDERS (server_redraw_window_borders(), called from
cmd-resize-pane.c/cmd-join-pane.c/cmd-split-window.c) alongside
reporting window damage, so this fallback fired on every single drag
step: redraw_client_damage(c) ran here, then ran again a few lines
later at the CLIENT_ALLREDRAWFLAGS block for the exact same
rectangles. Confirmed via the server's own -vv log: a 6-step drag
produced 12 "composing damage" lines in matched pairs (identical
position and size, milliseconds apart) with no fix, 6 with it.

No memory-safety issue - redraw_client_damage() only reads w->damage,
never frees it - just wasted work rendering the same rectangles twice
per pass.

Fix: `(c->flags & CLIENT_ALLREDRAWFLAGS) == 0`, matching the comment's
actual intent.

regress/floating-pane-drag-no-double-composite.sh drags a floating
pane and asserts no two consecutive "composing damage" log lines share
the same position and size (comparing both together, since distinct
drag steps commonly share the same rectangle size and only the
position differs). Verified failing 3/3 against the pre-fix code (all
6 rectangles doubled each run) and passing 5/5 against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:09:28 +01:00
Michael Grant
a7f74730d7 server-client: don't let a drag's own sync-start defer its redraw
server_client_key_callback()'s mouse-drag dispatch opens a
synchronized-output frame (tty_sync_start()) before running the drag
callback, on every single drag motion event - deliberately, so a fast-
path write and any later damage-composed correction land in one atomic
terminal update instead of two visible frames. But
server_client_check_redraw() later in the same pass checks
EVBUFFER_LENGTH(tty->out) != 0 to decide whether to defer this pass's
redraw, and nothing drains tty->out in between (the actual write
happens later, via libevent) - so the frame-open sequence just queued
(8 bytes: "\033[?2026h" on a synchronized-output-capable terminal)
makes that check see "outstanding output" and defer against itself,
escalating the drag's damage to a full-window redraw on every single
motion event. Confirmed via the server's own -vv log: a 6-step drag
produced five "redraw deferred (8 left)" lines, one per motion event,
with no fix.

Fix: record how much was already queued at the instant the sync frame
opened (tty->sync_offset), and have the redraw check discount
anything queued after that point - it's already part of the frame
this pass is committed to flushing, not a reason to defer. If the
buffer was genuinely non-empty before the frame opened, sync_offset
holds that real backlog and deferral still happens correctly.

regress/floating-pane-drag-sync-no-self-defer.sh drags a floating pane
on a synchronized-output-capable terminal and asserts the server log
never shows the self-inflicted 8-byte deferral. Verified failing 3/3
against the pre-fix code (5 occurrences per run, matching the manual
-vv repro) and passing 5/5 against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:06:09 +01:00
Michael Grant
4a1e445b38 window-copy: redraw pane styles on a scrollbar-driven focus change
window_copy_scroll() (called from both scrollbar-slider-drag paths -
window_copy_cmd_scroll_to_mouse() and copy-mode -S) calls
window_set_active_pane() to switch focus to the dragged pane, but was
the one caller in the whole codebase that did this without pairing it
with window_redraw_active_switch() first, and without falling back to
a full server_redraw_window() either. Every other window_set_active_
pane() caller does one or the other.

This used to be harmless because window_set_active_pane() itself did
an unconditional full redraw on every active-pane change - narrowing
that to borders/status-only for the non-zoomed case (this branch) made
pane *body* colours only repaint when something sets PANE_REDRAW,
which is exactly what window_redraw_active_switch() does by comparing
cached window-style/window-active-style colours. Without it, dragging
an inactive pane's scrollbar slider changes which pane is active
(borders and status update immediately) while both panes keep their
stale body colours until an unrelated redraw happens to touch them.

Fix: call window_redraw_active_switch() immediately before window_set_
active_pane(), matching the established pattern (e.g.
cmd-resize-pane.c's mouse-drag handler).

regress/window-copy-scrollbar-drag-focus-style.sh sets clearly
distinguishable window-active-style/window-style backgrounds, drags
the *inactive* pane's 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 immediately
shows the active-style colour. Verified failing 3/3 against the
pre-fix code (showing both panes with swapped/stale colours, exactly
as reported) and passing 5/5 against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:01:52 +01:00
Michael Grant
9a3aa26a33 screen-write: clip negative floating-pane offsets before reporting damage
screen_write_redraw_cb() passed wp->xoff/wp->yoff straight through as
u_int to redraw_damage_window(). Both are genuinely signed and can be
negative for a floating pane positioned partly off the window's left
or top edge (layout_floating_args_parse() explicitly allows -X/-Y
down to -sx/-sy). A negative value wraps to a huge u_int,
redraw_damage_window()'s first bounds check (x >= w->sx) rejects the
whole rectangle, and nothing gets redrawn - not even the pane's
visible portion.

This is broader than just the alternate-screen-exit case that first
surfaced it: screen_write_pane_is_obscured() routes any scrolling
output in such a pane through this same callback, so a partly
off-screen floating pane lost every scroll repaint, not just its
post-alternate-screen one.

Fixed at the call site (matching the existing correct reference
pattern in window_pane_damage_floating(), window.c): compute in signed
int, clip negative offsets to the window's own origin and shrink the
corresponding size to match, then convert to u_int only once the
rectangle is known to be sane. Left redraw_damage_window()'s own
signature alone - it has a second caller with an inclusive-bounds
convention that a signature change would need to reconcile, and the
bug is specific to this call site not clamping before converting.

regress/floating-pane-offscreen-alternate-redraw.sh creates a floating
pane with -X -5 (partly off the left edge), cycles it through the
alternate screen, and checks an attached client's own received bytes
(not capture-pane, which reads the grid directly and would pass
regardless of whether the client was ever told to redraw) show the
primary screen's content correctly restored in the pane's visible
columns. Verified failing 3/3 against the pre-fix code and passing
5/5 against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:53:32 +01:00
Michael Grant
1ce64bd087 screen-redraw: fix wide characters getting blanked on the right clip edge
redraw_damage_grow_span_clip() guards its left-edge growth against
walking onto an unrelated wide character's base cell (via
redraw_span_left_grow_ok(), commit 6f65c318), but the right edge was
left growing unconditionally, on the reasoning that "tty_draw_line()
already draws a wide character in full even when the requested range
clips off its trailing padding half" - that reasoning was wrong.

When the right edge lands cleanly on a fresh character's base cell (a
character fully outside the range), growing right pulls in only that
base column. tty_draw_line()'s tty_draw_line_get_empty() then sees
gc->data.width > nx for the truncated remainder and treats it as an
empty cell to clear via tty_draw_line_clear() - a different code path
than the leading-padding-clear the left-edge fix reasoned about, but
exactly as destructive: it blanks a character that was never inside
the damage rectangle at all.

The padding check the left-edge fix added doesn't actually care about
direction - "is the cell at this scene x-coordinate a padding cell" is
the same question whether asked of a range's start or its end - so
renamed redraw_span_left_grow_ok() to redraw_span_cell_is_padding()
and apply it symmetrically to both edges.

regress/floating-pane-drag-wide-character-right.sh mirrors the
existing left-edge test for this edge: constructs the exact column
parity needed (the vacated rectangle's right edge landing on a base
cell) deterministically rather than relying on luck, verified failing
3/3 against the pre-fix code and passing 5/5 against the fix. The
existing left-edge test continues to pass unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:47:21 +01:00
Michael Grant
6f65c31887 screen-redraw: fix wide characters getting blanked by damage-clip growth
redraw_damage_grow_span_clip() widens a composed damage rectangle's
clipped edge by one cell whenever it isn't already at the span's own
boundary, to pull a wide character's base half back into range when
the edge happens to land on its padding half. This was unconditional -
it never checked which half it was actually touching.

When the edge instead already lands cleanly on a fresh character's
base cell (nothing to pull in - that character is simply outside the
rectangle), growing left walks one cell further, into the *previous*,
unrelated character's padding half, and blanks it: tty_draw_line()
treats any leading padding cell in its draw range as proof the range
starts mid-character and clears it (the "If there is padding at the
start, we must have truncated a wide character" branch, tty-draw.c).
Net effect: redrawing a damage rectangle can destroy a wide character
sitting just outside it, on whichever side the edge's column parity
happens to be unlucky.

This surfaced via dragging a display-popup pane (now backed by a
floating pane upstream, since popups were folded into the general
floating-pane mechanism) away from wide-character content, but it is a
general bug in any damage-composed redraw, not popup-specific: an
identical drag against an ordinary floating pane reproduces it
whenever the parity lines up the same way, confirmed while building
the new regress test below. It only looked popup-specific because
display-popup's new floating-pane-backed drag moves the pane on the
very first motion event, reliably hitting the bad parity, whereas the
old (now-removed) popup.c's drag handler didn't move on the first
event and tended to land on the safe parity by chance.

Also confirmed this is not a tmux/terminal wide-character width
disagreement: utf8_width()'s only override table is emoji/regional-
indicator ranges (no CJK), so a codepoint like U+754C falls straight
through to wcwidth(); the -vv log's own "wcwidth(0754C) returned 2"
line during the repro confirms tmux and libc agree on width 2. The bug
is in the redraw-clipping logic, not the width calculation.

Fix: only grow the left edge when the cell actually there is a padding
cell (redraw_span_left_grow_ok()), for the span types that can contain
one - pane content, a pane's status line, and a menu, the only spans
drawn via tty_draw_line() against a real backing screen. Border and
scrollbar spans draw single synthesized cells directly and can never
split a wide character, so their unconditional growth is untouched.
The right edge doesn't need the same guard: tty_draw_line() already
draws a wide character in full even when the requested range clips off
its trailing padding half, so growing right is at worst redundant,
never destructive.

regress/floating-pane-drag-wide-character.sh reproduces this
deterministically with an ordinary floating pane (not a popup, since
the bug isn't popup-specific): it creates the pane, checks its real
resulting position (rather than hand-computing the border-framing
offset), and retries one column over if needed until the vacated
rectangle's left edge lands on a base cell - the bad-parity case every
earlier manual repro landed on only by chance. Verified failing 3/3
against the pre-fix code and passing 5/5 against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 08:42:25 +01:00
Michael Grant
bc20fc6da4 regress: remove tests for the now-obsolete popup-as-overlay drag mechanic
display-popup is now a compatibility shim over a floating pane (upstream
removed the whole overlay/popup machinery in favour of floating panes -
see the preceding merge). These three tests moved/resized a popup with
hardcoded mouse coordinates tuned to the old popup implementation's
exact positioning and clamping math; under the new floating-pane-backed
implementation the popup's actual on-screen geometry shifts slightly,
so the hardcoded grab points miss the border entirely and the popup
never moves. This isn't a redraw regression: the underlying drag
mechanism (resize-pane -M / move-pane -M) is already covered by
floating-pane-drag-scrollbar-strip.sh and still works correctly for a
popup in popup-drag-wide-character.sh, which is unaffected and stays.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:30:22 +01:00
Michael Grant
d74b980d80 regress: give check-names.sh a clean shell
The test's session used the default $SHELL - the developer's own
interactive bash, whose PS1 embeds an OSC 0 title-setter for tmux/xterm
TERM types (common on this machine, not test-controlled). That redrew
the terminal title on every prompt, clobbering the test's own
OSC-2-set-title assertions regardless of how long it waited afterwards.
Use bash --noprofile --norc, matching the pattern already used by
style-trim.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:25:07 +01:00
Michael Grant
ac4daa57c9 Merge branch 'master' into redraw-damage-rectangles
# Conflicts:
#	popup.c
#	screen-redraw.c
#	tmux.h
#	tty.c
#	window.c
2026-09-21 23:25:01 +01:00
Nicholas Marriott
541ca0e844 Merge branch 'master' into redraw-damage-rectangles 2026-09-21 11:09:55 +01:00
Michael Grant
07d40d5fa9 cmd-attach-session: redraw pane styles when changing active pane
Call window_redraw_active_switch() before selecting a pane so differing window-style and window-active-style colours are updated immediately.\n\nAdd a regression test for selecting a pane through attach-session.
2026-09-14 04:36:57 +01:00
Nicholas Marriott
80e87aaf16 Merge branch 'master' into redraw-damage-rectangles 2026-09-09 14:05:48 +01:00
Michael Grant
f41b983e04 screen-write, tty, popup, window: fix remaining untested damage gaps
Two more fixes based on Michael K. Darling's branch
(github.com/darlingm/tmux, pr5516-regression-fixes), taken as-is -
neither is caught by any test in regress/ yet, found by code review
rather than a failing test:

- screen_write_redraw_cb() (screen-write.c) reported damage for only a
  single row, using ttyctx->ocy as if every fallback redraw were a
  single-cell write. But it's also the callback for cases that can
  legitimately span many rows - a large scroll-region fallback
  (tty_redraw_region(), when tty_large_region() or the pane is
  obscured), a full reset, and entering/leaving the alternate screen.
  For those, only the top row of the affected area ever got marked as
  damaged, leaving the rest stale until an unrelated redraw happened to
  cover it. Changed the shared tty_ctx_redraw_cb typedef to carry
  (py, ny) - the actual row range - and updated every call site to pass
  the range it actually knows about, instead of hardcoding a single
  row.

- window_pane_redraw_floating() (window.c) never refreshed the status
  line after moving/resizing a floating pane, so a status format
  depending on that pane's geometry (e.g. #{pane_width}) could go
  stale until an unrelated status refresh happened. Added a
  server_status_window(w) call.

Also confirmed the window_pane_scrollbar_intersects() parameter
naming cleanup (loop -> wp) discussed earlier was already done in an
earlier "Cleanup." commit - nothing left to do there.

All 9 tests in regress/ plus the two pre-existing floating-pane tests
plus a further 19-test sweep of redraw/tty/input/sync-adjacent
regress tests pass.

Co-Authored-By: Michael K. Darling <darlingm@gmail.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:49:46 +01:00
Michael Grant
48e33179d4 server-client, popup, screen-redraw: fix damage-system regressions
Fixes the four bugs caught by the regression tests added in aed1209c
(popup-drag-status-line.sh, popup-drag-wide-character.sh,
popup-drag-pane-prompt.sh, switch-client-redraw.sh), based on fixes
from Michael K. Darling (github.com/darlingm/tmux, pr5516-regression-
fixes), reviewed and adapted:

- server-client.c: server_client_set_session()'s check for whether the
  client's window actually changed compared old->curw to s->curw, but
  when old == s these read the same, already-updated field, so a
  same-session window switch was never detected. Compare against the
  client's own cached redraw scene instead (redraw_client_has_window(),
  new in screen-redraw.c/tmux.h). Taken from darlingm as-is.

- popup.c: popup_damage() only translated a popup's client-coordinate
  rectangle into window coordinates, so a popup dragged across the
  status line never triggered a status-line redraw once it moved away -
  status_redraw()'s own "skip if content unchanged" optimization
  suppressed it, since only the popup moved, not the status content.
  Now detects overlap with the status line and forces a redraw via the
  existing (previously unused) CLIENT_REDRAWSTATUSALWAYS flag, and
  properly clips the reported rectangle to the pane area for
  status-at-top/bottom/off. Taken from darlingm as-is.

- screen-redraw.c: redraw_draw_damage_rect() clipped a span to a damage
  rectangle's raw geometric edges, which have no idea what's in the
  grid, so a clip edge could land mid-character and tear a wide
  character in half. Added redraw_damage_grow_span_clip(): widen the
  clip by one cell on each edge that isn't already at the span's own
  boundary. Reimplemented simpler than darlingm's version (which walked
  grid cells per span type via a switch and direct grid lookups) -
  since no grid cell is ever wider than two columns, an unconditional
  one-cell margin is always enough to pull a split character back in,
  with no need to inspect grid content at all.

- screen-redraw.c: redraw_draw_damage_rect() also never re-overlaid a
  pane's active in-pane prompt after drawing its underlying content, so
  damage crossing a prompt row erased it until an unrelated redraw
  restored it. Factored the existing full-redraw prompt-building code
  into a shared redraw_make_pane_prompt() helper and added
  redraw_damage_draw_pane_prompt(), which recomposes the prompt over
  the drawn range. Taken from darlingm as-is.

All 9 regression tests in regress/ now pass. redraw_damage_grow_span_clip
was verified independently by disabling it and confirming
popup-drag-wide-character.sh reproduces its original failure.

Co-Authored-By: Michael K. Darling <darlingm@gmail.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:25:29 +01:00
Michael Grant
aed1209c02 regress: add Michael K. Darling's damage/redraw regression tests
From github.com/darlingm/tmux, branch pr5516-regression-fixes. Adds 9
regression tests covering gaps found in the redraw-damage-rectangles
branch: screen-write full/region redraw fallback, same-session window
switches, wide-character clipping at damage edges, pane prompts and
status lines surviving damage, floating-pane status format refresh,
and multi-client damage delivery.

redraw-multiclient.sh is adapted here to use ASCII pane borders
(pane-border-lines simple) instead of darlingm's original UTF-8
borders: the original reliably "failed" under this test's nested
tmux-in-tmux harness (relaying through an outer tmux client) due to
that harness mis-rendering a cell that held a multi-byte UTF-8 border
character being overwritten by later plain content - confirmed to be
a nested-relay artifact, not a real bug, by replaying the identical
drag sequence against a real terminal (xterm), where it never
reproduces. ASCII borders avoid the artifact; the test still reliably
catches the real "damage consumed by only one client" bug it targets
(verified by reintroducing that bug and confirming the test fails).

The other 8 tests are added verbatim from darlingm's branch. Four of
them (popup-drag-status-line.sh, popup-drag-wide-character.sh,
popup-drag-pane-prompt.sh, switch-client-redraw.sh) currently FAIL on
this branch, since the source fixes they test for have not been
merged yet - only the tests are being added here.

Co-Authored-By: Michael K. Darling <darlingm@gmail.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:14:49 +01:00
Michael Grant
6739bd03f5 Merge remote-tracking branch 'origin/master' into redraw-damage-rectangles
# Conflicts:
#	server-client.c
#	window.c
2026-08-25 08:08:17 +01:00
Michael Grant
f66eeef8a5 Added a fallback in server_client_check_redraw() as recommended by codex to catch an unserviced edge-case. 2026-08-24 13:36:34 +01:00
Michael Grant
2d220d9a7f Cleanup. 2026-08-24 13:25:12 +01:00
Michael Grant
da4895d559 popup: damage-based redraw during drag/resize instead of full client redraw
popup_handle_drag()'s MOVE and SIZE branches each called
server_redraw_client(c) unconditionally, redrawing the client's entire
window on every drag step. Report damage for just the popup's old and
new rectangle instead, via a new popup_damage() (translating from raw
client/tty coordinates into window coordinates), and set
CLIENT_REDRAWOVERLAY so the popup itself still redraws.
2026-08-22 16:54:25 +01:00
Michael Grant
b254f557e4 md-join-pane, cmd-split-window, cmd-resize-pane: use shared floating-pane
redraw instead of full client redraw

The three interactive mouse-drag paths that move or resize a floating
pane (move-pane -M's Alt-drag, split-window/new-pane's interactive
resize, and resize-pane's own border drag) each unconditionally called
server_redraw_window(w), redrawing every pane in the window for a change
that only ever disturbs the floating pane's own old and new rectangle.
Switch all three to window_pane_redraw_floating().
2026-08-22 16:53:08 +01:00
Michael Grant
b26eeb5229 window: redraw only borders/status on active-pane change
window_set_active_pane() unconditionally called server_redraw_window(w)
on every active-pane change, redrawing every pane's content even though
only the previous and new active pane's border/status appearance
actually changed. Unzooming (which does change every pane's geometry)
still gets the full redraw; otherwise this now only redraws borders and
status.
2026-08-22 16:42:56 +01:00
Michael Grant
48cdd85886 server-client: consume window damage during the normal redraw pass
redraw_client_damage() (added previously, unused until now) is called
from server_client_check_redraw()'s normal redraw pass, and
server_client_any_pane_redraw() now also checks for pending window
damage so a client with only damage (no PANE_REDRAW/PANE_REDRAWSCROLLBAR
flags) still gets its redraw pass run.

server_client_check_redraw() now returns whether the redraw was deferred
(waiting for outstanding tty output to drain) rather than performed. A
deferred redraw no longer escalates to a full CLIENT_REDRAWWINDOW to
avoid losing what was pending - server_client_loop() now only clears
PANE_REDRAW, PANE_REDRAWSCROLLBAR and window damage once every client
viewing a window actually drew this pass (tracked via a new per-window
redraw_deferred flag), otherwise they're left in place and retried in
their normal, narrowly-scoped form.

server_client_set_session() now redraws only if the client's session or
current window actually changed, not on every call (e.g. switch-client
-t= from clicking a pane name in the status line resolves here even
when nothing besides the active pane changed).

A drag callback's mouse_drag_update() now opens a sync region itself
(tty_sync_start()) before its first write, so a fast-path write it makes
directly and a later correction arriving via redraw_client_damage() end
up in the same atomic terminal update instead of two visible frames.
2026-08-22 16:36:25 +01:00
Michael Grant
f255f089fc window: add floating-pane damage/redraw helpers
window_pane_redraw_floating() reports damage for only a floating pane's
old and new rectangle (via redraw_damage_window(), grown by one cell to
cover its border frame - see the "floating" case in screen-redraw.c),
instead of the caller falling back to a full window redraw. Any other
pane whose *scrollbar strip* - not its whole body - intersects either
rectangle still gets PANE_REDRAWSCROLLBAR directly, since scrollbars
aren't covered by the damage system.
2026-08-22 16:32:21 +01:00
Michael Grant
d6074895bb screen-redraw, screen-write: add damage-rectangle tracking and composition
Introduce a per-window list of damaged rectangles (struct redraw_damage)
and redraw_damage_window() to record them, redraw_client_damage() to
consume them by composing exactly the damaged cells (via a new
redraw_draw_damage_rect(), which also force-refreshes any pane-status
span it touches, since window_make_pane_status()'s content-diff check
has no way to know the physical cells were disturbed by something else).

redraw_draw_span() now takes an explicit [clip_x, clip_x + clip_n) range
instead of always drawing a span's full width, so a damage rectangle can
redraw just the portion of a span it actually covers.

screen_write_redraw_cb() - the fallback when a write can't be applied
directly to the terminal - now reports damage for just the affected row
via this mechanism, instead of unconditionally flagging the whole pane
for a full redraw.
2026-08-22 15:45:29 +01:00
Michael Grant
faba411289 layout: only mark scrollbar for redraw when the pane actually changed
wp->flags |= PANE_REDRAWSCROLLBAR was set unconditionally whenever a pane
  reserved a scrollbar, even if layout_fix_panes() left its geometry
  completely unchanged - forcing a needless scrollbar redraw on every layout
  pass. Move it inside the existing "did this pane's geometry actually
  change" check.
2026-08-22 13:51:03 +01:00
27 changed files with 725 additions and 1049 deletions

View File

@@ -257,8 +257,7 @@ check_PROGRAMS = \
fuzz/input-fuzzer \
fuzz/cmd-parse-fuzzer \
fuzz/format-fuzzer \
fuzz/style-fuzzer \
fuzz/layout-fuzzer
fuzz/style-fuzzer
fuzz_input_fuzzer_LDFLAGS = $(FUZZING_LIBS)
fuzz_input_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS)
fuzz_cmd_parse_fuzzer_LDFLAGS = $(FUZZING_LIBS)
@@ -267,8 +266,6 @@ fuzz_format_fuzzer_LDFLAGS = $(FUZZING_LIBS)
fuzz_format_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS)
fuzz_style_fuzzer_LDFLAGS = $(FUZZING_LIBS)
fuzz_style_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS)
fuzz_layout_fuzzer_LDFLAGS = $(FUZZING_LIBS)
fuzz_layout_fuzzer_LDADD = $(LDADD) $(tmux_OBJECTS)
endif
# Install tmux.1 in the right format.

View File

@@ -307,7 +307,8 @@ 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, oxoff, oyoff, osx, osy;
int y, ly, x, lx;
int old_xoff, old_yoff, old_sx, old_sy;
wp = cmd_mouse_pane(m, NULL, &wl);
if (wp == NULL) {
@@ -329,16 +330,17 @@ 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;
old_xoff = wp->xoff;
old_yoff = wp->yoff;
old_sx = wp->sx;
old_sy = wp->sy;
lc->g.xoff += x - lx;
lc->g.yoff += y - ly;
layout_fix_panes(w, NULL);
window_redraw_floating_pane(wp, oxoff, oyoff, osx, osy);
window_pane_redraw_floating(w, wp, old_xoff, old_yoff, old_sx,
old_sy);
server_redraw_window_borders(w);
}
}

View File

@@ -237,8 +237,9 @@ 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, resizes = 0;
int new_xoff, new_yoff, old_xoff, old_yoff;
int left, right;
int new_xoff, new_yoff, resizes = 0;
int old_xoff, old_yoff, old_sx, old_sy;
wp = cmd_mouse_pane(m, NULL, &wl);
if (wp == NULL) {
@@ -251,6 +252,8 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c,
sy = wp->sy;
old_xoff = wp->xoff;
old_yoff = wp->yoff;
old_sx = (int)wp->sx;
old_sy = (int)wp->sy;
left = wp->xoff - 1;
right = wp->xoff + sx;
if (window_pane_scrollbar_reserve(wp) &&
@@ -350,7 +353,8 @@ cmd_resize_pane_mouse_resize_move_floating(struct client *c,
}
if (resizes != 0) {
layout_fix_panes(w, NULL);
window_redraw_floating_pane(wp, old_xoff, old_yoff, sx, sy);
window_pane_redraw_floating(w, wp, old_xoff, old_yoff, old_sx,
old_sy);
server_redraw_window_borders(w);
}
}

View File

@@ -362,7 +362,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;
int old_xoff, old_yoff, old_sx, old_sy;
if (c->tty.mouse_last_pane == -1)
return;
@@ -418,14 +418,15 @@ 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;
old_xoff = wp->xoff;
old_yoff = wp->yoff;
old_sx = wp->sx;
old_sy = wp->sy;
layout_set_size(lc, sx, sy, xoff, yoff);
layout_fix_panes(w, NULL);
window_redraw_floating_pane(wp, oxoff, oyoff, osx, osy);
window_pane_redraw_floating(w, wp, old_xoff, old_yoff, old_sx,
old_sy);
server_redraw_window_borders(w);
}

View File

@@ -1,111 +0,0 @@
/*
* Copyright (c) 2026 Arthur Chan <arthur.chan@adalogics.com>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER
* IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING
* OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
/*
* Fuzz the custom layout parser.
*
* A layout string such as "bb62,80x24,0,0{40x24,0,0,1,39x24,41,0,2}" is
* accepted by select-layout and is what tmux stores and restores for a
* window, so it is parsed from configuration and from commands. It drives
* layout-custom.c (the string parser and the checksum), then layout.c, which
* resizes and assigns the cells.
*
* layout_parse() refuses a window with no panes, and refuses a layout whose
* cell count is smaller than the pane count, so the window is given a fixed
* number of panes. The count is fixed rather than derived from the input, so
* a mutation always means a different layout string.
*/
#include <sys/types.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include "tmux.h"
#define FUZZER_MAXLEN 1024
#define FUZZER_PANES 4
struct event_base *libevent;
int
LLVMFuzzerTestOneInput(const u_char *data, size_t size)
{
struct window *w;
struct window_pane *wp;
char *buf, *cause = NULL, *dump;
u_int i;
if (size == 0 || size > FUZZER_MAXLEN)
return 0;
/* layout_parse() takes a C string. */
buf = malloc(size + 1);
if (buf == NULL)
return 0;
memcpy(buf, data, size);
buf[size] = '\0';
w = window_create(80, 24, 0, 0);
if (w == NULL) {
free(buf);
return 0;
}
window_add_ref(w, __func__);
for (i = 0; i < FUZZER_PANES; i++) {
wp = window_add_pane(w, NULL, 0, 0);
if (w->active == NULL)
w->active = wp;
}
if (layout_parse(w, buf, &cause) == 0) {
dump = layout_dump(w, w->layout_root, 1);
free(dump);
}
free(cause);
window_remove_ref(w, __func__);
free(buf);
return 0;
}
int
LLVMFuzzerInitialize(__unused int *argc, __unused char ***argv)
{
const struct options_table_entry *oe;
global_environ = environ_create();
global_options = options_create(NULL);
global_s_options = options_create(NULL);
global_w_options = options_create(NULL);
for (oe = options_table; oe->name != NULL; oe++) {
if (oe->scope & OPTIONS_TABLE_SERVER)
options_default(global_options, oe);
if (oe->scope & OPTIONS_TABLE_SESSION)
options_default(global_s_options, oe);
if (oe->scope & OPTIONS_TABLE_WINDOW)
options_default(global_w_options, oe);
}
libevent = osdep_event_init();
socket_path = xstrdup("dummy");
return 0;
}

View File

@@ -1,17 +0,0 @@
# tmux custom layout strings: "<checksum>,<sx>x<sy>,<x>,<y>{...}" / "[...]"
","
"x"
"{"
"}"
"["
"]"
"0"
"1"
"80x24,0,0"
"40x24,0,0"
"bb62,"
"cafe,"
",0,0,0"
",0,0{"
",0,0["
"@"

View File

@@ -1,2 +0,0 @@
[libfuzzer]
max_len = 1024

View File

@@ -477,9 +477,9 @@ layout_fix_panes(struct window *w, struct window_pane *skip)
wp->yoff != old_yoff ||
wp->sx != old_sx ||
wp->sy != old_sy) {
changed = 1;
if (window_pane_scrollbar_reserve(wp))
wp->flags |= PANE_REDRAWSCROLLBAR;
changed = 1;
}
}
if (changed)

View File

@@ -153,13 +153,11 @@ wait_format "$p0" '#{pane_in_mode}' '0'
wait_option @picked "$p1"
# Commands after display-panes run immediately while the mode remains.
# Record the mode in the same command queue so client startup cannot race
# the 500ms timeout.
$TMUX set -g @after none || fail "set @after failed"
$TMUX display-panes -Nd 500 -t "$p0" \; \
set -gF -t "$p0" @after '#{pane_mode}' ||
$TMUX display-panes -Nd 500 -t "$p0" \; set -g @after fast ||
fail "display-panes immediate command failed"
wait_option @after 'panes-mode'
wait_option @after fast
wait_format "$p0" '#{pane_mode}' 'panes-mode'
wait_format "$p0" '#{pane_in_mode}' '0'
# Existing zoom is restored on exit.

View File

@@ -1,135 +0,0 @@
#!/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

View File

@@ -1,6 +1,17 @@
#!/bin/sh
# Each drag motion must draw the floating pane content once.
# server_client_check_redraw() had `(~c->flags & CLIENT_ALLREDRAWFLAGS)` as
# a fallback condition guarding a call to redraw_client_damage() - for a
# multi-bit mask, `~x & MASK` means "at least one of these bits is unset"
# (almost always true), not "none of these bits are set" as the comment
# and surrounding logic clearly intend. Every floating-pane drag command
# unconditionally sets CLIENT_REDRAWBORDERS (server_redraw_window_borders()
# in cmd-resize-pane.c/cmd-join-pane.c/cmd-split-window.c) alongside
# reporting window damage, so this fallback fired on every single drag
# step, composing the exact same damage rectangle a second time a few
# lines later at the CLIENT_ALLREDRAWFLAGS block - wasted work, not a
# correctness issue, but a clean, deterministic signal to check for via
# the server's own -vv log.
PATH=/bin:/usr/bin
TERM=screen
@@ -11,7 +22,7 @@ export PATH TERM LC_ALL
DIR=$(mktemp -d) || exit 1
cd "$DIR" || exit 1
INNER="$TEST_TMUX -Ldoublecomp-inner-$$ -f/dev/null"
INNER="$TEST_TMUX -vv -Ldoublecomp-inner-$$ -f/dev/null"
OUTER="$TEST_TMUX -Ldoublecomp-outer-$$ -f/dev/null"
fail()
@@ -36,16 +47,14 @@ mouse()
sleep 0.15
}
$INNER new-session -d -s inner -x 40 -y 15 "printf '\\033[15;1HOUTSIDE'; exec sleep 100" || exit 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 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
'sleep 100') || exit 1
$OUTER new-session -d -s outer -x 40 -y 15 'sleep 100' || 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
@@ -59,18 +68,13 @@ 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.
# Top-border drag (a move): each step both reports window damage and sets
# CLIENT_REDRAWBORDERS (server_redraw_window_borders() in the caller),
# which is exactly the combination the buggy fallback misfired on.
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
while [ $i -lt $steps ]; do
GRABCOL=$((GRABCOL + 1))
mouse 32 "$GRABCOL" "$BORDERROW" M
i=$((i + 1))
@@ -79,10 +83,18 @@ 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"
[ "$NEWXOFF" != "$XOFF" ] || fail "sanity: floating pane did not move (still at $XOFF)"
LOG=$(ls tmux-server*.log 2>/dev/null | head -1)
[ -n "$LOG" ] || fail "sanity: no server -vv log was produced"
# Each drag step should compose its damage exactly once. If any rectangle
# was composed twice, the same "x,y WxH" text appears on two consecutive
# composing-damage lines - compare the position+size together, since
# distinct steps commonly share the same size (only the position differs).
dup=$(grep "composing damage" "$LOG" | awk '{print $(NF-1), $NF}' |
uniq -d | wc -l)
[ "$dup" -eq 0 ] ||
fail "$dup damage rectangle(s) were composed twice in the same pass"
exit 0

View File

@@ -137,23 +137,4 @@ 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

View File

@@ -1,6 +1,16 @@
#!/bin/sh
# Synchronized drag output must not cause a full redraw of untouched rows.
# server_client_key_callback()'s mouse-drag dispatch opens a synchronized-
# output frame (tty_sync_start()) before running the drag callback, on
# every single drag motion event. server_client_check_redraw() then checks
# EVBUFFER_LENGTH(tty->out) != 0 later in the same pass to decide whether
# to defer this pass's redraw - nothing drains tty->out in between, so the
# frame-open sequence just queued (8 bytes: "\033[?2026h") makes that check
# see "outstanding output" and defer against itself, escalating the drag's
# damage to a full-window redraw on every motion event on any
# synchronized-output-capable terminal. This checks the server's own -vv
# log for that exact self-inflicted "8 left" deferral pattern during a
# drag, and requires it never appears.
PATH=/bin:/usr/bin
TERM=screen
@@ -11,7 +21,7 @@ export PATH TERM LC_ALL
DIR=$(mktemp -d) || exit 1
cd "$DIR" || exit 1
INNER="$TEST_TMUX -Lsyncdefer-inner-$$ -f/dev/null"
INNER="$TEST_TMUX -vv -Lsyncdefer-inner-$$ -f/dev/null"
OUTER="$TEST_TMUX -Lsyncdefer-outer-$$ -f/dev/null"
fail()
@@ -36,20 +46,18 @@ mouse()
sleep 0.15
}
$INNER new-session -d -s inner -x 40 -y 15 "printf '\\033[15;1HOUTSIDE'; exec sleep 100" || exit 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 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
'sleep 100') || exit 1
$OUTER new-session -d -s outer -x 40 -y 15 'sleep 100' || 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
$INNER set-option -as terminal-features ',screen-256color:sync' || exit 1
$OUTER 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
@@ -60,18 +68,13 @@ 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.
# Plain (non-Alt) top-border drag: "MouseDrag1Border" -> resize-pane -M ->
# a move, since grabbing the top border moves rather than resizes. Several
# small steps, each its own drag-motion event and so its own pass through
# the code under test.
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
while [ $i -lt 6 ]; do
GRABCOL=$((GRABCOL + 1))
mouse 32 "$GRABCOL" "$BORDERROW" M
i=$((i + 1))
@@ -80,14 +83,13 @@ 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
[ "$NEWXOFF" != "$XOFF" ] || fail "sanity: floating pane did not move (still at $XOFF)"
LOG=$(ls tmux-server*.log 2>/dev/null | head -1)
[ -n "$LOG" ] || fail "sanity: no server -vv log was produced"
n=$(grep -c "redraw deferred (8 left)" "$LOG")
[ "$n" -eq 0 ] ||
fail "drag self-deferred against its own queued sync bytes $n time(s)"
exit 0

View File

@@ -1,7 +1,20 @@
#!/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.
# A floating pane positioned partly off the window's left/top edge (e.g.
# created with -X -5) has a negative wp->xoff/wp->yoff. screen_write_
# redraw_cb() (screen-write.c) used to pass these straight through as u_int
# to redraw_damage_window(), which wraps a negative offset to a huge value
# - redraw_damage_window()'s own bounds check then rejects the whole
# rectangle, so nothing gets redrawn, not even the pane's visible portion.
#
# This fires on returning from the alternate screen (screen_write_
# alternateoff()) among other paths. This test exercises exactly that:
# fills the pane's primary screen, switches it to the alternate screen and
# back, and checks the client actually receives the restored primary
# content in the pane's visible (on-screen) columns - using an attached
# client's own received bytes (via a nested outer client), not
# capture-pane, which reads the grid directly and would pass regardless of
# whether the client was ever actually told to redraw it.
PATH=/bin:/usr/bin
TERM=screen
@@ -44,24 +57,28 @@ wait_outer_has()
fail "outer client did not show $marker"
}
wait_visible_restored()
{
i=0
while [ "$i" -lt 50 ]; do
$OUTER capture-pane -p -t outer:0.0 >"$CAPTURE" 2>/dev/null || true
sed -n "${CONTENTROW}p" "$CAPTURE" | grep -q '^AAAAA' && return 0
sleep 0.1
i=$((i + 1))
done
fail "primary-screen content was not restored in the pane's visible columns after returning from the alternate screen"
}
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[1;1H", 'A' x 15;
sleep 2;
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[1;1H", 'B' x 15;
sleep 2;
print "\e[?1049l";
sleep 100;
PERL
@@ -70,6 +87,15 @@ $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
# Content pane spans window columns -5..9 (partly off the left edge); only
# columns 0..9 are ever visible.
FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 15 -y 5 -X -5 -Y 2 \
"perl '$EMITTER'") || exit 1
XOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_left}')
YOFF=$($INNER display-message -p -t "$FLOAT" '#{pane_top}')
[ "$XOFF" -lt 0 ] || fail "sanity: floating pane is not off-screen (xoff=$XOFF)"
CONTENTROW=$((YOFF + 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
@@ -78,25 +104,8 @@ $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
wait_outer_has AAAAA
wait_outer_has BBBBB
wait_visible_restored
exit 0

View File

@@ -1,7 +1,31 @@
#!/bin/sh
# Damage redraws must show each client's own pane status, even though the
# cached status screen is shared between clients.
# redraw_damage_refresh_status() (screen-redraw.c) force-regenerates a
# pane's border-status title when a damage rectangle touches it, guarded
# by the per-pane PANE_NEWSTATUS flag. window_make_pane_status() formats
# pane-border-format using the requesting client's own context (so e.g.
# #{client_name} differs per client), but wp->status_screen/PANE_NEWSTATUS
# are shared by every client viewing the pane. With two clients attached
# to the same session, whichever client's damage pass runs first renders
# its own text and sets the flag; the other client's damage pass, finding
# the flag already set, used to skip rendering entirely and reuse
# whatever was already there.
#
# This checks the actual server-side decision via the -vv log rather than
# a visual capture: an unrelated periodic client status-refresh reliably
# repaints each client's title correctly again within the same tick right
# after the buggy decision is made, before anything is ever flushed to
# either terminal, so the wrong content this bug produces is never
# visible to any external capture - the log is the only place the actual
# bug (or its absence) can be observed.
#
# A floating pane with its own pane-border-status is positioned so that a
# damage rectangle from an *unrelated* palette change (OSC 4) in the
# underlying tiled pane - whose own geometry spans the whole window -
# overlaps the floating pane's title row without touching its content,
# giving a damage-only trigger with no side effect that would otherwise
# force a normal (non-buggy) full per-client status re-render in the same
# pass and mask the result either way.
PATH=/bin:/usr/bin
TERM=screen
@@ -11,141 +35,83 @@ 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
cd "$DIR" || exit 1
INNER="$TEST_TMUX -vv -Lstatuscc-inner-$$ -f/dev/null"
OUTER1="$TEST_TMUX -Lstatuscc-outer1-$$ -f/dev/null"
OUTER2="$TEST_TMUX -Lstatuscc-outer2-$$ -f/dev/null"
fail()
{
echo "$*" >&2
[ -s "$CAPTURE" ] && cat "$CAPTURE" >&2
exit 1
}
cleanup()
{
$OUTER kill-server 2>/dev/null
$OUTER1 kill-server 2>/dev/null
$OUTER2 kill-server 2>/dev/null
$INNER kill-server 2>/dev/null
cd /
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'
BASEEMITTER=$DIR/base-emitter.pl
cat >"$BASEEMITTER" <<'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";
}
my $line = <STDIN>;
print "\e]4;1;rgb:11/22/33\e\\";
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 \
$INNER new-session -d -s inner -x 40 -y 10 "perl '$BASEEMITTER'" || exit 1
$INNER set-option -g status off || exit 1
$INNER set-option -g window-size manual || exit 1
$INNER set-option -g pane-border-status top || exit 1
$INNER set-option -g pane-border-format 'C=#{client_name}' || exit 1
BASE=$($INNER list-panes -t inner -F '#{pane_id}') || exit 1
FLOAT=$($INNER new-pane -d -PF '#{pane_id}' -x 20 -y 3 -X 5 -Y 4 \
'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
$OUTER1 new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1
$OUTER1 set-option -g status off || exit 1
$OUTER1 set-option -g window-size manual || exit 1
$OUTER1 set-option -g default-terminal screen-256color || exit 1
$OUTER1 respawn-pane -k -t outer:0.0 \
"$TEST_TMUX -Lstatuscc-inner-$$ -f/dev/null attach-session -t inner" ||
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
NAME1=$($INNER list-clients -F '#{client_name}') || exit 1
# 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
$OUTER2 new-session -d -s outer -x 40 -y 10 'sleep 100' || exit 1
$OUTER2 set-option -g status off || exit 1
$OUTER2 set-option -g window-size manual || exit 1
$OUTER2 set-option -g default-terminal screen-256color || exit 1
$OUTER2 respawn-pane -k -t outer:0.0 \
"$TEST_TMUX -Lstatuscc-inner-$$ -f/dev/null attach-session -t inner" ||
exit 1
sleep 0.5
ALLNAMES=$($INNER list-clients -F '#{client_name}') || exit 1
NAME2=$(echo "$ALLNAMES" | grep -v "^$NAME1\$")
[ -n "$NAME2" ] || fail "sanity: could not identify the second client"
# Let any attach-driven full redraw (and its own, non-buggy, per-client
# status render) finish completely before triggering the damage-only
# palette update.
sleep 1.5
$INNER send-keys -t "$BASE" Enter || exit 1
sleep 0.5
LOG=$(ls tmux-server*.log 2>/dev/null | head -1)
[ -n "$LOG" ] || fail "sanity: no server -vv log was produced"
n1=$(grep -c "regenerated pane .* status for $NAME1\$" "$LOG")
n2=$(grep -c "regenerated pane .* status for $NAME2\$" "$LOG")
[ "$n1" -ge 1 ] || fail "damage pass never regenerated $NAME1's own status - it reused whatever the other client's render left behind"
[ "$n2" -ge 1 ] || fail "damage pass never regenerated $NAME2's own status - it reused whatever the other client's render left behind"
exit 0

View File

@@ -131,14 +131,6 @@ 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'

View File

@@ -1,113 +0,0 @@
#!/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

View File

@@ -1,140 +0,0 @@
#!/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

View File

@@ -1,112 +0,0 @@
#!/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

View File

@@ -1,7 +1,23 @@
#!/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.
# A pane that doesn't span the terminal's full width - because
# pane-scrollbars is on (the scrollbar occupies a column) or the pane is
# one of a side-by-side split - needs DECSLRM (left/right margin) support
# to use the fast native-scroll path (tty_cmd_linefeed()/scrollup()/
# scrolldown()/reverseindex(), tty.c:
# "(!tty_full_width(tty, ctx) && !tty_use_margin(tty))"). Without it, every
# single scroll falls back to tty_redraw_region()'s full manual repaint of
# the whole region - a real, confirmed source of flicker (and, separately,
# of image content not surviving a scroll in branches with image support).
#
# tty_default_features() (tty-features.c) grants the "margins" feature to
# several terminals it can positively identify via XTVERSION/DA2 (mintty,
# iTerm2, WezTerm, ghostty, XTerm-as-VT420) - this checks the actual
# server-side scroll decision via the -vv log for the underlying mechanism
# those table entries all rely on, using the terminal-features option
# directly (which any of them - or a user's own terminal-overrides -
# ultimately feed into) rather than simulating any one terminal's
# identification handshake.
PATH=/bin:/usr/bin
TERM=screen
@@ -11,7 +27,8 @@ export PATH TERM LC_ALL
[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux)
DIR=$(mktemp -d) || exit 1
INNER="$TEST_TMUX -Lmarginsscrollbar-inner-$$ -f/dev/null"
cd "$DIR" || exit 1
INNER="$TEST_TMUX -vv -Lmarginsscrollbar-inner-$$ -f/dev/null"
OUTER="$TEST_TMUX -Lmarginsscrollbar-outer-$$ -f/dev/null"
fail()
@@ -24,70 +41,77 @@ cleanup()
{
$OUTER kill-server 2>/dev/null
$INNER kill-server 2>/dev/null
cd /
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
for margins in on off; do
$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"
wait_for_client()
{
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
CLIENT=$($INNER list-clients -F '#{client_name}' 2>/dev/null)
[ -n "$CLIENT" ] && return 0
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"
fail "inner client did not attach"
}
run_scroll_phase()
{
label=$1
margins=$2
rm -f tmux-server*.log
$INNER new-session -d -s inner -x 40 -y 6 'exec sh' || exit 1
$INNER set -g status 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 ',*:margins' || exit 1
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 new-session -d -x 40 -y 6 || exit 1
OUTERPANE=$($OUTER list-panes -F '#{pane_id}') || 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 send-keys -t "$OUTERPANE" -l "$INNER attach -t inner" || exit 1
$OUTER send-keys -t "$OUTERPANE" Enter || exit 1
sleep 1
wait_for_client
i=0
while [ "$i" -lt 8 ]; do
$INNER send-keys -t inner Enter || exit 1
sleep 0.2
i=$((i + 1))
done
sleep 0.3
LOG=$(ls tmux-server*.log 2>/dev/null | head -1)
[ -n "$LOG" ] || fail "$label: sanity: no server -vv log was produced"
n=$(grep -c "tty_redraw_region.*large region redraw" "$LOG")
$OUTER kill-server 2>/dev/null
$INNER kill-server 2>/dev/null
done
echo "$n"
}
# Phase 1: margins granted - must never fall back to a full region redraw.
n_with=$(run_scroll_phase "with margins" "on")
[ "$n_with" -eq 0 ] ||
fail "with margins granted, scrolling a scrollbar-enabled pane still fell back to a full region redraw ($n_with times)"
# Phase 2: sanity check - without margins, the same scenario must actually
# hit the fallback, proving phase 1 wasn't accidentally trivial.
n_without=$(run_scroll_phase "without margins" "off")
[ "$n_without" -gt 0 ] ||
fail "sanity: without margins, scrolling a scrollbar-enabled pane never fell back to a full region redraw - this scenario no longer exercises the bug this test checks for"
exit 0

View File

@@ -204,19 +204,19 @@ struct redraw_scene {
u_int oy;
};
/* A damaged rectangle in a window. */
/* A single damaged window-coordinate rectangle. */
struct redraw_damage {
u_int x;
u_int y;
u_int sx;
u_int sy;
u_int x;
u_int y;
u_int sx;
u_int sy;
TAILQ_ENTRY(redraw_damage) entry;
TAILQ_ENTRY(redraw_damage) entry;
};
/*
* If there are more damage rectangles than this, they are collapsed into
* one.
* Cap on the number of pending damage rectangles per window before they are
* collapsed into a single rectangle covering their union.
*/
#define REDRAW_DAMAGE_MAX 16
@@ -224,15 +224,15 @@ struct redraw_damage {
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.
* Bumped once per redraw_client_damage() call (one client's one redraw
* pass) - see redraw_damage_refresh_status().
*/
static u_int redraw_status_generation;
static u_int redraw_status_serial;
/* Context for building the scene. */
struct redraw_build_ctx {
@@ -1103,7 +1103,7 @@ redraw_free_damage(struct window *w)
w->damage_count = 0;
}
/* Collapse all pending damage for a window into one rectangle. */
/* Collapse all pending damage for a window into one rectangle - its union. */
static void
redraw_collapse_damage(struct window *w)
{
@@ -1140,7 +1140,14 @@ redraw_collapse_damage(struct window *w)
w->damage_count = 1;
}
/* Record window damage, merging nearby rectangles and limiting the count. */
/*
* Record a damaged window-coordinate rectangle. Clips it to the window,
* merges it with an existing rectangle where doing so does not make the
* result substantially larger than the two combined, and collapses the
* whole list to its union once it grows past a modest cap.
*
* This only records damage - nothing consumes it yet.
*/
void
redraw_damage_window(struct window *w, u_int x, u_int y, u_int sx, u_int sy)
{
@@ -1157,6 +1164,7 @@ redraw_damage_window(struct window *w, u_int x, u_int y, u_int sx, u_int sy)
return;
TAILQ_FOREACH(rd, &w->damage, entry) {
/* Skip unless overlapping or directly adjacent. */
if (x > rd->x + rd->sx || rd->x > x + sx ||
y > rd->y + rd->sy || rd->y > y + sy)
continue;
@@ -1184,7 +1192,9 @@ redraw_damage_window(struct window *w, u_int x, u_int y, u_int sx, u_int sy)
rd->sx = sx;
rd->sy = sy;
TAILQ_INSERT_TAIL(&w->damage, rd, entry);
if (++w->damage_count > REDRAW_DAMAGE_MAX)
w->damage_count++;
if (w->damage_count > REDRAW_DAMAGE_MAX)
redraw_collapse_damage(w);
}
@@ -1500,10 +1510,14 @@ redraw_draw_menu_span(struct redraw_draw_ctx *dctx,
tty_draw_line(tty, s, px, span->data.m.py, n, x, y, NULL);
}
/* Draw a span. */
/*
* Draw a span, restricted to [clip_x, clip_x + clip_n) - a caller drawing
* the whole span passes the span's own x/width here; a caller drawing only
* a damaged sub-range passes that range instead.
*/
static void
redraw_draw_span(struct redraw_draw_ctx *dctx, struct redraw_span *span,
u_int y)
u_int y, u_int clip_x, u_int clip_n)
{
struct redraw_span_data *data = &span->data;
enum redraw_span_type type = data->type;
@@ -1513,21 +1527,21 @@ redraw_draw_span(struct redraw_draw_ctx *dctx, struct redraw_span *span,
switch (span->data.type) {
case REDRAW_SPAN_PANE:
redraw_draw_pane_span(dctx, span, span->x, y, span->width);
redraw_draw_pane_span(dctx, span, clip_x, y, clip_n);
break;
case REDRAW_SPAN_BORDER:
case REDRAW_SPAN_EMPTY:
case REDRAW_SPAN_OUTSIDE:
redraw_draw_border_span(dctx, span, span->x, y, span->width);
redraw_draw_border_span(dctx, span, clip_x, y, clip_n);
break;
case REDRAW_SPAN_STATUS:
redraw_draw_status_span(dctx, span, span->x, y, span->width);
redraw_draw_status_span(dctx, span, clip_x, y, clip_n);
break;
case REDRAW_SPAN_SCROLLBAR:
redraw_draw_scrollbar_span(dctx, span, span->x, y, span->width);
redraw_draw_scrollbar_span(dctx, span, clip_x, y, clip_n);
break;
case REDRAW_SPAN_MENU:
redraw_draw_menu_span(dctx, span, span->x, y, span->width);
redraw_draw_menu_span(dctx, span, clip_x, y, clip_n);
break;
}
}
@@ -1562,15 +1576,19 @@ redraw_draw_pane_lines(struct redraw_draw_ctx *dctx, struct window_pane *wp,
if (flags & REDRAW_PANE) {
spans = &line->spans[REDRAW_SPAN_PANE];
TAILQ_FOREACH(span, spans, entry) {
if (span->data.p.wp == wp)
redraw_draw_span(dctx, span, cy);
if (span->data.p.wp == wp) {
redraw_draw_span(dctx, span, cy,
span->x, span->width);
}
}
}
if (flags & REDRAW_PANE_SCROLLBAR) {
spans = &line->spans[REDRAW_SPAN_SCROLLBAR];
TAILQ_FOREACH(span, spans, entry) {
if (span->data.sb.wp == wp)
redraw_draw_span(dctx, span, cy);
if (span->data.sb.wp == wp) {
redraw_draw_span(dctx, span, cy,
span->x, span->width);
}
}
}
}
@@ -1628,8 +1646,10 @@ redraw_draw_lines(struct redraw_draw_ctx *dctx, int flags)
}
}
spans = &line->spans[type];
TAILQ_FOREACH(span, spans, entry)
redraw_draw_span(dctx, span, cy);
TAILQ_FOREACH(span, spans, entry) {
redraw_draw_span(dctx, span, cy, span->x,
span->width);
}
}
}
}
@@ -1649,8 +1669,10 @@ redraw_draw_menu_lines(struct redraw_draw_ctx *dctx)
cy = dctx->status_lines + y;
else
cy = y;
TAILQ_FOREACH(span, &line->spans[REDRAW_SPAN_MENU], entry)
redraw_draw_span(dctx, span, cy);
TAILQ_FOREACH(span, &line->spans[REDRAW_SPAN_MENU], entry) {
redraw_draw_span(dctx, span, cy, span->x,
span->width);
}
}
}
@@ -1730,7 +1752,7 @@ redraw_set_draw_context(struct redraw_draw_ctx *dctx,
dctx->flags |= REDRAW_ISOLATES;
}
/* Build a pane prompt. */
/* Build a pane prompt into a one-line screen. */
static void
redraw_make_pane_prompt(struct window_pane *wp, struct screen *screen)
{
@@ -1818,8 +1840,7 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags)
redraw = status_prompt_redraw(c);
else
redraw = status_redraw(c);
if (!redraw &&
(~c->flags & CLIENT_REDRAWSTATUSALWAYS) &&
if (!redraw && (~c->flags & CLIENT_REDRAWSTATUSALWAYS) &&
!REDRAW_IS_ALL(flags)) {
flags &= ~REDRAW_STATUS;
if (flags == 0)
@@ -2005,34 +2026,162 @@ redraw_pane_scrollbar(struct client *c, struct window_pane *wp)
redraw_draw(c, wp, REDRAW_PANE_SCROLLBAR);
}
/* Rebuild damaged pane status. */
/*
* A REDRAW_SPAN_STATUS span within a damaged rectangle needs its content
* rebuilt and force-drawn regardless of whether that content has logically
* changed. window_make_pane_status()'s grid_compare() only tells us
* whether the *content* changed, not whether the physical cells were
* disturbed by something else (e.g. a floating pane sliding across this
* row) - and being inside a damage rectangle already proves that happened.
* Without this, redraw_draw_span() silently skips REDRAW_SPAN_STATUS spans
* whenever PANE_NEWSTATUS is not set, leaving a pane's border-status title
* blank until some unrelated redraw happens to touch it (e.g. a focus
* change or window resize).
*
* wp->status_screen/PANE_NEWSTATUS are per-pane, but the formatted content
* (window_make_pane_status() expands pane-border-format, which can read
* per-client fields like #{client_name}) is per-client. Gating purely on
* PANE_NEWSTATUS would let one client's damage pass render its own text,
* set the flag, and leave every other client's pass - this tick or any
* later one, since nothing else clears it here - reusing that stale,
* wrong-client text. redraw_status_serial (bumped once per
* redraw_client_damage() call, i.e. once per client per pass) still
* dedupes repeat calls within that same pass, but forces a fresh,
* correctly-client-formatted render on every distinct client/pass.
*/
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;
u_int width;
if ((wp->flags & PANE_NEWSTATUS) && g == redraw_status_generation)
if ((wp->flags & PANE_NEWSTATUS) &&
wp->status_serial == redraw_status_serial)
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;
}
if (width == 0)
return;
log_debug("%s: regenerated pane %%%u status for %s", __func__, wp->id,
dctx->scene->c->name);
window_make_pane_status(wp, dctx->scene->c, width, first);
wp->flags |= PANE_NEWSTATUS;
wp->status_serial = redraw_status_serial;
}
/* Draw a pane's prompt over a damaged span. */
/* Whether the cell at (px, py) in screen s is a padding cell. */
static int
redraw_screen_cell_is_padding(struct screen *s, u_int px, u_int py)
{
struct grid_cell gc;
if (px >= screen_size_x(s))
return (0);
grid_view_get_cell(s->grid, px, py, &gc);
return ((gc.flags & GRID_FLAG_PADDING) != 0);
}
/*
* Whether the cell at scene x-coordinate x within this span is the second
* (padding) half of a wide character - the condition under which growing a
* damage clip's edge toward it, to pull in the rest of that character, is
* correct. True unconditionally for span types with no real backing screen
* (border, scrollbar) - these only ever draw single synthesized cells, so
* growing them is always harmless. For span types with a real screen (pane
* content, a pane's status line, a menu), only true when x is actually a
* padding cell there - if x is instead the start of an unrelated,
* already-complete character, growing toward it would walk into that
* character's opposite half and corrupt it: tty_draw_line() clears a
* leading padding cell in its draw range (proof the range starts
* mid-character) and, via a different check (tty_draw_line_get_empty()'s
* gc->data.width > nx test), also clears a trailing base cell that has no
* room left for its own padding (proof the range ends mid-character) - so
* growing either edge onto a base cell is equally destructive to whatever
* character lies just outside the range, just through a different part of
* tty_draw_line().
*/
static int
redraw_span_cell_is_padding(struct redraw_span *span, u_int x)
{
struct screen *s;
u_int px, py;
switch (span->data.type) {
case REDRAW_SPAN_PANE:
s = span->data.p.wp->screen;
px = span->data.p.px + (x - span->x);
py = span->data.p.py;
break;
case REDRAW_SPAN_STATUS:
s = &span->data.st.wp->status_screen;
px = span->data.st.offset + (x - span->x);
py = 0;
break;
case REDRAW_SPAN_MENU:
s = menu_screen(span->data.m.md);
px = span->data.m.px + (x - span->x);
py = span->data.m.py;
break;
default:
return (1);
}
return (redraw_screen_cell_is_padding(s, px, py));
}
/*
* Grow a clipped span range by one cell on either edge that isn't already at
* the span's own boundary. A clip edge that lands mid-character (this is a
* damage rectangle, so its edges are geometric and have no idea what's in
* the grid) may be sitting on the second, padding half of a wide character
* whose other half falls just outside the requested range - growing by one
* cell is enough to pull the whole character back in, since no grid cell is
* ever wider than two columns, and clamping to the span's own x and width
* keeps this from bleeding into a neighbouring span. Both edges need the
* same padding check before growing: growing onto a cell that isn't padding
* (an unrelated, already-complete character just outside the range) is
* destructive on either side, not just the left - see
* redraw_span_cell_is_padding().
*/
static void
redraw_damage_grow_span_clip(struct redraw_span *span, u_int *xp, u_int *endp)
{
if (*xp > span->x && redraw_span_cell_is_padding(span, *xp))
(*xp)--;
if (*endp < span->x + span->width &&
redraw_span_cell_is_padding(span, *endp))
(*endp)++;
}
/*
* As redraw_damage_grow_span_clip(), but against an explicit screen: px0 is
* the column in that screen corresponding to span->x, py the row. Used for
* a span's separately rendered content (e.g. a pane's prompt) that isn't
* span->data.p.wp->screen (or whichever grid redraw_span_cell_is_padding()
* would otherwise consult for this span's type), and so has its own,
* unrelated wide-character boundaries at the same columns.
*/
static void
redraw_damage_grow_screen_clip(struct redraw_span *span, struct screen *s,
u_int px0, u_int py, u_int *xp, u_int *endp)
{
if (*xp > span->x &&
redraw_screen_cell_is_padding(s, px0 + (*xp - span->x), py))
(*xp)--;
if (*endp < span->x + span->width &&
redraw_screen_cell_is_padding(s, px0 + (*endp - span->x), py))
(*endp)++;
}
/* Recompose a pane's prompt over a damaged section of its display row. */
static void
redraw_damage_draw_pane_prompt(struct redraw_draw_ctx *dctx,
struct redraw_span *span, u_int y)
struct redraw_span *span, u_int y, u_int x, u_int n)
{
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;
u_int px, width, prompt_y, x0, x1;
if (wp->prompt == NULL || wp->sx == 0 || wp->sy == 0)
return;
@@ -2042,26 +2191,46 @@ redraw_damage_draw_pane_prompt(struct redraw_draw_ctx *dctx,
prompt_y = wp->sy - 1;
if (span->data.p.py != prompt_y)
return;
redraw_make_pane_prompt(wp, &screen);
/*
* x and n were clipped and grown against wp->screen, whose character
* boundaries have nothing to do with the prompt's separately
* rendered screen - realign the range on the prompt's own grid
* instead, clamped to this span so it cannot bleed into a
* neighbouring one.
*/
x0 = x;
x1 = x + n;
redraw_damage_grow_screen_clip(span, &screen, span->data.p.px, 0, &x0,
&x1);
px = span->data.p.px + (x0 - span->x);
if (px < screen_size_x(&screen)) {
width = span->width;
width = x1 - x0;
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);
tty_draw_line(tty, &screen, px, 0, width, x0, y, NULL);
}
screen_free(&screen);
}
/* Draw the spans intersecting a damaged rectangle. */
/*
* Compose exactly the cells within a damaged rectangle (already in this
* client's own scene coordinates), rather than a whole pane. For each row
* in range, every span of every type whose x-range intersects the
* rectangle is drawn restricted to just the intersected sub-range.
*/
static void
redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y,
redraw_draw_damage_rect(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;
u_int cy, yy, clip_x, clip_end, type;
if (x >= scene->sx || y >= scene->sy)
return;
@@ -2081,30 +2250,42 @@ redraw_draw_damage_rectangle(struct redraw_draw_ctx *dctx, u_int x, u_int y,
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)
clip_x = (span->x > x) ? span->x : x;
clip_end = (span->x + span->width < x + sx) ?
span->x + span->width : x + sx;
if (clip_end <= clip_x)
continue;
if (type == REDRAW_SPAN_STATUS) {
redraw_damage_refresh_status(dctx,
span->data.st.wp);
}
redraw_draw_span(dctx, span, cy);
redraw_damage_grow_span_clip(span, &clip_x,
&clip_end);
redraw_draw_span(dctx, span, cy, clip_x,
clip_end - clip_x);
if (type == REDRAW_SPAN_PANE) {
redraw_damage_draw_pane_prompt(dctx,
span, cy);
span, cy, clip_x,
clip_end - clip_x);
}
}
}
}
}
/* Draw pending window damage on this client. */
/*
* Consume a client's window's pending damage by composing exactly the
* damaged cells, after clipping each rectangle to what this client can see
* and translating it into this client's own scene coordinates.
*
* Unlike redraw_pane(), this does not redraw a whole pane's worth of cells
* for a small disturbance - only the cells within the (clipped) rectangle
* are touched, via redraw_draw_damage_rect().
*/
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;
@@ -2112,7 +2293,7 @@ redraw_client_damage(struct client *c)
if (TAILQ_EMPTY(&w->damage))
return;
redraw_status_generation++;
redraw_status_serial++;
scene = redraw_get_scene(c);
if (scene == NULL)
@@ -2120,11 +2301,6 @@ redraw_client_damage(struct client *c)
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);
@@ -2133,9 +2309,11 @@ redraw_client_damage(struct client *c)
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);
}
if (x0 >= x1 || y0 >= y1)
continue;
log_debug("%s: %s composing damage %u,%u %ux%u", __func__,
c->name, x0 - ox, y0 - oy, x1 - x0, y1 - y0);
redraw_draw_damage_rect(&dctx, x0 - ox, y0 - oy, x1 - x0,
y1 - y0);
}
}

View File

@@ -120,7 +120,18 @@ screen_write_set_cursor(struct screen_write_ctx *ctx, int cx, int cy)
evtimer_add(&w->offset_timer, &tv);
}
/* Redraw lines. */
/*
* Called when a write could not be applied directly to the terminal and
* needs a redraw instead. Report damage for the requested rows. wp->yoff is
* already adjusted past any top pane-border-status row, so wp->yoff + py is
* the correct window-coordinate row. wp->xoff/wp->yoff are signed and can be
* negative for a floating pane positioned partly off the window's left or
* top edge, so clip to the window's own origin here before converting to
* the unsigned coordinates redraw_damage_window() takes - passing a
* negative offset through unclipped wraps to a huge value that its own
* bounds check then silently rejects, losing the pane's visible portion
* entirely rather than just the off-screen part.
*/
static void
screen_write_redraw_cb(const struct tty_ctx *ttyctx, u_int py, u_int ny)
{
@@ -138,8 +149,10 @@ screen_write_redraw_cb(const struct tty_ctx *ttyctx, u_int py, u_int ny)
x0 = 0;
if (y0 < 0)
y0 = 0;
if (x1 > x0 && y1 > y0)
redraw_damage_window(wp->window, x0, y0, x1 - x0, y1 - y0);
if (x1 <= x0 || y1 <= y0)
return;
redraw_damage_window(wp->window, (u_int)x0, (u_int)y0,
(u_int)(x1 - x0), (u_int)(y1 - y0));
}
/* Update context for client. */

View File

@@ -358,10 +358,30 @@ server_client_set_session(struct client *c, struct session *s)
server_client_fire_session_changed(c, old);
/*
* Redraw if the session or displayed window changed. Use the
* cached scene because the session's current window is already set.
* A full redraw is only needed if the client's session or
* current window actually changed - not if this merely
* confirmed the client is still looking at the same window
* (as happens when switch-client -t targets a pane in the
* already-current window, e.g. clicking a pane name in a
* second #{P:} status line: the default MouseDown1Status
* binding resolves that click to switch-client -t=, which
* reaches here regardless of whether anything besides the
* active pane changed). Redrawing unconditionally here
* forced a full window redraw for what should have been
* just an active-pane change, already handled narrowly by
* window_set_active_pane() and window_redraw_active_switch()
* before this is reached.
*
* old and s may be the same session object, whose curw was
* already updated to the new window before this function was
* called - old->curw and s->curw would then read the same,
* already-current value, so comparing them can never detect
* a same-session window change. Compare against the client's
* own cached scene instead, which only reflects what it has
* actually drawn.
*/
if (old != s || !redraw_client_has_window(c, s->curw->window))
if (old == NULL || old != s ||
!redraw_client_has_window(c, s->curw->window))
server_redraw_client(c);
}
@@ -1355,8 +1375,18 @@ server_client_key_callback(struct cmdq_item *item, void *data)
m->key = key;
/*
* Synchronize direct drag output with the later damage redraw
* before invoking the drag callback.
* Mouse drag is in progress, so fire the callback (now that
* the mouse event is valid).
*
* Start a synchronized-output region here rather than
* leaving it to whatever redraw eventually follows: a drag
* callback may write directly via the pane's fast path
* immediately, with any correction only arriving later via
* redraw_client_damage(), which opens its own sync region.
* Since tty_sync_end() is only called once, at the very end
* of this client's pass in server_client_reset_state(),
* starting it here merges both into one atomic terminal
* update instead of two visible frames.
*/
if ((key & KEYC_MASK_KEY) == KEYC_DRAGGING) {
tty_sync_start(&c->tty);
@@ -1786,8 +1816,13 @@ server_client_loop(void)
}
/*
* Clear window redraw state after processing all clients. Deferred
* redraws are preserved in client flags.
* Any windows will have been redrawn as part of clients, so clear
* their flags now. A client whose redraw was deferred this pass
* (waiting for outstanding tty output to drain) has already
* escalated to CLIENT_REDRAWWINDOW or CLIENT_REDRAWSCROLLBARS in
* server_client_check_redraw() to cover whatever it is about to
* lose here, so PANE_REDRAW/PANE_REDRAWSCROLLBAR and window damage
* can simply be cleared unconditionally.
*/
RB_FOREACH(w, windows, &windows) {
TAILQ_FOREACH(wp, &w->panes, entry) {
@@ -2363,7 +2398,6 @@ 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;
@@ -2389,12 +2423,28 @@ server_client_check_redraw(struct client *c)
return;
}
/* Ignore output queued within the current synchronized frame. */
/*
* 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. server_client_loop() clears PANE_REDRAW,
* PANE_REDRAWSCROLLBAR and window damage unconditionally every pass,
* so escalate to a coarser, persistent client flag that survives
* that clear and forces a full catch-up redraw once this client is
* unblocked, rather than trying to keep the fine-grained state
* around for a retry.
*
* If a synchronized-output frame is open, discount anything queued
* since it started (down to sync_offset, the length when it opened):
* those bytes are already part of the frame this pass is committed
* to flushing (see tty_sync_start()), not a reason to defer this
* pass's redraw - without this, a mouse-drag callback that itself
* opens the frame before writing anything would see its own
* just-queued bytes as "outstanding output" and defer against
* itself every single motion event.
*/
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);
@@ -2406,10 +2456,8 @@ server_client_check_redraw(struct client *c)
log_debug("redraw timer started");
evtimer_add(&ev, &tv);
}
if (damaged) {
if (!TAILQ_EMPTY(&w->damage))
c->flags |= CLIENT_REDRAWWINDOW;
return;
}
TAILQ_FOREACH(wp, &w->panes, entry) {
if (wp->flags & PANE_REDRAW) {
c->flags |= CLIENT_REDRAWWINDOW;
@@ -2444,8 +2492,18 @@ server_client_check_redraw(struct client *c)
}
}
/* Draw damage here if no client redraw flags will handle it. */
if (damaged && (c->flags & CLIENT_ALLREDRAWFLAGS) == 0)
/*
* Window damage is also what makes server_client_any_pane_
* redraw() decide a redraw is needed at all, independently of
* any CLIENT_ALLREDRAWFLAGS bit. Every current damage source
* happens to set one of those flags too, so the block below
* always consumes it - but consume it here too in case that
* ever stops holding, since server_client_loop() clears
* window damage unconditionally every pass regardless of
* whether it was actually drawn.
*/
if (!TAILQ_EMPTY(&w->damage) &&
(c->flags & CLIENT_ALLREDRAWFLAGS) == 0)
redraw_client_damage(c);
}

14
tmux.1
View File

@@ -1,4 +1,4 @@
.\" $OpenBSD: tmux.1,v 1.1173 2026/09/23 12:37:50 nicm Exp $
.\" $OpenBSD: tmux.1,v 1.1172 2026/09/22 06:58:05 nicm Exp $
.\"
.\" Copyright (c) 2007 Nicholas Marriott <nicholas.marriott@gmail.com>
.\"
@@ -14,7 +14,7 @@
.\" IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING
.\" OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
.\"
.Dd $Mdocdate: September 23 2026 $
.Dd $Mdocdate: September 22 2026 $
.Dt TMUX 1
.Os
.Sh NAME
@@ -994,19 +994,13 @@ Will run:
/bin/sh \-c \[aq]vi \[ti]/.tmux.conf\[aq]
.Ed
.Pp
Unless specified, tmux uses the value of
.Ic default\-shell
in place of
.Pa /bin/sh .
.Pp
Additionally, the
.Ic new\-window ,
.Ic new\-session ,
.Ic split\-window ,
.Ic respawn\-window ,
.Ic respawn\-pane
.Ic respawn\-window
and
.Ic display\-popup
.Ic respawn\-pane
commands allow
.Ar shell\-command
to be given as multiple arguments and executed directly (without

12
tmux.h
View File

@@ -1403,7 +1403,7 @@ struct window_pane {
struct screen base;
struct screen status_screen;
u_int status_generation;
u_int status_serial;
TAILQ_HEAD(, window_mode_entry) modes;
@@ -1806,6 +1806,12 @@ struct tty {
struct event timer;
size_t discarded;
/*
* Buffer length at the instant a synchronized-output frame opened
* (tty_sync_start()), so server_client_check_redraw()'s "is there
* already outstanding output" check can discount whatever this
* pass itself queued into that frame - see tty_sync_start().
*/
size_t sync_offset;
struct termios tio;
@@ -3821,9 +3827,9 @@ int window_pane_get_pane_status(struct window_pane *);
struct style_range *window_pane_status_get_range(struct window_pane *, u_int,
u_int);
int window_pane_is_floating(struct window_pane *);
void window_pane_redraw_floating(struct window *,
struct window_pane *, int, int, int, int);
int window_pane_is_floating_with_hidden(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 *);

View File

@@ -1,4 +1,4 @@
/* $OpenBSD: tty-features.c,v 1.46 2026/09/24 08:16:13 nicm Exp $ */
/* $OpenBSD: tty-features.c,v 1.45 2026/09/22 14:10:26 nicm Exp $ */
/*
* Copyright (c) 2020 Nicholas Marriott <nicholas.marriott@gmail.com>

103
window.c
View File

@@ -2942,21 +2942,31 @@ window_pane_is_floating_with_hidden(struct window_pane *wp)
return (1);
}
/* Report damage for a floating pane, including its border and scrollbar. */
/*
* Report damage for a floating pane's rectangle, grown by one cell on every
* side - a floating pane draws its border frame at xoff-1/yoff-1 through
* xoff+sx/yoff+sy (see the "floating" case in screen-redraw.c), one cell
* outside its own content area, so damage for just the content area leaves
* the frame's previous position undrawn as the pane moves. If a scrollbar
* is reserved, its side of the frame is pushed out further still by its
* width and padding (also matched in screen-redraw.c), so grow that side
* to match.
*/
static void
window_damage_floating_pane(struct window_pane *wp, int xoff, int yoff,
int sx, int sy)
window_pane_damage_floating(struct window *w, 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;
int x0, x1, y0, y1, sb_left = 0, sb_right = 0;
if (window_pane_scrollbar_reserve(wp)) {
if (w->sb_pos == PANE_SCROLLBARS_LEFT)
sb_left = sb_sy->width + sb_sy->pad;
sb_left = wp->scrollbar_style.width +
wp->scrollbar_style.pad;
else
sb_right = sb_sy->width + sb_sy->pad;
sb_right = wp->scrollbar_style.width +
wp->scrollbar_style.pad;
}
x0 = xoff - 1 - sb_left;
x1 = xoff + sx + sb_right;
y0 = yoff - 1;
@@ -2965,16 +2975,75 @@ window_damage_floating_pane(struct window_pane *wp, int xoff, int yoff,
x0 = 0;
if (y0 < 0)
y0 = 0;
if (x1 >= x0 && y1 >= y0)
redraw_damage_window(w, x0, y0, x1 - x0 + 1U, y1 - y0 + 1U);
if (x1 < x0 || y1 < y0)
return;
redraw_damage_window(w, (u_int)x0, (u_int)y0, (u_int)(x1 - x0) + 1,
(u_int)(y1 - y0) + 1);
}
/* 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)
/*
* Whether a pane's scrollbar strip - not its whole body - intersects a
* window-coordinate rectangle. A reserved scrollbar occupies a strip of
* scrollbar_style.width+pad columns just outside the pane's own content
* area (see the scrollbar-reserve case in layout_fix_panes(), layout.c),
* on whichever side w->sb_pos points to.
*/
static int
window_pane_scrollbar_intersects(struct window *w, struct window_pane *wp,
u_int x, u_int y, u_int sx, u_int sy)
{
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);
int sb_x, sb_w, ix = (int)x, iy = (int)y, isx = (int)sx;
int isy = (int)sy;
if (!window_pane_scrollbar_reserve(wp))
return (0);
sb_w = wp->scrollbar_style.width + wp->scrollbar_style.pad;
if (w->sb_pos == PANE_SCROLLBARS_LEFT)
sb_x = (int)wp->xoff - sb_w;
else
sb_x = (int)wp->xoff + (int)wp->sx;
return (sb_x < ix + isx && sb_x + sb_w > ix &&
(int)wp->yoff < iy + isy && (int)wp->yoff + (int)wp->sy > iy);
}
/*
* Report damage for only a floating pane's old and new area, rather than
* the whole window - a floating pane move or resize only disturbs what it
* was covering and what it now covers. Scrollbars aren't covered by the
* damage system, so a pane whose *scrollbar strip* (not its whole body)
* intersects either area is still flagged directly for a scrollbar redraw.
* Checking the whole pane body here, rather than just its narrow scrollbar
* strip, meant merely dragging over a pane's ordinary content set
* PANE_REDRAWSCROLLBAR on every such pane on every motion event, triggering
* a needless scrollbar redraw (and the redraw pass it forces) each time
* even though the scrollbar itself never moved.
*
* Shared by every command that drags a floating pane around by the mouse:
* resize-pane's own border drag (cmd-resize-pane.c), move-pane -M's
* alternate Alt-drag (cmd-join-pane.c), and split-window/new-pane's
* interactive resize of a newly-created floating pane (cmd-split-window.c).
*/
void
window_pane_redraw_floating(struct window *w, struct window_pane *wp,
int old_xoff, int old_yoff, int old_sx, int old_sy)
{
struct window_pane *loop;
window_pane_damage_floating(w, wp, old_xoff, old_yoff, old_sx,
old_sy);
window_pane_damage_floating(w, wp, wp->xoff, wp->yoff, wp->sx,
wp->sy);
TAILQ_FOREACH(loop, &w->panes, entry) {
if (window_pane_scrollbar_intersects(w, loop,
(u_int)old_xoff, (u_int)old_yoff, (u_int)old_sx,
(u_int)old_sy) ||
window_pane_scrollbar_intersects(w, loop, wp->xoff,
wp->yoff, wp->sx, wp->sy))
loop->flags |= PANE_REDRAWSCROLLBAR;
}
/* Session status formats may depend on the pane's new geometry. */
server_status_window(w);
}