fix(tui): prevent jump-to-end label from shifting when scrollbar hides (#9842)

- center the label independently of scrollbar visibility
- clip drawing and click bounds before the scrollbar
- fixes #9136
This commit is contained in:
Ramiz Wachtler 2026-09-21 17:16:06 +02:00 • committed by GitHub
parent aef5fc429b
commit 8c72793785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 52 additions and 4 deletions

View file

@ -1630,11 +1630,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
const row = clip.y + clip.height - 1;
if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen;
const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, "");
const labelWidth = visibleWidth(label);
const column = clip.x + Math.floor((clip.width - labelWidth) / 2);
const rightEdge = scrollbarColumn ?? clip.x + clip.width;
const availableWidth = Math.max(0, rightEdge - column);
const text = truncateToWidth(label, availableWidth, "");
const textWidth = visibleWidth(text);
if (textWidth === 0) return screen;
const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
const result = [...screen];
result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
this.scrollToEndIndicatorRect = { row, column, width: textWidth };

View file

@ -138,7 +138,51 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => {
it("keeps the jump-to-end indicator centered as the auto scrollbar hides and reappears", async () => {
// Regression test for #9136: auto scrollbar visibility must not move the indicator.
const terminal = new VirtualTerminal(80, 6);
const label = " ↓ Jump to latest message · End ";
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => label,
});
const transcript = new ScrollView(
new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ follow: "end", primary: true, scrollbar: "auto", scrollbarHideDelayMs: 0 },
);
tui.setLayoutRoot(transcript);
tui.start();
try {
await terminal.waitForRender();
// Scrolling over the track keeps the scrollbar visible until the pointer leaves.
terminal.sendInput("\x1b[<64;80;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, true);
assert.strictEqual(transcript.isFollowingEnd, false);
const scrollTop = transcript.scrollTop;
const visibleColumn = terminal.getViewport()[5].indexOf(label);
// Leaving the track lets the auto-hide timer expire without changing the content.
terminal.sendInput("\x1b[<35;79;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, false);
assert.strictEqual(transcript.scrollTop, scrollTop);
const hiddenColumn = terminal.getViewport()[5].indexOf(label);
terminal.sendInput("\x1b[<35;80;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, true);
assert.strictEqual(transcript.scrollTop, scrollTop);
const revealedColumn = terminal.getViewport()[5].indexOf(label);
assert.deepStrictEqual([visibleColumn, hiddenColumn, revealedColumn], [24, 24, 24]);
} finally {
tui.stop();
}
});
it("leaves the scrollbar visible and clickable when the jump-to-end indicator spans the transcript", async () => {
// Regression coverage for #9136: centering must not paint or capture clicks over the scrollbar.
const terminal = new VirtualTerminal(30, 6);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => "↓".repeat(30),
@ -161,6 +205,7 @@ describe("TuiAltScreen", () => {
assert.strictEqual(transcript.isFollowingEnd, false);
// The indicator must not intercept a press on the scrollbar's last column.
assert.strictEqual(terminal.getViewport()[3], `${"↓".repeat(29)}┃`);
terminal.sendInput("\x1b[<0;30;4M");
terminal.sendInput("\x1b[<0;30;4m");
await terminal.waitForRender();