| import indentString from 'indent-string' | |
| import { applyTextStyles } from './colorize.js' | |
| import type { DOMElement } from './dom.js' | |
| import getMaxWidth from './get-max-width.js' | |
| import type { Rectangle } from './layout/geometry.js' | |
| import { LayoutDisplay, LayoutEdge, type LayoutNode } from './layout/node.js' | |
| import { nodeCache, pendingClears } from './node-cache.js' | |
| import type Output from './output.js' | |
| import renderBorder from './render-border.js' | |
| import type { Screen } from './screen.js' | |
| import { | |
| type StyledSegment, | |
| squashTextNodesToSegments, | |
| } from './squash-text-nodes.js' | |
| import type { Color } from './styles.js' | |
| import { isXtermJs } from './terminal.js' | |
| import { widestLine } from './widest-line.js' | |
| import wrapText from './wrap-text.js' | |
| // Matches detectXtermJsWheel() in ScrollKeybindingHandler.tsx — the curve | |
| // and drain must agree on terminal detection. TERM_PROGRAM check is the sync | |
| // fallback; isXtermJs() is the authoritative XTVERSION-probe result. | |
| function isXtermJsHost(): boolean { | |
| return process.env.TERM_PROGRAM === 'vscode' || isXtermJs() | |
| } | |
| // Per-frame scratch: set when any node's yoga position/size differs from | |
| // its cached value, or a child was removed. Read by ink.tsx to decide | |
| // whether the full-damage sledgehammer (PR #20120) is needed this frame. | |
| // Applies on both alt-screen and main-screen. Steady-state frames | |
| // (spinner tick, clock tick, text append into a fixed-height box) don't | |
| // shift layout → narrow damage bounds → O(changed cells) diff instead of | |
| // O(rows×cols). | |
| let layoutShifted = false | |
| export function resetLayoutShifted(): void { | |
| layoutShifted = false | |
| } | |
| export function didLayoutShift(): boolean { | |
| return layoutShifted | |
| } | |
| // DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes | |
| // between frames (and nothing else moved), log-update.ts can emit a | |
| // hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole | |
| // viewport. top/bottom are 0-indexed inclusive screen rows; delta > 0 = | |
| // content moved up (scrollTop increased, CSI n S). | |
| export type ScrollHint = { top: number; bottom: number; delta: number } | |
| let scrollHint: ScrollHint | null = null | |
| // Rects of position:absolute nodes from the PREVIOUS frame, used by | |
| // ScrollBox's blit+shift third-pass repair (see usage site). Recorded at | |
| // three paths — full-render nodeCache.set, node-level blit early-return, | |
| // blitEscapingAbsoluteDescendants — so clean-overlay consecutive scrolls | |
| // still have the rect. | |
| let absoluteRectsPrev: Rectangle[] = [] | |
| let absoluteRectsCur: Rectangle[] = [] | |
| export function resetScrollHint(): void { | |
| scrollHint = null | |
| absoluteRectsPrev = absoluteRectsCur | |
| absoluteRectsCur = [] | |
| } | |
| export function getScrollHint(): ScrollHint | null { | |
| return scrollHint | |
| } | |
| // The ScrollBox DOM node (if any) with pendingScrollDelta left after this | |
| // frame's drain. renderer.ts calls markDirty(it) post-render so the NEXT | |
| // frame's root blit check fails and we descend to continue draining. | |
| // Without this, after the scrollbox's dirty flag is cleared (line ~721), | |
| // the next frame blits root and never reaches the scrollbox — drain stalls. | |
| let scrollDrainNode: DOMElement | null = null | |
| export function resetScrollDrainNode(): void { | |
| scrollDrainNode = null | |
| } | |
| export function getScrollDrainNode(): DOMElement | null { | |
| return scrollDrainNode | |
| } | |
| // At-bottom follow scroll event this frame. When streaming content | |
| // triggers scrollTop = maxScroll, the ScrollBox records the delta + | |
| // viewport bounds here. ink.tsx consumes it post-render to translate any active | |
| // text selection by -delta so the highlight stays anchored to the TEXT | |
| // (native terminal behavior — the selection walks up the screen as content | |
| // scrolls, eventually clipping at the top). The frontFrame screen buffer | |
| // still holds the old content at that point — captureScrolledRows reads | |
| // from it before the front/back swap to preserve the text for copy. | |
| export type FollowScroll = { | |
| delta: number | |
| viewportTop: number | |
| viewportBottom: number | |
| } | |
| let followScroll: FollowScroll | null = null | |
| export function consumeFollowScroll(): FollowScroll | null { | |
| const f = followScroll | |
| followScroll = null | |
| return f | |
| } | |
| // ── Native terminal drain (iTerm2/Ghostty/etc. — proportional events) ── | |
| // Minimum rows applied per frame. Above this, drain is proportional (~3/4 | |
| // of remaining) so big bursts catch up in logâ‚„ frames while the tail | |
| // decelerates smoothly. Hard cap is innerHeight-1 so DECSTBM hint fires. | |
| const SCROLL_MIN_PER_FRAME = 4 | |
| // ── xterm.js (VS Code) smooth drain ── | |
| // Low pending (≤5) drains ALL in one frame — slow wheel clicks should be | |
| // instant (click → visible jump → done), not micro-stutter 1-row frames. | |
| // Higher pending drains at a small fixed step so fast-scroll animation | |
| // stays smooth (no big jumps). Pending >MAX snaps excess. | |
| const SCROLL_INSTANT_THRESHOLD = 5 // ≤ this: drain all at once | |
| const SCROLL_HIGH_PENDING = 12 // threshold for HIGH step | |
| const SCROLL_STEP_MED = 2 // pending (INSTANT, HIGH): catch-up | |
| const SCROLL_STEP_HIGH = 3 // pending ≥ HIGH: fast flick | |
| const SCROLL_MAX_PENDING = 30 // snap excess beyond this | |
| // xterm.js adaptive drain. Returns rows applied; mutates pendingScrollDelta. | |
| function drainAdaptive( | |
| node: DOMElement, | |
| pending: number, | |
| innerHeight: number, | |
| ): number { | |
| const sign = pending > 0 ? 1 : -1 | |
| let abs = Math.abs(pending) | |
| let applied = 0 | |
| // Snap excess beyond animation window so big flicks don't coast. | |
| if (abs > SCROLL_MAX_PENDING) { | |
| applied += sign * (abs - SCROLL_MAX_PENDING) | |
| abs = SCROLL_MAX_PENDING | |
| } | |
| // ≤5: drain all (slow click = instant). Above: small fixed step. | |
| const step = | |
| abs <= SCROLL_INSTANT_THRESHOLD | |
| ? abs | |
| : abs < SCROLL_HIGH_PENDING | |
| ? SCROLL_STEP_MED | |
| : SCROLL_STEP_HIGH | |
| applied += sign * step | |
| const rem = abs - step | |
| // Cap total at innerHeight-1 so DECSTBM blit+shift fast path fires | |
| // (matches drainProportional). Excess stays in pendingScrollDelta. | |
| const cap = Math.max(1, innerHeight - 1) | |
| const totalAbs = Math.abs(applied) | |
| if (totalAbs > cap) { | |
| const excess = totalAbs - cap | |
| node.pendingScrollDelta = sign * (rem + excess) | |
| return sign * cap | |
| } | |
| node.pendingScrollDelta = rem > 0 ? sign * rem : undefined | |
| return applied | |
| } | |
| // Native proportional drain. step = max(MIN, floor(abs*3/4)), capped at | |
| // innerHeight-1 so DECSTBM + blit+shift fast path fire. | |
| function drainProportional( | |
| node: DOMElement, | |
| pending: number, | |
| innerHeight: number, | |
| ): number { | |
| const abs = Math.abs(pending) | |
| const cap = Math.max(1, innerHeight - 1) | |
| const step = Math.min(cap, Math.max(SCROLL_MIN_PER_FRAME, (abs * 3) >> 2)) | |
| if (abs <= step) { | |
| node.pendingScrollDelta = undefined | |
| return pending | |
| } | |
| const applied = pending > 0 ? step : -step | |
| node.pendingScrollDelta = pending - applied | |
| return applied | |
| } | |
| // OSC 8 hyperlink escape sequences. Empty params (;;) — ansi-tokenize only | |
| // recognizes this exact prefix. The id= param (for grouping wrapped lines) | |
| // is added at terminal-output time in termio/osc.ts link(). | |
| const OSC = '\u001B]' | |
| const BEL = '\u0007' | |
| function wrapWithOsc8Link(text: string, url: string): string { | |
| return `${OSC}8;;${url}${BEL}${text}${OSC}8;;${BEL}` | |
| } | |
| /** | |
| * Build a mapping from each character position in the plain text to its segment index. | |
| * Returns an array where charToSegment[i] is the segment index for character i. | |
| */ | |
| function buildCharToSegmentMap(segments: StyledSegment[]): number[] { | |
| const map: number[] = [] | |
| for (let i = 0; i < segments.length; i++) { | |
| const len = segments[i]!.text.length | |
| for (let j = 0; j < len; j++) { | |
| map.push(i) | |
| } | |
| } | |
| return map | |
| } | |
| /** | |
| * Apply styles to wrapped text by mapping each character back to its original segment. | |
| * This preserves per-segment styles even when text wraps across lines. | |
| * | |
| * @param trimEnabled - Whether whitespace trimming is enabled (wrap-trim mode). | |
| * When true, we skip whitespace in the original that was trimmed from the output. | |
| * When false (wrap mode), all whitespace is preserved so no skipping is needed. | |
| */ | |
| function applyStylesToWrappedText( | |
| wrappedPlain: string, | |
| segments: StyledSegment[], | |
| charToSegment: number[], | |
| originalPlain: string, | |
| trimEnabled: boolean = false, | |
| ): string { | |
| const lines = wrappedPlain.split('\n') | |
| const resultLines: string[] = [] | |
| let charIndex = 0 | |
| for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { | |
| const line = lines[lineIdx]! | |
| // In trim mode, skip leading whitespace that was trimmed from this line. | |
| // Only skip if the original has whitespace but the output line doesn't start | |
| // with whitespace (meaning it was trimmed). If both have whitespace, the | |
| // whitespace was preserved and we shouldn't skip. | |
| if (trimEnabled && line.length > 0) { | |
| const lineStartsWithWhitespace = /\s/.test(line[0]!) | |
| const originalHasWhitespace = | |
| charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!) | |
| // Only skip if original has whitespace but line doesn't | |
| if (originalHasWhitespace && !lineStartsWithWhitespace) { | |
| while ( | |
| charIndex < originalPlain.length && | |
| /\s/.test(originalPlain[charIndex]!) | |
| ) { | |
| charIndex++ | |
| } | |
| } | |
| } | |
| let styledLine = '' | |
| let runStart = 0 | |
| let runSegmentIndex = charToSegment[charIndex] ?? 0 | |
| for (let i = 0; i < line.length; i++) { | |
| const currentSegmentIndex = charToSegment[charIndex] ?? runSegmentIndex | |
| if (currentSegmentIndex !== runSegmentIndex) { | |
| // Flush the current run | |
| const runText = line.slice(runStart, i) | |
| const segment = segments[runSegmentIndex] | |
| if (segment) { | |
| let styled = applyTextStyles(runText, segment.styles) | |
| if (segment.hyperlink) { | |
| styled = wrapWithOsc8Link(styled, segment.hyperlink) | |
| } | |
| styledLine += styled | |
| } else { | |
| styledLine += runText | |
| } | |
| runStart = i | |
| runSegmentIndex = currentSegmentIndex | |
| } | |
| charIndex++ | |
| } | |
| // Flush the final run | |
| const runText = line.slice(runStart) | |
| const segment = segments[runSegmentIndex] | |
| if (segment) { | |
| let styled = applyTextStyles(runText, segment.styles) | |
| if (segment.hyperlink) { | |
| styled = wrapWithOsc8Link(styled, segment.hyperlink) | |
| } | |
| styledLine += styled | |
| } else { | |
| styledLine += runText | |
| } | |
| resultLines.push(styledLine) | |
| // Skip newline character in original that corresponds to this line break. | |
| // This is needed when the original text contains actual newlines (not just | |
| // wrapping-inserted newlines). Without this, charIndex gets out of sync | |
| // because the newline is in originalPlain/charToSegment but not in the | |
| // split lines. | |
| if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') { | |
| charIndex++ | |
| } | |
| // In trim mode, skip whitespace that was replaced by newline when wrapping. | |
| // We skip whitespace in the original until we reach a character that matches | |
| // the first character of the next line. This handles cases like: | |
| // - "AB \tD" wrapped to "AB\n\tD" - skip spaces until we hit the tab | |
| // In non-trim mode, whitespace is preserved so no skipping is needed. | |
| if (trimEnabled && lineIdx < lines.length - 1) { | |
| const nextLine = lines[lineIdx + 1]! | |
| const nextLineFirstChar = nextLine.length > 0 ? nextLine[0] : null | |
| // Skip whitespace until we hit a char that matches the next line's first char | |
| while ( | |
| charIndex < originalPlain.length && | |
| /\s/.test(originalPlain[charIndex]!) | |
| ) { | |
| // Stop if we found the character that starts the next line | |
| if ( | |
| nextLineFirstChar !== null && | |
| originalPlain[charIndex] === nextLineFirstChar | |
| ) { | |
| break | |
| } | |
| charIndex++ | |
| } | |
| } | |
| } | |
| return resultLines.join('\n') | |
| } | |
| /** | |
| * Wrap text and record which output lines are soft-wrap continuations | |
| * (i.e. the `\n` before them was inserted by word-wrap, not in the | |
| * source). wrapAnsi already processes each input line independently, so | |
| * wrapping per-input-line here gives identical output to a single | |
| * whole-string wrap while letting us mark per-piece provenance. | |
| * Truncate modes never add newlines (cli-truncate is whole-string) so | |
| * they fall through with softWrap undefined — no tracking, no behavior | |
| * change from the pre-softWrap path. | |
| */ | |
| function wrapWithSoftWrap( | |
| plainText: string, | |
| maxWidth: number, | |
| textWrap: Parameters<typeof wrapText>[2], | |
| ): { wrapped: string; softWrap: boolean[] | undefined } { | |
| if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') { | |
| return { | |
| wrapped: wrapText(plainText, maxWidth, textWrap), | |
| softWrap: undefined, | |
| } | |
| } | |
| const origLines = plainText.split('\n') | |
| const outLines: string[] = [] | |
| const softWrap: boolean[] = [] | |
| for (const orig of origLines) { | |
| const pieces = wrapText(orig, maxWidth, textWrap).split('\n') | |
| for (let i = 0; i < pieces.length; i++) { | |
| outLines.push(pieces[i]!) | |
| softWrap.push(i > 0) | |
| } | |
| } | |
| return { wrapped: outLines.join('\n'), softWrap } | |
| } | |
| // If parent container is `<Box>`, text nodes will be treated as separate nodes in | |
| // the tree and will have their own coordinates in the layout. | |
| // To ensure text nodes are aligned correctly, take X and Y of the first text node | |
| // and use it as offset for the rest of the nodes | |
| // Only first node is taken into account, because other text nodes can't have margin or padding, | |
| // so their coordinates will be relative to the first node anyway | |
| function applyPaddingToText( | |
| node: DOMElement, | |
| text: string, | |
| softWrap?: boolean[], | |
| ): string { | |
| const yogaNode = node.childNodes[0]?.yogaNode | |
| if (yogaNode) { | |
| const offsetX = yogaNode.getComputedLeft() | |
| const offsetY = yogaNode.getComputedTop() | |
| text = '\n'.repeat(offsetY) + indentString(text, offsetX) | |
| if (softWrap && offsetY > 0) { | |
| // Prepend `false` for each padding line so indices stay aligned | |
| // with text.split('\n'). Mutate in place — caller owns the array. | |
| softWrap.unshift(...Array<boolean>(offsetY).fill(false)) | |
| } | |
| } | |
| return text | |
| } | |
| // After nodes are laid out, render each to output object, which later gets rendered to terminal | |
| function renderNodeToOutput( | |
| node: DOMElement, | |
| output: Output, | |
| { | |
| offsetX = 0, | |
| offsetY = 0, | |
| prevScreen, | |
| skipSelfBlit = false, | |
| inheritedBackgroundColor, | |
| }: { | |
| offsetX?: number | |
| offsetY?: number | |
| prevScreen: Screen | undefined | |
| // Force this node to descend instead of blitting its own rect, while | |
| // still passing prevScreen to children. Used for non-opaque absolute | |
| // overlays over a dirty clipped region: the overlay's full rect has | |
| // transparent gaps (stale underlying content in prevScreen), but its | |
| // opaque descendants' narrower rects are safe to blit. | |
| skipSelfBlit?: boolean | |
| inheritedBackgroundColor?: Color | |
| }, | |
| ): void { | |
| const { yogaNode } = node | |
| if (yogaNode) { | |
| if (yogaNode.getDisplay() === LayoutDisplay.None) { | |
| // Clear old position if node was visible before becoming hidden | |
| if (node.dirty) { | |
| const cached = nodeCache.get(node) | |
| if (cached) { | |
| output.clear({ | |
| x: Math.floor(cached.x), | |
| y: Math.floor(cached.y), | |
| width: Math.floor(cached.width), | |
| height: Math.floor(cached.height), | |
| }) | |
| // Drop descendants' cache too — hideInstance's markDirty walks UP | |
| // only, so descendants' .dirty stays false. Their nodeCache entries | |
| // survive with pre-hide rects. On unhide, if position didn't shift, | |
| // the blit check at line ~432 passes and copies EMPTY cells from | |
| // prevScreen (cleared here) → content vanishes. | |
| dropSubtreeCache(node) | |
| layoutShifted = true | |
| } | |
| } | |
| return | |
| } | |
| // Left and top positions in Yoga are relative to their parent node | |
| const x = offsetX + yogaNode.getComputedLeft() | |
| const yogaTop = yogaNode.getComputedTop() | |
| let y = offsetY + yogaTop | |
| const width = yogaNode.getComputedWidth() | |
| const height = yogaNode.getComputedHeight() | |
| // Absolute-positioned overlays (e.g. autocomplete menus with bottom='100%') | |
| // can compute negative screen y when they extend above the viewport. Without | |
| // clamping, setCellAt drops cells at y<0, clipping the TOP of the content | |
| // (best matches in an autocomplete). By clamping to 0, we shift the element | |
| // down so the top rows are visible and the bottom overflows below — the | |
| // opaque prop ensures it paints over whatever is underneath. | |
| if (y < 0 && node.style.position === 'absolute') { | |
| y = 0 | |
| } | |
| // Check if we can skip this subtree (clean node with unchanged layout). | |
| // Blit cells from previous screen instead of re-rendering. | |
| const cached = nodeCache.get(node) | |
| if ( | |
| !node.dirty && | |
| !skipSelfBlit && | |
| node.pendingScrollDelta === undefined && | |
| cached && | |
| cached.x === x && | |
| cached.y === y && | |
| cached.width === width && | |
| cached.height === height && | |
| prevScreen | |
| ) { | |
| const fx = Math.floor(x) | |
| const fy = Math.floor(y) | |
| const fw = Math.floor(width) | |
| const fh = Math.floor(height) | |
| output.blit(prevScreen, fx, fy, fw, fh) | |
| if (node.style.position === 'absolute') { | |
| absoluteRectsCur.push(cached) | |
| } | |
| // Absolute descendants can paint outside this node's layout bounds | |
| // (e.g. a slash menu with position='absolute' bottom='100%' floats | |
| // above). If a dirty clipped sibling re-rendered and overwrote those | |
| // cells, the blit above only restored this node's own rect — the | |
| // absolute descendants' cells are lost. Re-blit them from prevScreen | |
| // so the overlays survive. | |
| blitEscapingAbsoluteDescendants(node, output, prevScreen, fx, fy, fw, fh) | |
| return | |
| } | |
| // Clear stale content from the old position when re-rendering. | |
| // Dirty: content changed. Moved: position/size changed (e.g., sibling | |
| // above changed height), old cells still on the terminal. | |
| const positionChanged = | |
| cached !== undefined && | |
| (cached.x !== x || | |
| cached.y !== y || | |
| cached.width !== width || | |
| cached.height !== height) | |
| if (positionChanged) { | |
| layoutShifted = true | |
| } | |
| if (cached && (node.dirty || positionChanged)) { | |
| output.clear( | |
| { | |
| x: Math.floor(cached.x), | |
| y: Math.floor(cached.y), | |
| width: Math.floor(cached.width), | |
| height: Math.floor(cached.height), | |
| }, | |
| node.style.position === 'absolute', | |
| ) | |
| } | |
| // Read before deleting — hasRemovedChild disables prevScreen blitting | |
| // for siblings to prevent stale overflow content from being restored. | |
| const clears = pendingClears.get(node) | |
| const hasRemovedChild = clears !== undefined | |
| if (hasRemovedChild) { | |
| layoutShifted = true | |
| for (const rect of clears) { | |
| output.clear({ | |
| x: Math.floor(rect.x), | |
| y: Math.floor(rect.y), | |
| width: Math.floor(rect.width), | |
| height: Math.floor(rect.height), | |
| }) | |
| } | |
| pendingClears.delete(node) | |
| } | |
| // Yoga squeezed this node to zero height (overflow in a height-constrained | |
| // parent) AND a sibling lands at the same y. Skip rendering — both would | |
| // write to the same row; if the sibling's content is shorter, this node's | |
| // tail chars ghost (e.g. "false" + "true" = "truee"). The clear above | |
| // already handled the visible→squeezed transition. | |
| // | |
| // The sibling-overlap check is load-bearing: Yoga's pixel-grid rounding | |
| // can give a box h=0 while still leaving a row for it (next sibling at | |
| // y+1, not y). HelpV2's third shortcuts column hits this — skipping | |
| // unconditionally drops "ctrl + z to suspend" from /help output. | |
| if (height === 0 && siblingSharesY(node, yogaNode)) { | |
| nodeCache.set(node, { x, y, width, height, top: yogaTop }) | |
| node.dirty = false | |
| return | |
| } | |
| if (node.nodeName === 'ink-raw-ansi') { | |
| // Pre-rendered ANSI content. The producer already wrapped to width and | |
| // emitted terminal-ready escape codes. Skip squash, measure, wrap, and | |
| // style re-application — output.write() parses ANSI directly into cells. | |
| const text = node.attributes['rawText'] as string | |
| if (text) { | |
| output.write(x, y, text) | |
| } | |
| } else if (node.nodeName === 'ink-text') { | |
| const segments = squashTextNodesToSegments( | |
| node, | |
| inheritedBackgroundColor | |
| ? { backgroundColor: inheritedBackgroundColor } | |
| : undefined, | |
| ) | |
| // First, get plain text to check if wrapping is needed | |
| const plainText = segments.map(s => s.text).join('') | |
| if (plainText.length > 0) { | |
| // Upstream Ink uses getMaxWidth(yogaNode) unclamped here. That | |
| // width comes from Yoga's AtMost pass and can exceed the actual | |
| // screen space (see getMaxWidth docstring). Yoga's height for this | |
| // node already reflects the constrained Exactly pass, so clamping | |
| // the wrap width here keeps line count consistent with layout. | |
| // Without this, characters past the screen edge are dropped by | |
| // setCellAt's bounds check. | |
| const maxWidth = Math.min(getMaxWidth(yogaNode), output.width - x) | |
| const textWrap = node.style.textWrap ?? 'wrap' | |
| // Check if wrapping is needed | |
| const needsWrapping = widestLine(plainText) > maxWidth | |
| let text: string | |
| let softWrap: boolean[] | undefined | |
| if (needsWrapping && segments.length === 1) { | |
| // Single segment: wrap plain text first, then apply styles to each line | |
| const segment = segments[0]! | |
| const w = wrapWithSoftWrap(plainText, maxWidth, textWrap) | |
| softWrap = w.softWrap | |
| text = w.wrapped | |
| .split('\n') | |
| .map(line => { | |
| let styled = applyTextStyles(line, segment.styles) | |
| // Apply OSC 8 hyperlink per-line so each line is independently | |
| // clickable. output.ts splits on newlines and tokenizes each | |
| // line separately, so a single wrapper around the whole block | |
| // would only apply the hyperlink to the first line. | |
| if (segment.hyperlink) { | |
| styled = wrapWithOsc8Link(styled, segment.hyperlink) | |
| } | |
| return styled | |
| }) | |
| .join('\n') | |
| } else if (needsWrapping) { | |
| // Multiple segments with wrapping: wrap plain text first, then re-apply | |
| // each segment's styles based on character positions. This preserves | |
| // per-segment styles even when text wraps across lines. | |
| const w = wrapWithSoftWrap(plainText, maxWidth, textWrap) | |
| softWrap = w.softWrap | |
| const charToSegment = buildCharToSegmentMap(segments) | |
| text = applyStylesToWrappedText( | |
| w.wrapped, | |
| segments, | |
| charToSegment, | |
| plainText, | |
| textWrap === 'wrap-trim', | |
| ) | |
| // Hyperlinks are handled per-run in applyStylesToWrappedText via | |
| // wrapWithOsc8Link, similar to how styles are applied per-run. | |
| } else { | |
| // No wrapping needed: apply styles directly | |
| text = segments | |
| .map(segment => { | |
| let styledText = applyTextStyles(segment.text, segment.styles) | |
| if (segment.hyperlink) { | |
| styledText = wrapWithOsc8Link(styledText, segment.hyperlink) | |
| } | |
| return styledText | |
| }) | |
| .join('') | |
| } | |
| text = applyPaddingToText(node, text, softWrap) | |
| output.write(x, y, text, softWrap) | |
| } | |
| } else if (node.nodeName === 'ink-box') { | |
| const boxBackgroundColor = | |
| node.style.backgroundColor ?? inheritedBackgroundColor | |
| // Mark this box's region as non-selectable (fullscreen text | |
| // selection). noSelect ops are applied AFTER blits/writes in | |
| // output.get(), so this wins regardless of what's rendered into | |
| // the region — including blits from prevScreen when the box is | |
| // clean (the op is emitted on both the dirty-render path here | |
| // AND on the blit fast-path at line ~235 since blitRegion copies | |
| // the noSelect bitmap alongside cells). | |
| // | |
| // 'from-left-edge' extends the exclusion from col 0 so any | |
| // upstream indentation (tool prefix, tree lines) is covered too | |
| // — a multi-row drag over a diff gutter shouldn't pick up the | |
| // ` ⎿ ` prefix on row 0 or the blank cells under it on row 1+. | |
| if (node.style.noSelect) { | |
| const boxX = Math.floor(x) | |
| const fromEdge = node.style.noSelect === 'from-left-edge' | |
| output.noSelect({ | |
| x: fromEdge ? 0 : boxX, | |
| y: Math.floor(y), | |
| width: fromEdge ? boxX + Math.floor(width) : Math.floor(width), | |
| height: Math.floor(height), | |
| }) | |
| } | |
| const overflowX = node.style.overflowX ?? node.style.overflow | |
| const overflowY = node.style.overflowY ?? node.style.overflow | |
| const clipHorizontally = overflowX === 'hidden' || overflowX === 'scroll' | |
| const clipVertically = overflowY === 'hidden' || overflowY === 'scroll' | |
| const isScrollY = overflowY === 'scroll' | |
| const needsClip = clipHorizontally || clipVertically | |
| let y1: number | undefined | |
| let y2: number | undefined | |
| if (needsClip) { | |
| const x1 = clipHorizontally | |
| ? x + yogaNode.getComputedBorder(LayoutEdge.Left) | |
| : undefined | |
| const x2 = clipHorizontally | |
| ? x + | |
| yogaNode.getComputedWidth() - | |
| yogaNode.getComputedBorder(LayoutEdge.Right) | |
| : undefined | |
| y1 = clipVertically | |
| ? y + yogaNode.getComputedBorder(LayoutEdge.Top) | |
| : undefined | |
| y2 = clipVertically | |
| ? y + | |
| yogaNode.getComputedHeight() - | |
| yogaNode.getComputedBorder(LayoutEdge.Bottom) | |
| : undefined | |
| output.clip({ x1, x2, y1, y2 }) | |
| } | |
| if (isScrollY) { | |
| // Scroll containers follow the ScrollBox component structure: | |
| // a single content-wrapper child with flexShrink:0 (doesn't shrink | |
| // to fit), whose children are the scrollable items. scrollHeight | |
| // comes from the wrapper's intrinsic Yoga height. The wrapper is | |
| // rendered with its Y translated by -scrollTop; its children are | |
| // culled against the visible window. | |
| const padTop = yogaNode.getComputedPadding(LayoutEdge.Top) | |
| const innerHeight = Math.max( | |
| 0, | |
| (y2 ?? y + height) - | |
| (y1 ?? y) - | |
| padTop - | |
| yogaNode.getComputedPadding(LayoutEdge.Bottom), | |
| ) | |
| const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as | |
| | DOMElement | |
| | undefined | |
| const contentYoga = content?.yogaNode | |
| // scrollHeight is the intrinsic height of the content wrapper. | |
| // Do NOT add getComputedTop() — that's the wrapper's offset | |
| // within the viewport (equal to the scroll container's | |
| // paddingTop), and innerHeight already subtracts padding, so | |
| // including it double-counts padding and inflates maxScroll. | |
| const scrollHeight = contentYoga?.getComputedHeight() ?? 0 | |
| // Capture previous scroll bounds BEFORE overwriting — the at-bottom | |
| // follow check compares against last frame's max. | |
| const prevScrollHeight = node.scrollHeight ?? scrollHeight | |
| const prevInnerHeight = node.scrollViewportHeight ?? innerHeight | |
| node.scrollHeight = scrollHeight | |
| node.scrollViewportHeight = innerHeight | |
| // Absolute screen-buffer row where the scrollable area (inside | |
| // padding) begins. Exposed via ScrollBoxHandle.getViewportTop() so | |
| // drag-to-scroll can detect when the drag leaves the scroll viewport. | |
| node.scrollViewportTop = (y1 ?? y) + padTop | |
| const maxScroll = Math.max(0, scrollHeight - innerHeight) | |
| // scrollAnchor: scroll so the anchored element's top is at the | |
| // viewport top (plus offset). Yoga is FRESH — same calculateLayout | |
| // pass that just produced scrollHeight. Deterministic alternative | |
| // to scrollTo(N) which bakes a number that's stale by the throttled | |
| // render; the element ref defers the read to now. One-shot snap. | |
| // A prior eased-seek version (proportional drain over ~5 frames) | |
| // moved scrollTop without firing React's notify → parent's quantized | |
| // store snapshot never updated → StickyTracker got stale range props | |
| // → firstVisible wrong. Also: SCROLL_MIN_PER_FRAME=4 with snap-at-1 | |
| // ping-ponged forever at delta=2. Smooth needs drain-end notify | |
| // plumbing; shipping instant first. stickyScroll overrides. | |
| if (node.scrollAnchor) { | |
| const anchorTop = node.scrollAnchor.el.yogaNode?.getComputedTop() | |
| if (anchorTop != null) { | |
| node.scrollTop = anchorTop + node.scrollAnchor.offset | |
| node.pendingScrollDelta = undefined | |
| } | |
| node.scrollAnchor = undefined | |
| } | |
| // At-bottom follow. Positional: if scrollTop was at (or past) the | |
| // previous max, pin to the new max. Scroll away → stop following; | |
| // scroll back (or scrollToBottom/sticky attr) → resume. The sticky | |
| // flag is OR'd in for cold start (scrollTop=0 before first layout) | |
| // and scrollToBottom-from-far-away (flag set before scrollTop moves) | |
| // — the imperative field takes precedence over the attribute so | |
| // scrollTo/scrollBy can break stickiness. pendingDelta<0 guard: | |
| // don't cancel an in-flight scroll-up when content races in. | |
| // Capture scrollTop before follow so ink.tsx can translate any | |
| // active text selection by the same delta (native terminal behavior: | |
| // view keeps scrolling, highlight walks up with the text). | |
| const scrollTopBeforeFollow = node.scrollTop ?? 0 | |
| const sticky = | |
| node.stickyScroll ?? Boolean(node.attributes['stickyScroll']) | |
| const prevMaxScroll = Math.max(0, prevScrollHeight - prevInnerHeight) | |
| // Positional check only valid when content grew — virtualization can | |
| // transiently SHRINK scrollHeight (tail unmount + stale heightCache | |
| // spacer) making scrollTop >= prevMaxScroll true by artifact, not | |
| // because the user was at bottom. | |
| const grew = scrollHeight >= prevScrollHeight | |
| const atBottom = | |
| sticky || (grew && scrollTopBeforeFollow >= prevMaxScroll) | |
| if (atBottom && (node.pendingScrollDelta ?? 0) >= 0) { | |
| node.scrollTop = maxScroll | |
| node.pendingScrollDelta = undefined | |
| // Sync flag so useVirtualScroll's isSticky() agrees with positional | |
| // state — sticky-broken-but-at-bottom (wheel tremor, click-select | |
| // at max) otherwise leaves useVirtualScroll's clamp holding the | |
| // viewport short of new streaming content. scrollTo/scrollBy set | |
| // false; this restores true, same as scrollToBottom() would. | |
| // Only restore when (a) positionally at bottom and (b) the flag | |
| // was explicitly broken (===false) by scrollTo/scrollBy. When | |
| // undefined (never set by user action) leave it alone — setting it | |
| // would make the sticky flag sticky-by-default and lock out | |
| // direct scrollTop writes (e.g. the alt-screen-perf test). | |
| if ( | |
| node.stickyScroll === false && | |
| scrollTopBeforeFollow >= prevMaxScroll | |
| ) { | |
| node.stickyScroll = true | |
| } | |
| } | |
| const followDelta = (node.scrollTop ?? 0) - scrollTopBeforeFollow | |
| if (followDelta > 0) { | |
| const vpTop = node.scrollViewportTop ?? 0 | |
| followScroll = { | |
| delta: followDelta, | |
| viewportTop: vpTop, | |
| viewportBottom: vpTop + innerHeight - 1, | |
| } | |
| } | |
| // Drain pendingScrollDelta. Native terminals (proportional burst | |
| // events) use proportional drain; xterm.js (VS Code, sparse events + | |
| // app-side accel curve) uses adaptive small-step drain. isXtermJs() | |
| // depends on the async XTVERSION probe, but by the time this runs | |
| // (pendingScrollDelta is only set by wheel events, >>50ms after | |
| // startup) the probe has resolved — same timing guarantee the | |
| // wheel-accel curve relies on. | |
| let cur = node.scrollTop ?? 0 | |
| const pending = node.pendingScrollDelta | |
| const cMin = node.scrollClampMin | |
| const cMax = node.scrollClampMax | |
| const haveClamp = cMin !== undefined && cMax !== undefined | |
| if (pending !== undefined && pending !== 0) { | |
| // Drain continues even past the clamp — the render-clamp below | |
| // holds the VISUAL at the mounted edge regardless. Hard-stopping | |
| // here caused stop-start jutter: drain hits edge → pause → React | |
| // commits → clamp widens → drain resumes → edge again. Letting | |
| // scrollTop advance smoothly while the clamp lags gives continuous | |
| // visual scroll at React's commit rate (the clamp catches up each | |
| // commit). But THROTTLE the drain when already past the clamp so | |
| // scrollTop doesn't race 5000 rows ahead of the mounted range | |
| // (slide-cap would then take 200 commits to catch up = long | |
| // perceived stall at the edge). Past-clamp drain caps at ~4 rows/ | |
| // frame, roughly matching React's slide rate so the gap stays | |
| // bounded and catch-up is quick once input stops. | |
| const pastClamp = | |
| haveClamp && | |
| ((pending < 0 && cur < cMin) || (pending > 0 && cur > cMax)) | |
| const eff = pastClamp ? Math.min(4, innerHeight >> 3) : innerHeight | |
| cur += isXtermJsHost() | |
| ? drainAdaptive(node, pending, eff) | |
| : drainProportional(node, pending, eff) | |
| } else if (pending === 0) { | |
| // Opposite scrollBy calls cancelled to zero — clear so we don't | |
| // schedule an infinite loop of no-op drain frames. | |
| node.pendingScrollDelta = undefined | |
| } | |
| let scrollTop = Math.max(0, Math.min(cur, maxScroll)) | |
| // Virtual-scroll clamp: if scrollTop raced past the currently-mounted | |
| // range (burst PageUp before React re-renders), render at the EDGE of | |
| // the mounted children instead of blank spacer. Do NOT write back to | |
| // node.scrollTop — the clamped value is for this paint only; the real | |
| // scrollTop stays so React's next commit sees the target and mounts | |
| // the right range. Not scheduling scrollDrainNode here keeps the | |
| // clamp passive — React's commit → resetAfterCommit → onRender will | |
| // paint again with fresh bounds. | |
| const clamped = haveClamp | |
| ? Math.max(cMin, Math.min(scrollTop, cMax)) | |
| : scrollTop | |
| node.scrollTop = scrollTop | |
| // Clamp hitting top/bottom consumes any remainder. Set drainPending | |
| // only after clamp so a wasted no-op frame isn't scheduled. | |
| if (scrollTop !== cur) node.pendingScrollDelta = undefined | |
| if (node.pendingScrollDelta !== undefined) scrollDrainNode = node | |
| scrollTop = clamped | |
| if (content && contentYoga) { | |
| // Compute content wrapper's absolute render position with scroll | |
| // offset applied, then render its children with culling. | |
| const contentX = x + contentYoga.getComputedLeft() | |
| const contentY = y + contentYoga.getComputedTop() - scrollTop | |
| // layoutShifted detection gap: when scrollTop moves by >= viewport | |
| // height (batched PageUps, fast wheel), every visible child gets | |
| // culled (cache dropped) and every newly-visible child has no | |
| // cache — so the children's positionChanged check can't fire. | |
| // The content wrapper's cached y (which encodes -scrollTop) is | |
| // the only node that survives to witness the scroll. | |
| const contentCached = nodeCache.get(content) | |
| let hint: ScrollHint | null = null | |
| if (contentCached && contentCached.y !== contentY) { | |
| // delta = newScrollTop - oldScrollTop (positive = scrolled down). | |
| // Capture a DECSTBM hint if the container itself didn't move | |
| // and the shift fits within the viewport — otherwise the full | |
| // rewrite is needed anyway, and layoutShifted stays the fallback. | |
| const delta = contentCached.y - contentY | |
| const regionTop = Math.floor(y + contentYoga.getComputedTop()) | |
| const regionBottom = regionTop + innerHeight - 1 | |
| if ( | |
| cached?.y === y && | |
| cached.height === height && | |
| innerHeight > 0 && | |
| Math.abs(delta) < innerHeight | |
| ) { | |
| hint = { top: regionTop, bottom: regionBottom, delta } | |
| scrollHint = hint | |
| } else { | |
| layoutShifted = true | |
| } | |
| } | |
| // Fast path: scroll (hint captured) with usable prevScreen. | |
| // Blit prevScreen's scroll region into next.screen, shift in-place | |
| // by delta (mirrors DECSTBM), then render ONLY the edge rows. The | |
| // nested clip keeps child writes out of stable rows — a tall child | |
| // that spans edge+stable still renders but stable cells are | |
| // clipped, preserving the blit. Avoids re-rendering every visible | |
| // child (expensive for long syntax-highlighted transcripts). | |
| // | |
| // When content.dirty (e.g. streaming text at the bottom of the | |
| // scroll), we still use the fast path — the dirty child is almost | |
| // always in the edge rows (the bottom, where new content appears). | |
| // After edge rendering, any dirty children in stable rows are | |
| // re-rendered in a second pass to avoid showing stale blitted | |
| // content. | |
| // | |
| // Guard: the fast path only handles pure scroll or bottom-append. | |
| // Child removal/insertion changes the content height in a way that | |
| // doesn't match the scroll delta — fall back to the full path so | |
| // removed children don't leave stale cells and shifted siblings | |
| // render at their new positions. | |
| const scrollHeight = contentYoga.getComputedHeight() | |
| const prevHeight = contentCached?.height ?? scrollHeight | |
| const heightDelta = scrollHeight - prevHeight | |
| const safeForFastPath = | |
| !hint || | |
| heightDelta === 0 || | |
| (hint.delta > 0 && heightDelta === hint.delta) | |
| // scrollHint is set above when hint is captured. If safeForFastPath | |
| // is false the full path renders a next.screen that doesn't match | |
| // the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as | |
| // content bleeding through during scroll-up + streaming). Clear it. | |
| if (!safeForFastPath) scrollHint = null | |
| if (hint && prevScreen && safeForFastPath) { | |
| const { top, bottom, delta } = hint | |
| const w = Math.floor(width) | |
| output.blit(prevScreen, Math.floor(x), top, w, bottom - top + 1) | |
| output.shift(top, bottom, delta) | |
| // Edge rows: new content entering the viewport. | |
| const edgeTop = delta > 0 ? bottom - delta + 1 : top | |
| const edgeBottom = delta > 0 ? bottom : top - delta - 1 | |
| output.clear({ | |
| x: Math.floor(x), | |
| y: edgeTop, | |
| width: w, | |
| height: edgeBottom - edgeTop + 1, | |
| }) | |
| output.clip({ | |
| x1: undefined, | |
| x2: undefined, | |
| y1: edgeTop, | |
| y2: edgeBottom + 1, | |
| }) | |
| // Snapshot dirty children before the first pass — the first | |
| // pass clears dirty flags, and edge-spanning children would be | |
| // missed by the second pass without this snapshot. | |
| const dirtyChildren = content.dirty | |
| ? new Set(content.childNodes.filter(c => (c as DOMElement).dirty)) | |
| : null | |
| renderScrolledChildren( | |
| content, | |
| output, | |
| contentX, | |
| contentY, | |
| hasRemovedChild, | |
| undefined, | |
| // Cull to edge in child-local coords (inverse of contentY offset). | |
| edgeTop - contentY, | |
| edgeBottom + 1 - contentY, | |
| boxBackgroundColor, | |
| true, | |
| ) | |
| output.unclip() | |
| // Second pass: re-render children in stable rows whose screen | |
| // position doesn't match where the shift put their old pixels. | |
| // Covers TWO cases: | |
| // 1. Dirty children — their content changed, blitted pixels are | |
| // stale regardless of position. | |
| // 2. Clean children BELOW a middle-growth point — when a dirty | |
| // sibling above them grows, their yogaTop increases but | |
| // scrollTop increases by the same amount (sticky), so their | |
| // screenY is CONSTANT. The shift moved their old pixels to | |
| // screenY-delta (wrong); they should stay at screenY. Without | |
| // this, the spinner/tmux-monitor ghost at shifted positions | |
| // during streaming (e.g. triple spinner, pill duplication). | |
| // For bottom-append (the common case), all clean children are | |
| // ABOVE the growth point; their screenY decreased by delta and | |
| // the shift put them at the right place — skipped here, fast | |
| // path preserved. | |
| if (dirtyChildren) { | |
| const edgeTopLocal = edgeTop - contentY | |
| const edgeBottomLocal = edgeBottom + 1 - contentY | |
| const spaces = ' '.repeat(w) | |
| // Track cumulative height change of children iterated so far. | |
| // A clean child's yogaTop is unchanged iff this is zero (no | |
| // sibling above it grew/shrank/mounted). When zero, the skip | |
| // check cached.y−delta === screenY reduces to delta === delta | |
| // (tautology) → skip without yoga reads. Restores O(dirty) | |
| // that #24536 traded away: for bottom-append the dirty child | |
| // is last (all clean children skip); for virtual-scroll range | |
| // shift the topSpacer shrink + new-item heights self-balance | |
| // to zero before reaching the clean block. Middle-growth | |
| // leaves shift non-zero → clean children after the growth | |
| // point fall through to yoga + the fine-grained check below, | |
| // preserving the ghost-box fix. | |
| let cumHeightShift = 0 | |
| for (const childNode of content.childNodes) { | |
| const childElem = childNode as DOMElement | |
| const isDirty = dirtyChildren.has(childNode) | |
| if (!isDirty && cumHeightShift === 0) { | |
| if (nodeCache.has(childElem)) continue | |
| // Uncached = culled last frame, now re-entering. blit | |
| // never painted it → fall through to yoga + render. | |
| // Height unchanged (clean), so cumHeightShift stays 0. | |
| } | |
| const cy = childElem.yogaNode | |
| if (!cy) continue | |
| const childTop = cy.getComputedTop() | |
| const childH = cy.getComputedHeight() | |
| const childBottom = childTop + childH | |
| if (isDirty) { | |
| const prev = nodeCache.get(childElem) | |
| cumHeightShift += childH - (prev ? prev.height : 0) | |
| } | |
| // Skip culled children (outside viewport) | |
| if ( | |
| childBottom <= scrollTop || | |
| childTop >= scrollTop + innerHeight | |
| ) | |
| continue | |
| // Skip children entirely within edge rows (already rendered) | |
| if (childTop >= edgeTopLocal && childBottom <= edgeBottomLocal) | |
| continue | |
| const screenY = Math.floor(contentY + childTop) | |
| // Clean children reaching here have cumHeightShift ≠0 OR | |
| // no cache. Re-check precisely: cached.y − delta is where | |
| // the shift left old pixels; if it equals new screenY the | |
| // blit is correct (shift re-balanced at this child, or | |
| // yogaTop happens to net out). No cache → blit never | |
| // painted it → render. | |
| if (!isDirty) { | |
| const childCached = nodeCache.get(childElem) | |
| if ( | |
| childCached && | |
| Math.floor(childCached.y) - delta === screenY | |
| ) { | |
| continue | |
| } | |
| } | |
| // Wipe this child's region with spaces to overwrite stale | |
| // blitted content — output.clear() only expands damage and | |
| // cannot zero cells that the blit already wrote. | |
| const screenBottom = Math.min( | |
| Math.floor(contentY + childBottom), | |
| Math.floor((y1 ?? y) + padTop + innerHeight), | |
| ) | |
| if (screenY < screenBottom) { | |
| const fill = Array(screenBottom - screenY) | |
| .fill(spaces) | |
| .join('\n') | |
| output.write(Math.floor(x), screenY, fill) | |
| output.clip({ | |
| x1: undefined, | |
| x2: undefined, | |
| y1: screenY, | |
| y2: screenBottom, | |
| }) | |
| renderNodeToOutput(childElem, output, { | |
| offsetX: contentX, | |
| offsetY: contentY, | |
| prevScreen: undefined, | |
| inheritedBackgroundColor: boxBackgroundColor, | |
| }) | |
| output.unclip() | |
| } | |
| } | |
| } | |
| // Third pass: repair rows where shifted copies of absolute | |
| // overlays landed. The blit copied prevScreen cells INCLUDING | |
| // overlay pixels (overlays render AFTER this ScrollBox so they | |
| // painted into prevScreen's scroll region). After shift, those | |
| // pixels sit at (rect.y - delta) — neither edge render nor the | |
| // overlay's own re-render covers them. Wipe and re-render | |
| // ScrollBox content so the diff writes correct cells. | |
| const spaces = absoluteRectsPrev.length ? ' '.repeat(w) : '' | |
| for (const r of absoluteRectsPrev) { | |
| if (r.y >= bottom + 1 || r.y + r.height <= top) continue | |
| const shiftedTop = Math.max(top, Math.floor(r.y) - delta) | |
| const shiftedBottom = Math.min( | |
| bottom + 1, | |
| Math.floor(r.y + r.height) - delta, | |
| ) | |
| // Skip if entirely within edge rows (already rendered). | |
| if (shiftedTop >= edgeTop && shiftedBottom <= edgeBottom + 1) | |
| continue | |
| if (shiftedTop >= shiftedBottom) continue | |
| const fill = Array(shiftedBottom - shiftedTop) | |
| .fill(spaces) | |
| .join('\n') | |
| output.write(Math.floor(x), shiftedTop, fill) | |
| output.clip({ | |
| x1: undefined, | |
| x2: undefined, | |
| y1: shiftedTop, | |
| y2: shiftedBottom, | |
| }) | |
| renderScrolledChildren( | |
| content, | |
| output, | |
| contentX, | |
| contentY, | |
| hasRemovedChild, | |
| undefined, | |
| shiftedTop - contentY, | |
| shiftedBottom - contentY, | |
| boxBackgroundColor, | |
| true, | |
| ) | |
| output.unclip() | |
| } | |
| } else { | |
| // Full path. Two sub-cases: | |
| // | |
| // Scrolled without a usable hint (big jump, container moved): | |
| // child positions in prevScreen are stale. Clear the viewport | |
| // and disable blit so children don't restore shifted content. | |
| // | |
| // No scroll (spinner tick, content edit): child positions in | |
| // prevScreen are still valid. Skip the viewport clear and pass | |
| // prevScreen so unchanged children blit. Dirty children already | |
| // self-clear via their own cached-rect clear. Without this, a | |
| // spinner inside ScrollBox forces a full-content rewrite every | |
| // frame — on wide terminals over tmux (no BSU/ESU) the | |
| // bandwidth crosses the chunk boundary and the frame tears. | |
| const scrolled = contentCached && contentCached.y !== contentY | |
| if (scrolled && y1 !== undefined && y2 !== undefined) { | |
| output.clear({ | |
| x: Math.floor(x), | |
| y: Math.floor(y1), | |
| width: Math.floor(width), | |
| height: Math.floor(y2 - y1), | |
| }) | |
| } | |
| // positionChanged (ScrollBox height shrunk — pill mount) means a | |
| // child spanning the old bottom edge would blit its full cached | |
| // rect past the new clip. output.ts clips blits now, but also | |
| // disable prevScreen here so the partial-row child re-renders at | |
| // correct bounds instead of blitting a clipped (truncated) old | |
| // rect. | |
| renderScrolledChildren( | |
| content, | |
| output, | |
| contentX, | |
| contentY, | |
| hasRemovedChild, | |
| scrolled || positionChanged ? undefined : prevScreen, | |
| scrollTop, | |
| scrollTop + innerHeight, | |
| boxBackgroundColor, | |
| ) | |
| } | |
| nodeCache.set(content, { | |
| x: contentX, | |
| y: contentY, | |
| width: contentYoga.getComputedWidth(), | |
| height: contentYoga.getComputedHeight(), | |
| }) | |
| content.dirty = false | |
| } | |
| } else { | |
| // Fill interior with background color before rendering children. | |
| // This covers padding areas and empty space; child text inherits | |
| // the color via inheritedBackgroundColor so written cells also | |
| // get the background. | |
| // Disable prevScreen for children: the fill overwrites the entire | |
| // interior each render, so child blits from prevScreen would restore | |
| // stale cells (wrong bg if it changed) on top of the fresh fill. | |
| const ownBackgroundColor = node.style.backgroundColor | |
| if (ownBackgroundColor || node.style.opaque) { | |
| const borderLeft = yogaNode.getComputedBorder(LayoutEdge.Left) | |
| const borderRight = yogaNode.getComputedBorder(LayoutEdge.Right) | |
| const borderTop = yogaNode.getComputedBorder(LayoutEdge.Top) | |
| const borderBottom = yogaNode.getComputedBorder(LayoutEdge.Bottom) | |
| const innerWidth = Math.floor(width) - borderLeft - borderRight | |
| const innerHeight = Math.floor(height) - borderTop - borderBottom | |
| if (innerWidth > 0 && innerHeight > 0) { | |
| const spaces = ' '.repeat(innerWidth) | |
| const fillLine = ownBackgroundColor | |
| ? applyTextStyles(spaces, { backgroundColor: ownBackgroundColor }) | |
| : spaces | |
| const fill = Array(innerHeight).fill(fillLine).join('\n') | |
| output.write(x + borderLeft, y + borderTop, fill) | |
| } | |
| } | |
| renderChildren( | |
| node, | |
| output, | |
| x, | |
| y, | |
| hasRemovedChild, | |
| // backgroundColor and opaque both disable child blit: the fill | |
| // overwrites the entire interior each render, so any child whose | |
| // layout position shifted would blit stale cells from prevScreen | |
| // on top of the fresh fill. Previously opaque kept blit enabled | |
| // on the assumption that plain-space fill + unchanged children = | |
| // valid composite, but children CAN reposition (ScrollBox remeasure | |
| // on re-render → /permissions body blanked on Down arrow, #25436). | |
| ownBackgroundColor || node.style.opaque ? undefined : prevScreen, | |
| boxBackgroundColor, | |
| ) | |
| } | |
| if (needsClip) { | |
| output.unclip() | |
| } | |
| // Render border AFTER children to ensure it's not overwritten by child | |
| // clearing operations. When a child shrinks, it clears its old area, | |
| // which may overlap with where the parent's border now is. | |
| renderBorder(x, y, node, output) | |
| } else if (node.nodeName === 'ink-root') { | |
| renderChildren( | |
| node, | |
| output, | |
| x, | |
| y, | |
| hasRemovedChild, | |
| prevScreen, | |
| inheritedBackgroundColor, | |
| ) | |
| } | |
| // Cache layout bounds for dirty tracking | |
| const rect = { x, y, width, height, top: yogaTop } | |
| nodeCache.set(node, rect) | |
| if (node.style.position === 'absolute') { | |
| absoluteRectsCur.push(rect) | |
| } | |
| node.dirty = false | |
| } | |
| } | |
| // Overflow contamination: content overflows right/down, so clean siblings | |
| // AFTER a dirty/removed sibling can contain stale overflow in prevScreen. | |
| // Disable blit for siblings after a dirty child — but still pass prevScreen | |
| // TO the dirty child itself so its clean descendants can blit. The dirty | |
| // child's own blit check already fails (node.dirty=true at line 216), so | |
| // passing prevScreen only benefits its subtree. | |
| // For removed children we don't know their original position, so | |
| // conservatively disable blit for all. | |
| // | |
| // Clipped children (overflow hidden/scroll on both axes) cannot overflow | |
| // onto later siblings — their content is confined to their layout bounds. | |
| // Skip the contamination guard for them so later siblings can still blit. | |
| // Without this, a spinner inside a ScrollBox dirties the wrapper on every | |
| // tick and the bottom prompt section never blits → 100% writes every frame. | |
| // | |
| // Exception: absolute-positioned clipped children may have layout bounds | |
| // that overlap arbitrary siblings, so the clipping does not help. | |
| // | |
| // Overlap contamination (seenDirtyClipped): a later ABSOLUTE sibling whose | |
| // rect sits inside a dirty clipped child's bounds would blit stale cells | |
| // from prevScreen — the clipped child just rewrote those cells this frame. | |
| // The clipsBothAxes skip only protects against OVERFLOW (clipped child | |
| // painting outside its bounds), not overlap (absolute sibling painting | |
| // inside them). For non-opaque absolute siblings, skipSelfBlit forces | |
| // descent (the full-width rect has transparent gaps → stale blit) while | |
| // still passing prevScreen so opaque descendants can blit their narrower | |
| // rects (NewMessagesPill's inner Text with backgroundColor). Opaque | |
| // absolute siblings fill their entire rect — direct blit is safe. | |
| function renderChildren( | |
| node: DOMElement, | |
| output: Output, | |
| offsetX: number, | |
| offsetY: number, | |
| hasRemovedChild: boolean, | |
| prevScreen: Screen | undefined, | |
| inheritedBackgroundColor: Color | undefined, | |
| ): void { | |
| let seenDirtyChild = false | |
| let seenDirtyClipped = false | |
| for (const childNode of node.childNodes) { | |
| const childElem = childNode as DOMElement | |
| // Capture dirty before rendering — renderNodeToOutput clears the flag | |
| const wasDirty = childElem.dirty | |
| const isAbsolute = childElem.style.position === 'absolute' | |
| renderNodeToOutput(childElem, output, { | |
| offsetX, | |
| offsetY, | |
| prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen, | |
| // Short-circuits on seenDirtyClipped (false in the common case) so | |
| // the opaque/bg reads don't happen per-child per-frame. | |
| skipSelfBlit: | |
| seenDirtyClipped && | |
| isAbsolute && | |
| !childElem.style.opaque && | |
| childElem.style.backgroundColor === undefined, | |
| inheritedBackgroundColor, | |
| }) | |
| if (wasDirty && !seenDirtyChild) { | |
| if (!clipsBothAxes(childElem) || isAbsolute) { | |
| seenDirtyChild = true | |
| } else { | |
| seenDirtyClipped = true | |
| } | |
| } | |
| } | |
| } | |
| function clipsBothAxes(node: DOMElement): boolean { | |
| const ox = node.style.overflowX ?? node.style.overflow | |
| const oy = node.style.overflowY ?? node.style.overflow | |
| return ( | |
| (ox === 'hidden' || ox === 'scroll') && (oy === 'hidden' || oy === 'scroll') | |
| ) | |
| } | |
| // When Yoga squeezes a box to h=0, the ghost only happens if a sibling | |
| // lands at the same computed top — then both write to that row and the | |
| // shorter content leaves the longer's tail visible. Yoga's pixel-grid | |
| // rounding can give h=0 while still advancing the next sibling's top | |
| // (HelpV2's third shortcuts column), so h=0 alone isn't sufficient. | |
| function siblingSharesY(node: DOMElement, yogaNode: LayoutNode): boolean { | |
| const parent = node.parentNode | |
| if (!parent) return false | |
| const myTop = yogaNode.getComputedTop() | |
| const siblings = parent.childNodes | |
| const idx = siblings.indexOf(node) | |
| for (let i = idx + 1; i < siblings.length; i++) { | |
| const sib = (siblings[i] as DOMElement).yogaNode | |
| if (!sib) continue | |
| return sib.getComputedTop() === myTop | |
| } | |
| // No next sibling with a yoga node — check previous. A run of h=0 boxes | |
| // at the tail would all share y with each other. | |
| for (let i = idx - 1; i >= 0; i--) { | |
| const sib = (siblings[i] as DOMElement).yogaNode | |
| if (!sib) continue | |
| return sib.getComputedTop() === myTop | |
| } | |
| return false | |
| } | |
| // When a node blits, its absolute-positioned descendants that paint outside | |
| // the node's layout bounds are NOT covered by the blit (which only copies | |
| // the node's own rect). If a dirty sibling re-rendered and overwrote those | |
| // cells, we must re-blit them from prevScreen so the overlays survive. | |
| // Example: PromptInputFooter's slash menu uses position='absolute' bottom='100%' | |
| // to float above the prompt; a spinner tick in the ScrollBox above re-renders | |
| // and overwrites those cells. Without this, the menu vanishes on the next frame. | |
| function blitEscapingAbsoluteDescendants( | |
| node: DOMElement, | |
| output: Output, | |
| prevScreen: Screen, | |
| px: number, | |
| py: number, | |
| pw: number, | |
| ph: number, | |
| ): void { | |
| const pr = px + pw | |
| const pb = py + ph | |
| for (const child of node.childNodes) { | |
| if (child.nodeName === '#text') continue | |
| const elem = child as DOMElement | |
| if (elem.style.position === 'absolute') { | |
| const cached = nodeCache.get(elem) | |
| if (cached) { | |
| absoluteRectsCur.push(cached) | |
| const cx = Math.floor(cached.x) | |
| const cy = Math.floor(cached.y) | |
| const cw = Math.floor(cached.width) | |
| const ch = Math.floor(cached.height) | |
| // Only blit rects that extend outside the parent's layout bounds — | |
| // cells within the parent rect are already covered by the parent blit. | |
| if (cx < px || cy < py || cx + cw > pr || cy + ch > pb) { | |
| output.blit(prevScreen, cx, cy, cw, ch) | |
| } | |
| } | |
| } | |
| // Recurse — absolute descendants can be nested arbitrarily deep | |
| blitEscapingAbsoluteDescendants(elem, output, prevScreen, px, py, pw, ph) | |
| } | |
| } | |
| // Render children of a scroll container with viewport culling. | |
| // scrollTopY..scrollBottomY are the visible window in CHILD-LOCAL Yoga coords | |
| // (i.e. what getComputedTop() returns). Children entirely outside this window | |
| // are skipped; their nodeCache entry is deleted so if they re-enter the | |
| // viewport later they don't emit a stale clear for a position now occupied | |
| // by a sibling. | |
| function renderScrolledChildren( | |
| node: DOMElement, | |
| output: Output, | |
| offsetX: number, | |
| offsetY: number, | |
| hasRemovedChild: boolean, | |
| prevScreen: Screen | undefined, | |
| scrollTopY: number, | |
| scrollBottomY: number, | |
| inheritedBackgroundColor: Color | undefined, | |
| // When true (DECSTBM fast path), culled children keep their cache — | |
| // the blit+shift put stable rows in next.screen so stale cache is | |
| // never read. Avoids walking O(total_children * subtree_depth) per frame. | |
| preserveCulledCache = false, | |
| ): void { | |
| let seenDirtyChild = false | |
| // Track cumulative height shift of dirty children iterated so far. When | |
| // zero, a clean child's yogaTop is unchanged (no sibling above it grew), | |
| // so cached.top is fresh and the cull check skips yoga. Bottom-append | |
| // has the dirty child last → all prior clean children hit cache → | |
| // O(dirty) not O(mounted). Middle-growth leaves shift non-zero after | |
| // the dirty child → subsequent children yoga-read (needed for correct | |
| // culling since their yogaTop shifted). | |
| let cumHeightShift = 0 | |
| for (const childNode of node.childNodes) { | |
| const childElem = childNode as DOMElement | |
| const cy = childElem.yogaNode | |
| if (cy) { | |
| const cached = nodeCache.get(childElem) | |
| let top: number | |
| let height: number | |
| if ( | |
| cached?.top !== undefined && | |
| !childElem.dirty && | |
| cumHeightShift === 0 | |
| ) { | |
| top = cached.top | |
| height = cached.height | |
| } else { | |
| top = cy.getComputedTop() | |
| height = cy.getComputedHeight() | |
| if (childElem.dirty) { | |
| cumHeightShift += height - (cached ? cached.height : 0) | |
| } | |
| // Refresh cached top so next frame's cumShift===0 path stays | |
| // correct. For culled children with preserveCulledCache=true this | |
| // is the ONLY refresh point — without it, a middle-growth frame | |
| // leaves stale tops that misfire next frame. | |
| if (cached) cached.top = top | |
| } | |
| const bottom = top + height | |
| if (bottom <= scrollTopY || top >= scrollBottomY) { | |
| // Culled — outside visible window. Drop stale cache entries from | |
| // the subtree so when this child re-enters it doesn't fire clears | |
| // at positions now occupied by siblings. The viewport-clear on | |
| // scroll-change handles the visible-area repaint. | |
| if (!preserveCulledCache) dropSubtreeCache(childElem) | |
| continue | |
| } | |
| } | |
| const wasDirty = childElem.dirty | |
| renderNodeToOutput(childElem, output, { | |
| offsetX, | |
| offsetY, | |
| prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen, | |
| inheritedBackgroundColor, | |
| }) | |
| if (wasDirty) { | |
| seenDirtyChild = true | |
| } | |
| } | |
| } | |
| function dropSubtreeCache(node: DOMElement): void { | |
| nodeCache.delete(node) | |
| for (const child of node.childNodes) { | |
| if (child.nodeName !== '#text') { | |
| dropSubtreeCache(child as DOMElement) | |
| } | |
| } | |
| } | |
| // Exported for testing | |
| export { buildCharToSegmentMap, applyStylesToWrappedText } | |
| export default renderNodeToOutput | |