1463 lines
62 KiB
TypeScript
1463 lines
62 KiB
TypeScript
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
|