-
Notifications
You must be signed in to change notification settings - Fork 490
Fluid full-screen animated TUIs: drop superseded frames to keep 60 fps and a responsive keyboard #2489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Fluid full-screen animated TUIs: drop superseded frames to keep 60 fps and a responsive keyboard #2489
Changes from 8 commits
e6e17a7
2358d28
f05615f
0ff11a9
7a16b73
5a6a6d5
b30766c
44a74df
c34c6e2
651c447
bfca22f
75ce231
91c47c9
507304d
bdc7dbd
7bc6a0d
dd606f3
fcd6bbf
a2eaefb
efe412a
d2e6999
506b0d3
4dc189c
e8c4956
3b9f068
81692b0
3690e6e
1628318
741a911
6e92729
6b54464
8d6c89e
775c246
e837aa1
9ba81f4
f6e359e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
|
|
||
| import { frameSafeSliceEnd, isInsideSyncBlockAt } from "./syncFrameBoundary"; | ||
|
|
||
| const H = "\x1b[?2026h"; | ||
| const L = "\x1b[?2026l"; | ||
|
|
||
| test("isInsideSyncBlockAt: open before close is inside", () => { | ||
| const data = `${H}frame body`; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, data.length), true); | ||
| }); | ||
|
|
||
| test("isInsideSyncBlockAt: closed block is not inside", () => { | ||
| const data = `${H}frame${L}`; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, data.length), false); | ||
| }); | ||
|
|
||
| test("isInsideSyncBlockAt: point between close and next open is not inside", () => { | ||
| const data = `${H}a${L}XX${H}b${L}`; | ||
| const between = `${H}a${L}X`.length; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, between), false); | ||
| }); | ||
|
|
||
| test("isInsideSyncBlockAt: a DECRQM query is not a frame boundary", () => { | ||
| const data = "\x1b[?2026$pplain text"; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, data.length), false); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: a cut inside a frame extends to past its close", () => { | ||
| const frame = `${H}${"x".repeat(100)}${L}`; | ||
| const data = `${frame}${frame}`; | ||
| // Desired cut lands inside the first frame. | ||
| const cut = H.length + 50; | ||
| const end = frameSafeSliceEnd(data, 0, cut); | ||
| assert.equal(end, frame.length, "must extend to the end of the open frame"); | ||
| assert.equal(isInsideSyncBlockAt(data, 0, end), false); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: a cut between frames is left untouched", () => { | ||
| const frame = `${H}${"x".repeat(100)}${L}`; | ||
| const data = `${frame}${frame}`; | ||
| const cut = frame.length; // exactly on the boundary | ||
| assert.equal(frameSafeSliceEnd(data, 0, cut), cut); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: an unterminated frame is held to the end", () => { | ||
| const data = `${H}${"x".repeat(100)}`; // no close | ||
| const cut = H.length + 50; | ||
| assert.equal(frameSafeSliceEnd(data, 0, cut), data.length); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: plain output is never adjusted", () => { | ||
| const data = "just some normal terminal output with no sync blocks"; | ||
| assert.equal(frameSafeSliceEnd(data, 0, 20), 20); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: end at data.length is returned as-is", () => { | ||
| const data = `${H}x${L}`; | ||
| assert.equal(frameSafeSliceEnd(data, 0, data.length), data.length); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: never moves the end backwards", () => { | ||
| const frame = `${H}${"x".repeat(100)}${L}`; | ||
| const data = `${frame}tail`; | ||
| const cut = H.length + 10; | ||
| const end = frameSafeSliceEnd(data, 0, cut); | ||
| assert.ok(end >= cut, "the adjusted end must not precede the desired end"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| /** | ||
| * DEC 2026 synchronized-output frame boundaries, for slicing terminal output | ||
| * without tearing a frame. | ||
| * | ||
| * A modern full-screen TUI (Tachikoma, and most others) enters the alternate | ||
| * screen once, then delimits every rendered frame with a DEC private mode 2026 | ||
| * synchronized-output block: `\x1b[?2026h` … full frame … `\x1b[?2026l`. xterm | ||
| * buffers rendering while the block is open and paints once on close, so a | ||
| * frame is coherent as long as its whole block reaches xterm before xterm's | ||
| * 1000ms synchronized-output timeout expires (RenderService SyncOutputHandler). | ||
| * | ||
| * The write coalescer/slicer, however, is only aware of alt-screen DECSET | ||
| * toggles — it never sees 2026 — so it slices a continuous frame stream by | ||
| * byte size and hands the shards to xterm across `setTimeout` gaps. A shard | ||
| * boundary that lands inside an open block leaves xterm mid-frame; if the rest | ||
| * of the frame arrives after the 1000ms timeout, xterm force-flushes a partial | ||
| * frame and the display tears. | ||
| * | ||
| * These helpers let the slicer keep every 2026 block whole. | ||
| */ | ||
|
|
||
| const SYNC_OPEN = "\x1b[?2026h"; | ||
| const SYNC_CLOSE = "\x1b[?2026l"; | ||
|
|
||
| /** | ||
| * Sync-block nesting state at `to`, scanning `data` from `from`. | ||
| * | ||
| * DEC 2026 does not nest in practice (a frame is one open/close pair), so this | ||
| * tracks "open" as a boolean latched by the most recent marker rather than a | ||
| * depth count. Returns whether an open block is still unclosed at `to`. | ||
| */ | ||
| export function isInsideSyncBlockAt(data: string, from: number, to: number): boolean { | ||
| let open = false; | ||
| let i = data.indexOf("\x1b[?2026", from); | ||
| while (i !== -1 && i < to) { | ||
| if (data.startsWith(SYNC_OPEN, i)) { | ||
| open = true; | ||
| i += SYNC_OPEN.length; | ||
| } else if (data.startsWith(SYNC_CLOSE, i)) { | ||
| open = false; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| i += SYNC_CLOSE.length; | ||
| } else { | ||
| // A different `?2026` sequence (e.g. a DECRQM query `\x1b[?2026$p`) — not | ||
| // a frame boundary; step past this ESC and keep scanning. | ||
| i += 1; | ||
| } | ||
| i = data.indexOf("\x1b[?2026", i); | ||
| } | ||
| return open; | ||
| } | ||
|
|
||
| /** | ||
| * A slice end at or after `desiredEnd` that never falls strictly inside an open | ||
| * DEC 2026 block. | ||
| * | ||
| * If `desiredEnd` lands inside an open frame, it is pushed forward to just past | ||
| * that frame's `\x1b[?2026l`. If the frame never closes within `data`, the end | ||
| * is pushed to `data.length` so the incomplete frame is held for the next | ||
| * write rather than emitted in pieces. | ||
| * | ||
| * Never moves the end backwards, so it composes with the slicer's other | ||
| * boundary rules (which only ever shrink a slice). | ||
| */ | ||
| export function frameSafeSliceEnd( | ||
| data: string, | ||
| offset: number, | ||
| desiredEnd: number, | ||
| ): number { | ||
| if (desiredEnd >= data.length) return data.length; | ||
| if (!isInsideSyncBlockAt(data, offset, desiredEnd)) return desiredEnd; | ||
| const close = data.indexOf(SYNC_CLOSE, desiredEnd); | ||
| if (close === -1) return data.length; | ||
| return close + SYNC_CLOSE.length; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This boundary scan only searches for the 7-bit
\x1b[?2026form, so a TUI that emits synchronized-output as 8-bit C1 CSI (\x9b?2026h/\x9b?2026l) is treated as ordinary output even though xterm accepts C1 CSI and this repo already handles it for other terminal controls. In that casewriteLargeTerminalBatchcan still slice a synchronized frame across timer yields, reintroducing the xterm sync-timeout tear/backlog behavior this change is meant to prevent; add the C1 opener/closer to the boundary and frame-gate detection helpers as well.Useful? React with 👍 / 👎.