From df3a220d6b8095fd09462ad3d897beeb27b122c2 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 11 Jan 2026 03:02:35 +0100 Subject: [PATCH] fix(tui): reduce flicker by only re-rendering changed lines (#617) --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 22 ++++-- packages/tui/test/tui-render.test.ts | 100 +++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a0860a0c..269e4b93 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Reduced flicker by only re-rendering changed lines ([#617](https://github.com/badlogic/pi-mono/pull/617) by [@ogulcancelik](https://github.com/ogulcancelik)) - Cursor position tracking when content shrinks with unchanged remaining lines - TUI renders with wrong dimensions after suspend/resume if terminal was resized while suspended ([#599](https://github.com/badlogic/pi-mono/issues/599)) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index e36ef822..1547e6fb 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -368,6 +368,7 @@ export class TUI extends Container { // Find first and last changed lines let firstChanged = -1; + let lastChanged = -1; const maxLines = Math.max(newLines.length, this.previousLines.length); for (let i = 0; i < maxLines; i++) { const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; @@ -377,6 +378,7 @@ export class TUI extends Container { if (firstChanged === -1) { firstChanged = i; } + lastChanged = i; } } @@ -445,9 +447,10 @@ export class TUI extends Container { buffer += "\r"; // Move to column 0 - // Render from first changed line to end, clearing each line before writing - // This avoids the \x1b[J clear-to-end which can cause flicker in xterm.js - for (let i = firstChanged; i < newLines.length; i++) { + // Only render changed lines (firstChanged to lastChanged), not all lines to end + // This reduces flicker when only a single line changes (e.g., spinner animation) + const renderEnd = Math.min(lastChanged, newLines.length - 1); + for (let i = firstChanged; i <= renderEnd; i++) { if (i > firstChanged) buffer += "\r\n"; buffer += "\x1b[2K"; // Clear current line const line = newLines[i]; @@ -483,8 +486,17 @@ export class TUI extends Container { buffer += line; } + // Track where cursor ended up after rendering + let finalCursorRow = renderEnd; + // If we had more lines before, clear them and move cursor back if (this.previousLines.length > newLines.length) { + // Move to end of new content first if we stopped before it + if (renderEnd < newLines.length - 1) { + const moveDown = newLines.length - 1 - renderEnd; + buffer += `\x1b[${moveDown}B`; + finalCursorRow = newLines.length - 1; + } const extraLines = this.previousLines.length - newLines.length; for (let i = newLines.length; i < this.previousLines.length; i++) { buffer += "\r\n\x1b[2K"; @@ -498,8 +510,8 @@ export class TUI extends Container { // Write entire buffer at once this.terminal.write(buffer); - // Cursor is now at end of last line - this.cursorRow = newLines.length - 1; + // Track cursor position for next render + this.cursorRow = finalCursorRow; this.previousLines = newLines; this.previousWidth = width; diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 61c6d155..c77f7ede 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -40,4 +40,104 @@ describe("TUI differential rendering", () => { tui.stop(); }); + + it("renders correctly when only a middle line changes (spinner case)", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Initial render + component.lines = ["Header", "Working...", "Footer"]; + tui.start(); + await terminal.flush(); + + // Simulate spinner animation - only middle line changes + const spinnerFrames = ["|", "/", "-", "\\"]; + for (const frame of spinnerFrames) { + component.lines = ["Header", `Working ${frame}`, "Footer"]; + tui.requestRender(); + await terminal.flush(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Header"), `Header preserved: ${viewport[0]}`); + assert.ok(viewport[1]?.includes(`Working ${frame}`), `Spinner updated: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Footer"), `Footer preserved: ${viewport[2]}`); + } + + tui.stop(); + }); + + it("renders correctly when first line changes but rest stays same", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"]; + tui.start(); + await terminal.flush(); + + // Change only first line + component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"]; + tui.requestRender(); + await terminal.flush(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("CHANGED"), `First line changed: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`); + assert.ok(viewport[3]?.includes("Line 3"), `Line 3 preserved: ${viewport[3]}`); + + tui.stop(); + }); + + it("renders correctly when last line changes but rest stays same", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"]; + tui.start(); + await terminal.flush(); + + // Change only last line + component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"]; + tui.requestRender(); + await terminal.flush(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`); + assert.ok(viewport[3]?.includes("CHANGED"), `Last line changed: ${viewport[3]}`); + + tui.stop(); + }); + + it("renders correctly when multiple non-adjacent lines change", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"]; + tui.start(); + await terminal.flush(); + + // Change lines 1 and 3, keep 0, 2, 4 the same + component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"]; + tui.requestRender(); + await terminal.flush(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("CHANGED 1"), `Line 1 changed: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`); + assert.ok(viewport[3]?.includes("CHANGED 3"), `Line 3 changed: ${viewport[3]}`); + assert.ok(viewport[4]?.includes("Line 4"), `Line 4 preserved: ${viewport[4]}`); + + tui.stop(); + }); });