/** * Render pipeline: TermNode tree → Yoga layout → FrameBuffer → ANSI output. * * Connects the Solid reactive tree to terminal output: * 1. applyProps() maps JSX props to yoga layout properties * 2. setupTextMeasure() wires Bun.stringWidth into yoga measure functions * 3. layout() runs yoga calculateLayout on the tree * 4. paint() renders positioned nodes into a FrameBuffer * 5. renderFrame() diffs + serializes to an ANSI string */ import { render, type TermNode } from "../renderer/term-node.ts"; import { Attr, type ClipRect, color, diffFrames, FrameBuffer, NO_COLOR, serializeChanges, wrapSynchronized, } from "./frame-buffer.ts"; import { applySelectionOverlay, createSelection, type SelectionState, } from "./selection.ts"; import { Align, Direction, Display, Edge, FlexDirection, Gutter, Justify, MeasureMode, Overflow, PositionType, Wrap, Node as YogaNode, } from "./yoga.ts"; // --- Prop → Yoga mapping --- const FLEX_DIR_MAP: Record = { row: FlexDirection.Row, column: FlexDirection.Column, "row-reverse": FlexDirection.RowReverse, "column-reverse": FlexDirection.ColumnReverse, }; const ALIGN_MAP: Record = { auto: Align.Auto, "flex-start": Align.FlexStart, center: Align.Center, "flex-end": Align.FlexEnd, stretch: Align.Stretch, baseline: Align.Baseline, }; const JUSTIFY_MAP: Record = { "flex-start": Justify.FlexStart, center: Justify.Center, "flex-end": Justify.FlexEnd, "space-between": Justify.SpaceBetween, "space-around": Justify.SpaceAround, "space-evenly": Justify.SpaceEvenly, }; const WRAP_MAP: Record = { nowrap: Wrap.NoWrap, wrap: Wrap.Wrap, "wrap-reverse": Wrap.WrapReverse, }; /** Apply a TermNode's props to its yoga node. */ export function applyProps(node: TermNode): void { const yoga = node.yogaNode; if (!yoga) return; const p = node.props; // Flex container if (p.flexDirection !== undefined) yoga.setFlexDirection( FLEX_DIR_MAP[p.flexDirection as string] ?? FlexDirection.Column, ); if (p.flexGrow !== undefined) yoga.setFlexGrow(p.flexGrow as number); if (p.flexShrink !== undefined) yoga.setFlexShrink(p.flexShrink as number); if (p.flexBasis !== undefined) yoga.setFlexBasis(p.flexBasis as number); if (p.flexWrap !== undefined) yoga.setFlexWrap(WRAP_MAP[p.flexWrap as string] ?? Wrap.NoWrap); if (p.alignItems !== undefined) yoga.setAlignItems(ALIGN_MAP[p.alignItems as string] ?? Align.Stretch); if (p.alignSelf !== undefined) yoga.setAlignSelf(ALIGN_MAP[p.alignSelf as string] ?? Align.Auto); if (p.justifyContent !== undefined) yoga.setJustifyContent( JUSTIFY_MAP[p.justifyContent as string] ?? Justify.FlexStart, ); // Dimensions if (p.width !== undefined) yoga.setWidth(p.width as number); if (p.height !== undefined) yoga.setHeight(p.height as number); if (p.minWidth !== undefined) yoga.setMinWidth(p.minWidth as number); if (p.minHeight !== undefined) yoga.setMinHeight(p.minHeight as number); if (p.maxWidth !== undefined) yoga.setMaxWidth(p.maxWidth as number); if (p.maxHeight !== undefined) yoga.setMaxHeight(p.maxHeight as number); // Padding if (p.padding !== undefined) yoga.setPadding(Edge.All, p.padding as number); if (p.paddingTop !== undefined) yoga.setPadding(Edge.Top, p.paddingTop as number); if (p.paddingBottom !== undefined) yoga.setPadding(Edge.Bottom, p.paddingBottom as number); if (p.paddingLeft !== undefined) yoga.setPadding(Edge.Left, p.paddingLeft as number); if (p.paddingRight !== undefined) yoga.setPadding(Edge.Right, p.paddingRight as number); // Margin if (p.margin !== undefined) yoga.setMargin(Edge.All, p.margin as number); if (p.marginTop !== undefined) yoga.setMargin(Edge.Top, p.marginTop as number); if (p.marginBottom !== undefined) yoga.setMargin(Edge.Bottom, p.marginBottom as number); if (p.marginLeft !== undefined) yoga.setMargin(Edge.Left, p.marginLeft as number); if (p.marginRight !== undefined) yoga.setMargin(Edge.Right, p.marginRight as number); // Gap if (p.gap !== undefined) yoga.setGap(Gutter.All, p.gap as number); // Borders — reserve cells for border characters if (p.borderStyle !== undefined && p.borderStyle !== "none") { if (p.borderStyle === "horizontal") { yoga.setBorder(Edge.Top, 1); yoga.setBorder(Edge.Bottom, 1); } else { yoga.setBorder(Edge.All, 1); } } // Overflow if (p.overflow !== undefined) { const overflowMap: Record = { visible: Overflow.Visible, hidden: Overflow.Hidden, scroll: Overflow.Scroll, }; yoga.setOverflow(overflowMap[p.overflow as string] ?? Overflow.Visible); } // Display if (p.display !== undefined) yoga.setDisplay(p.display === "none" ? Display.None : Display.Flex); // Position if (p.position !== undefined) { const posMap: Record = { relative: PositionType.Relative, absolute: PositionType.Absolute, }; yoga.setPositionType(posMap[p.position as string] ?? PositionType.Relative); } if (p.top !== undefined) yoga.setPosition(Edge.Top, p.top as number | string); if (p.bottom !== undefined) yoga.setPosition(Edge.Bottom, p.bottom as number | string); if (p.left !== undefined) yoga.setPosition(Edge.Left, p.left as number | string); if (p.right !== undefined) yoga.setPosition(Edge.Right, p.right as number | string); } // --- Prop helpers --- /** Extract a color prop value, returning NO_COLOR if unset. */ function propColor(props: Record, key: string): number { const v = props[key]; if (v == null) return NO_COLOR; return color(v as Bun.ColorInput); } // --- Text measurement --- /** Collect text content from a text node's children. Cached on the node. */ export function collectText(node: TermNode): string { if (node._cachedText !== null) return node._cachedText; let result = ""; for (const child of node.children) { if (child.type === "_text") { result += (child.props.content as string) ?? ""; } else if (child.type !== "_slot") { result += collectText(child); } } node._cachedText = result; return result; } /** * Set up yoga measure functions for text nodes in the tree. * Must be called after the tree is built but before layout. */ export function setupMeasureFunctions(node: TermNode): void { if (!node.dirty) return; // clean subtree — no new text/input nodes if ((node.type === "text" || node.type === "hyperlink") && node.yogaNode) { if (node.yogaNode.measureFunc) { // Already installed — closure reads from node dynamically, no need to replace for (const child of node.children) setupMeasureFunctions(child); return; } node.yogaNode.setMeasureFunc( ( width: number, widthMode: MeasureMode, _height: number, _heightMode: MeasureMode, ) => { const content = collectText(node); if (!content) return { width: 0, height: 0 }; const maxWidth = widthMode === MeasureMode.Undefined ? Number.POSITIVE_INFINITY : width; const sourceLines = content.split("\n"); const mw = Math.floor(maxWidth); let maxLineWidth = 0; let height = 0; for (const line of sourceLines) { const lw = Bun.stringWidth(line); maxLineWidth = Math.max(maxLineWidth, lw); if (mw > 0 && mw < Number.POSITIVE_INFINITY && lw > mw) { // Use Bun.wrapAnsi to count lines — matches paint's word-wrap height += Bun.wrapAnsi(line, mw).split("\n").length; } else { height += 1; } } return { width: Math.min(maxLineWidth, mw), height }; }, ); } else if (node.type === "input" && node.yogaNode) { if (node.yogaNode.measureFunc) { for (const child of node.children) setupMeasureFunctions(child); return; } node.yogaNode.setMeasureFunc( ( width: number, widthMode: MeasureMode, _height: number, _heightMode: MeasureMode, ) => { const value = (node.props.value as string) ?? ""; const placeholder = (node.props.placeholder as string) ?? ""; const display = value || placeholder; if (!display) return { width: 1, height: 1 }; // cursor only const maxWidth = widthMode === MeasureMode.Undefined ? Number.POSITIVE_INFINITY : width; // Measure per-line (not Bun.wrapAnsi which strips trailing spaces) const sourceLines = display.split("\n"); const mw = Math.floor(maxWidth); let maxLineWidth = 0; let height = 0; for (const line of sourceLines) { const lw = Bun.stringWidth(line); maxLineWidth = Math.max(maxLineWidth, lw); height += mw > 0 ? Math.max(1, Math.ceil(lw / mw)) : 1; } return { width: Math.min(maxLineWidth, mw) + 1, // +1 for cursor height, }; }, ); } for (const child of node.children) { setupMeasureFunctions(child); } } // --- Layout --- /** Apply props and calculate layout for the entire tree. */ export function layout(root: TermNode, width: number, height?: number): void { applyAllProps(root); setupMeasureFunctions(root); root.yogaNode?.calculateLayout(width, height, Direction.LTR); } function applyAllProps(node: TermNode): void { if (!node.dirty) return; // clean subtree — no props changed applyProps(node); for (const child of node.children) { applyAllProps(child); } } // --- Text truncation --- /** Truncate a single line to fit within `maxWidth` columns. */ function truncateLine(line: string, maxWidth: number, mode: string): string { const lineWidth = Bun.stringWidth(line); if (lineWidth <= maxWidth) return line; if (maxWidth <= 0) return ""; switch (mode) { case "truncate": case "truncate-end": return `${Bun.sliceAnsi(line, 0, maxWidth - 1)}…`; case "truncate-start": { // Show the tail of the line with ellipsis at start const rightCols = maxWidth - 1; const startOffset = Bun.stringWidth(line) - rightCols; return `…${Bun.sliceAnsi(line, Math.max(0, startOffset))}`; } case "truncate-middle": { const half = Math.floor((maxWidth - 1) / 2); const left = Bun.sliceAnsi(line, 0, half); const rightCols = maxWidth - 1 - half; const rightStart = Bun.stringWidth(line) - rightCols; return `${left}…${Bun.sliceAnsi(line, Math.max(0, rightStart))}`; } default: return Bun.sliceAnsi(line, 0, maxWidth); } } // --- Border characters --- const BORDER_CHARS = { single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" }, double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" }, round: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" }, bold: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" }, } as const; // --- Paint: layout tree → frame buffer --- /** * Compute cursor row and column within a multi-line wrapped string. * cursorPos is the character offset in the original (unwrapped) value. * * Walks the original value character-by-character instead of relying on * Bun.wrapAnsi, which strips trailing spaces and would cause the cursor * to not advance when the user types a space. */ function cursorToRowCol( value: string, cursorPos: number, colWidth: number, ): { row: number; col: number } { if (colWidth <= 0) return { row: 0, col: cursorPos }; let row = 0; let col = 0; for (let i = 0; i < cursorPos && i < value.length; i++) { if (value[i] === "\n") { row++; col = 0; } else { const w = Bun.stringWidth(value[i] ?? ""); if (col + w > colWidth) { row++; col = w; } else { col += w; } } } return { row, col }; } /** * Inverse of cursorToRowCol — convert a (row, col) screen position * to a character offset in the value string. Clamps to value.length * if the click is past the end of the text. */ export function rowColToCursor( value: string, targetRow: number, targetCol: number, colWidth: number, ): number { if (colWidth <= 0) return Math.min(targetCol, value.length); let row = 0; let col = 0; for (let i = 0; i < value.length; i++) { // If we've reached the target row and are at or past the target column if (row === targetRow && col >= targetCol) return i; // If we've passed the target row, return start of this position if (row > targetRow) return i; if (value[i] === "\n") { if (row === targetRow) return i; // clicked past end of this line row++; col = 0; } else { const w = Bun.stringWidth(value[i] ?? ""); if (col + w > colWidth) { if (row === targetRow) return i; // clicked past end of wrapped line row++; col = w; } else { col += w; } } } // Clicked past end of text return value.length; } /** Paint an element — supports multi-line text with cursor. */ function paintInput( node: TermNode, buf: FrameBuffer, x: number, y: number, w: number, h: number, clip?: ClipRect, ): void { const value = (node.props.value as string) ?? ""; const placeholder = (node.props.placeholder as string) ?? ""; const focused = (node.props.focused as boolean) ?? true; const rawCursor = (node.props._cursorPos as number) ?? value.length; const cursorPos = Math.max(0, Math.min(rawCursor, value.length)); const fg = propColor(node.props, "color"); const bg = propColor(node.props, "bg"); const placeholderFg = propColor(node.props, "placeholderColor"); const startCol = Math.floor(x); const startRow = Math.floor(y); const colWidth = Math.floor(w); const maxRow = Math.min(startRow + Math.floor(h), buf.rows); if (startRow >= buf.rows) return; if (value) { // Wrap and render all lines const wrapped = colWidth > 0 ? Bun.wrapAnsi(value, colWidth) : value; const lines = wrapped.split("\n"); for (let i = 0; i < lines.length && startRow + i < maxRow; i++) { buf.writeString( startCol, startRow + i, lines[i] ?? "", fg, bg, Attr.None, clip, ); } // Overlay cursor if (focused) { const cur = cursorToRowCol(value, cursorPos, colWidth); const curRow = startRow + cur.row; if (curRow < maxRow) { const curCol = startCol + cur.col; const charAtCursor = curCol < buf.cols ? buf.get(curCol, curRow).char : " "; const display = charAtCursor === " " || charAtCursor === "" ? " " : charAtCursor; buf.writeString(curCol, curRow, display, fg, bg, Attr.Inverse, clip); } } } else if (placeholder) { // Render placeholder with dim const phFg = placeholderFg !== NO_COLOR ? placeholderFg : fg; const truncated = Bun.sliceAnsi(placeholder, 0, colWidth); buf.writeString(startCol, startRow, truncated, phFg, bg, Attr.Dim, clip); if (focused) { const first = placeholder[0] ?? " "; buf.writeString(startCol, startRow, first, phFg, bg, Attr.Inverse, clip); } } else if (focused) { // Empty + focused: just cursor buf.writeString(startCol, startRow, " ", fg, bg, Attr.Inverse, clip); } } /** Resolve a TermNode's text style props to Cell attributes. */ function resolveAttrs(props: Record): Attr { let attrs = Attr.None; if (props.bold) attrs |= Attr.Bold; if (props.dim) attrs |= Attr.Dim; if (props.italic) attrs |= Attr.Italic; if (props.underline) attrs |= Attr.Underline; if (props.inverse) attrs |= Attr.Inverse; if (props.strikethrough) attrs |= Attr.Strikethrough; return attrs; } /** Paint the positioned TermNode tree into a frame buffer. */ /** * A paint-time color filter: maps a glyph cell's local position within a * width×height box to a packed 24-bit fg, or `undefined` to leave it untouched. */ export type ColorField = ( x: number, y: number, width: number, height: number, ) => number | undefined; /** * Recolor the glyph cells inside a box's painted region using `field`, after * its subtree has painted. Spaces and wide-char continuation cells are skipped, * and the box's clip rect is respected. This is how grouped animations * (shimmer/wave/pulse) sweep across heterogeneous children — text, spinners, * nested boxes — purely by screen position, with no per-node awareness. */ function applyColorField( buf: FrameBuffer, col: number, row: number, w: number, h: number, field: ColorField, clip?: ClipRect, ): void { const x0 = Math.max(col, clip?.left ?? 0, 0); const y0 = Math.max(row, clip?.top ?? 0, 0); const x1 = Math.min(col + w, clip?.right ?? buf.cols, buf.cols); const y1 = Math.min(row + h, clip?.bottom ?? buf.rows, buf.rows); for (let y = y0; y < y1; y++) { for (let x = x0; x < x1; x++) { const cell = buf.get(x, y); if (cell.width === 0 || cell.char === " ") continue; const fg = field(x - col, y - row, w, h); if (fg !== undefined) { cell.fg = fg; buf.dirtyRows.add(y); } } } } export function paint( node: TermNode, buf: FrameBuffer, offsetX = 0, offsetY = 0, clip?: ClipRect, ): void { const yoga = node.yogaNode; if (!yoga) return; const x = offsetX + yoga.getComputedLeft(); const y = offsetY + yoga.getComputedTop(); const w = yoga.getComputedWidth(); const h = yoga.getComputedHeight(); // Cache screen-space rect for hit-testing. Mutate the existing rect in place // when present so a repaint doesn't allocate a new object per node per frame. const left = Math.floor(x); const top = Math.floor(y); const right = Math.floor(x + w); const bottom = Math.floor(y + h); if (node.screenRect) { node.screenRect.left = left; node.screenRect.top = top; node.screenRect.right = right; node.screenRect.bottom = bottom; } else { node.screenRect = { left, top, right, bottom }; } if (node.type === "box") { const col = Math.floor(x); const row = Math.floor(y); const bw = Math.floor(w); const bh = Math.floor(h); // Paint background if set const bgColor = propColor(node.props, "bg"); if (bgColor !== NO_COLOR) { buf.fillRect(col, row, bw, bh, " ", NO_COLOR, bgColor, Attr.None, clip); } // Paint border if set const borderStyle = node.props.borderStyle as string | undefined; const hasBorder = borderStyle != null && borderStyle !== "none"; if (hasBorder) { const isHorizontal = borderStyle === "horizontal"; const chars = BORDER_CHARS[borderStyle as keyof typeof BORDER_CHARS] ?? BORDER_CHARS.single; const borderFg = propColor(node.props, "color"); if (isHorizontal) { // Horizontal-only: full-width lines, no corners or vertical edges const hBar = chars.h.repeat(Math.max(0, bw)); buf.writeString(col, row, hBar, borderFg, bgColor, Attr.None, clip); buf.writeString( col, row + bh - 1, hBar, borderFg, bgColor, Attr.None, clip, ); } else { // Corners buf.writeString(col, row, chars.tl, borderFg, bgColor, Attr.None, clip); buf.writeString( col + bw - 1, row, chars.tr, borderFg, bgColor, Attr.None, clip, ); buf.writeString( col, row + bh - 1, chars.bl, borderFg, bgColor, Attr.None, clip, ); buf.writeString( col + bw - 1, row + bh - 1, chars.br, borderFg, bgColor, Attr.None, clip, ); // Horizontal edges const hBar = chars.h.repeat(Math.max(0, bw - 2)); buf.writeString(col + 1, row, hBar, borderFg, bgColor, Attr.None, clip); buf.writeString( col + 1, row + bh - 1, hBar, borderFg, bgColor, Attr.None, clip, ); // Vertical edges for (let r = row + 1; r < row + bh - 1 && r < buf.rows; r++) { buf.writeString(col, r, chars.v, borderFg, bgColor, Attr.None, clip); buf.writeString( col + bw - 1, r, chars.v, borderFg, bgColor, Attr.None, clip, ); } } } // Determine child clipping and scroll offset const overflow = node.props.overflow as string | undefined; if (overflow === "hidden" || overflow === "scroll") { const border = hasBorder ? 1 : 0; const innerClip: ClipRect = { left: Math.max(clip?.left ?? 0, col + border), top: Math.max(clip?.top ?? 0, row + border), right: Math.min(clip?.right ?? buf.cols, col + bw - border), bottom: Math.min(clip?.bottom ?? buf.rows, row + bh - border), }; const scrollY = overflow === "scroll" ? node.scrollTop : 0; for (const child of node.children) { paint(child, buf, x, y - scrollY, innerClip); } } else { for (const child of node.children) { paint(child, buf, x, y, clip); } } // Paint-time color filter: recolor glyph cells in this box's region // (set by for shimmer/wave/pulse effects across children). const colorField = node.props.colorField as ColorField | undefined; if (colorField) { applyColorField(buf, col, row, bw, bh, colorField, clip); } } else if (node.type === "text") { const content = collectText(node); if (!content) return; const fg = propColor(node.props, "color"); const bg = propColor(node.props, "bg"); const attrs = resolveAttrs(node.props); const wrapMode = (node.props.wrap as string) ?? "word"; const colWidth = Math.floor(w); const startCol = Math.floor(x); const startRow = Math.floor(y); const maxRow = Math.min(startRow + Math.floor(h), buf.rows); if (wrapMode === "word") { // Paint from raw content lines to preserve spaces // (Bun.wrapAnsi strips trailing whitespace) const sourceLines = content.split("\n"); let row = 0; for (const srcLine of sourceLines) { if (startRow + row >= maxRow) break; if (colWidth > 0 && Bun.stringWidth(srcLine) > colWidth) { // Line needs wrapping — use wrapAnsi for word boundaries const wrapped = Bun.wrapAnsi(srcLine, colWidth); for (const wLine of wrapped.split("\n")) { if (startRow + row >= maxRow) break; buf.writeString( startCol, startRow + row, wLine, fg, bg, attrs, clip, ); row++; } } else { buf.writeString( startCol, startRow + row, srcLine, fg, bg, attrs, clip, ); row++; } } } else { // Truncation modes — each source line becomes exactly one output line const sourceLines = content.split("\n"); for (let i = 0; i < sourceLines.length && startRow + i < maxRow; i++) { const line = sourceLines[i] ?? ""; const truncated = truncateLine(line, colWidth, wrapMode); buf.writeString(startCol, startRow + i, truncated, fg, bg, attrs, clip); } } } else if (node.type === "hyperlink") { const content = collectText(node); if (!content) return; const href = (node.props.href as string) ?? ""; const fg = propColor(node.props, "color"); const bg = propColor(node.props, "bg"); const attrs = resolveAttrs(node.props); const colWidth = Math.floor(w); const startCol = Math.floor(x); const startRow = Math.floor(y); const maxRow = Math.min(startRow + Math.floor(h), buf.rows); const sourceLines = content.split("\n"); let row = 0; for (const srcLine of sourceLines) { if (startRow + row >= maxRow) break; if (colWidth > 0 && Bun.stringWidth(srcLine) > colWidth) { const wrapped = Bun.wrapAnsi(srcLine, colWidth); for (const wLine of wrapped.split("\n")) { if (startRow + row >= maxRow) break; buf.writeString( startCol, startRow + row, wLine, fg, bg, attrs, clip, href, ); row++; } } else { buf.writeString( startCol, startRow + row, srcLine, fg, bg, attrs, clip, href, ); row++; } } } else if (node.type === "input") { paintInput(node, buf, x, y, w, h, clip); } // _text and _slot nodes are handled by their parent text node via collectText } // --- Scroll utilities --- /** Get the total content height of a node's children (after layout). */ export function getScrollHeight(node: TermNode): number { let maxBottom = 0; for (const child of node.children) { if (child.yogaNode) { const bottom = child.yogaNode.getComputedTop() + child.yogaNode.getComputedHeight(); if (bottom > maxBottom) maxBottom = bottom; } } return maxBottom; } /** Scroll a node to an absolute offset, clamped to valid range. */ export function scrollTo(node: TermNode, top: number): void { const viewportHeight = node.yogaNode?.getComputedHeight() ?? 0; // Account for border inset (1 cell top + 1 cell bottom) const borderTop = node.yogaNode?.getComputedBorder(Edge.Top) ?? 0; const borderBottom = node.yogaNode?.getComputedBorder(Edge.Bottom) ?? 0; const border = borderTop + borderBottom; const contentHeight = getScrollHeight(node); const maxScroll = Math.max(0, contentHeight - (viewportHeight - border)); node.scrollTop = Math.max(0, Math.min(top, maxScroll)); } /** Scroll a node by a relative delta. */ export function scrollBy(node: TermNode, delta: number): void { scrollTo(node, node.scrollTop + delta); } /** Scroll a node to the bottom of its content. */ export function scrollToBottom(node: TermNode): void { scrollTo(node, Infinity); } // --- Hit-testing --- /** * Find the deepest TermNode at screen coordinates (col, row). * Walks children in reverse order so later (visually on-top) nodes win. * Returns null if no node contains the point. */ export function hitTest( node: TermNode, col: number, row: number, ): TermNode | null { const rect = node.screenRect; if (!rect) return null; if ( col < rect.left || col >= rect.right || row < rect.top || row >= rect.bottom ) { return null; } // Check children in reverse z-order (last child is on top) for (let i = node.children.length - 1; i >= 0; i--) { const child = node.children[i]; if (!child) continue; // Skip internal nodes (_text, _slot) if (child.type === "_text" || child.type === "_slot") continue; const hit = hitTest(child, col, row); if (hit) return hit; } // No child matched — this node is the target return node; } /** * Walk from a hit node up to root, looking for the first ancestor * (including itself) that has a specific handler prop. */ export function findHandler( node: TermNode | null, prop: string, ): { node: TermNode; handler: (e: unknown) => void } | null { let current = node; while (current) { const handler = current.props[prop]; if (typeof handler === "function") { return { node: current, handler: handler as (e: unknown) => void }; } current = current.parent; } return null; } // --- Frame render --- export interface RenderState { /** Number of rows actually used by content (updated each frame) */ activeHeight: number; buffer: FrameBuffer; cols: number; /** Whether the terminal supports OSC 8 hyperlinks */ linksEnabled: boolean; prevBuffer: FrameBuffer; rows: number; /** Text selection state */ selection: SelectionState; syncSupported: boolean; } export function createRenderState( cols: number, rows: number, syncSupported = false, linksEnabled = false, ): RenderState { return { activeHeight: 0, cols, rows, buffer: new FrameBuffer(cols, rows), prevBuffer: new FrameBuffer(cols, rows), selection: createSelection(), syncSupported, linksEnabled, }; } /** Clear dirty flags after layout so unchanged frames skip layout next time. */ export function clearDirty(node: TermNode): void { node.dirty = false; for (const child of node.children) { clearDirty(child); } } /** * Run the full render pipeline: layout → paint → diff → serialize. * Returns the ANSI string to write to stdout. */ export function renderFrame(root: TermNode, state: RenderState): string { // 1. Layout — skip if no props/children/text changed since last frame if (root.dirty) { layout(root, state.cols, state.rows); clearDirty(root); } // 2. Clear only previously-painted rows, then paint state.buffer.clearDirtyRows(); paint(root, state.buffer); // 2b. Track actual content height state.activeHeight = state.buffer.contentHeight(); // 2c. Apply selection overlay (if active) if (state.selection.anchor && state.selection.focus) { applySelectionOverlay(state.buffer, state.selection); } // 3. Diff only rows that were painted this frame or last frame const dirtyRows = new Set(state.buffer.dirtyRows); for (const row of state.prevBuffer.dirtyRows) dirtyRows.add(row); const changes = diffFrames(state.prevBuffer, state.buffer, dirtyRows); // 4. Serialize to ANSI const ansi = serializeChanges(changes, undefined, state.linksEnabled); // 5. Swap buffers const tmp = state.prevBuffer; state.prevBuffer = state.buffer; state.buffer = tmp; // 6. Wrap in sync output if supported if (!ansi) return ""; return state.syncSupported ? wrapSynchronized(ansi) : ansi; } /** * Content-driven render: active region height = content height. * Ports CC's Ink primary-screen rendering model (renderer.ts + log-update.ts): * * - Yoga root height is unconstrained → content determines height * - screen.height = yogaHeight (content height, may exceed terminal rows) * - cursor.y = screen.height (parked after last content row) * - When content < viewport: only renders content rows, input near top * - When content >= viewport: log-update's cursor-restore LF naturally * scrolls old content into terminal scrollback * - Diff is only computed for rows within the terminal viewport * * @param terminalRows - the terminal's visible row count (for viewport calc) */ export function renderContentDriven( root: TermNode, state: RenderState, terminalRows: number, ): string { // 1. Layout with unconstrained height — content determines size if (root.dirty) { layout(root, state.cols); clearDirty(root); } // 2. Compute content height from yoga layout (CC: renderer.ts line 85) let contentHeight = 0; for (const child of root.children) { if (child.yogaNode) { const bottom = child.yogaNode.getComputedTop() + child.yogaNode.getComputedHeight(); if (bottom > contentHeight) contentHeight = Math.ceil(bottom); } } contentHeight = Math.max(1, contentHeight); // Ensure buffers are large enough for content if (contentHeight > state.buffer.rows) { state.buffer = new FrameBuffer(state.cols, contentHeight); state.prevBuffer = new FrameBuffer(state.cols, contentHeight); state.rows = contentHeight; } // 3. Paint full content into buffer state.buffer.clearDirtyRows(); paint(root, state.buffer); const prevHeight = state.activeHeight; state.activeHeight = contentHeight; const heightDelta = Math.max(contentHeight, 1) - Math.max(prevHeight, 1); const growing = heightDelta > 0; const shrinking = heightDelta < 0; // CC pattern (log-update.ts): cursor is at (0, prevHeight) from the // previous frame's cursor-restore. Rows 0..viewportY-1 are in scrollback // (unreachable). We can only diff rows viewportY..contentHeight-1. const cursorAtBottom = prevHeight >= terminalRows; const prevHadScrollback = cursorAtBottom; const viewportY = growing ? Math.max(0, prevHeight - terminalRows + (prevHadScrollback ? 1 : 0)) : Math.max( Math.max(prevHeight, contentHeight) - terminalRows + (prevHadScrollback ? 1 : 0), 0, ); // 4. Diff only rows within the viewport (skip scrollback) const dirtyRows = new Set(); for (const row of state.buffer.dirtyRows) { if (row >= viewportY) dirtyRows.add(row); } for (const row of state.prevBuffer.dirtyRows) { if (row >= viewportY && row < contentHeight) dirtyRows.add(row); } const changes = diffFrames(state.prevBuffer, state.buffer, dirtyRows); // 5. Swap buffers const tmp = state.prevBuffer; state.prevBuffer = state.buffer; state.buffer = tmp; // Skip output if nothing changed and height is stable if (changes.length === 0 && heightDelta === 0) return ""; let out = ""; // Handle shrinking: clear removed rows from bottom if (shrinking) { const linesToClear = -heightDelta; // Move up from cursor position and clear out += `\x1b[${linesToClear}A\x1b[0J`; // Now cursor is at contentHeight, move up to row 0 relative const remaining = contentHeight - (prevHeight - linesToClear); if (remaining > 0) { // noop — we're at the right position } } // Serialize changed rows. serializeChanges uses absolute row positions // in cursor moves, so this works correctly. const ansi = serializeChanges(changes, undefined, state.linksEnabled); if (ansi) { // Move cursor to top of active region before writing diffs if (prevHeight > 0) { out += `\x1b[${prevHeight}A\r`; } out += ansi; } // Handle growth: new rows are rendered by the diff above. // Move cursor to after content for cursor-restore LF effect. // CC: cursor.y = screen.height (log-update handles the LF at frame end) if (growing && !ansi) { // Content grew but diff was empty (shouldn't happen, but be safe) if (prevHeight > 0) { out += `\x1b[${prevHeight}A\r`; } } // Park cursor at (0, contentHeight) — CC's cursor-restore position. // Using CR + LFs to reach the target row, which scrolls the terminal // when contentHeight >= terminalRows (exactly how CC scrolls). out += `\r`; const targetRow = contentHeight; // We need to get to row=contentHeight from wherever the cursor ended up. // After writing ansi, cursor is somewhere in the content. Use absolute. out += `\x1b[${targetRow + 1};1H`; // If content exceeds terminal and cursor is past viewport bottom, // emit a newline to scroll (CC: log-update lines 426-438) if (contentHeight >= terminalRows) { out += `\n`; } if (!out) return ""; return state.syncSupported ? wrapSynchronized(out) : out; } /** * Render a component to an ANSI string (one-shot). * Creates a temporary Solid tree, runs layout + paint, serializes, disposes. * Returns { ansi, height } — the ANSI output and the content height in rows. */ export function renderToAnsi( code: () => unknown, cols: number, linksEnabled = false, ): { ansi: string; height: number } { // Temporary root — unconstrained height so content determines size const yogaRoot = new YogaNode(); yogaRoot.setWidth(cols); const root: TermNode = { type: "box", props: {}, children: [], parent: null, yogaNode: yogaRoot, screenRect: null, scrollTop: 0, dirty: true, _cachedText: null, }; // Mount Solid tree const dispose = render(code, root); // Layout with unconstrained height layout(root, cols); // Measure actual content height let contentRows = 0; for (const child of root.children) { if (child.yogaNode) { const bottom = child.yogaNode.getComputedTop() + child.yogaNode.getComputedHeight(); if (bottom > contentRows) contentRows = Math.ceil(bottom); } } contentRows = Math.max(1, contentRows); // Paint into a buffer sized to content const buf = new FrameBuffer(cols, contentRows); paint(root, buf); // Serialize (diff against empty buffer = full output) const empty = new FrameBuffer(cols, contentRows); const changes = diffFrames(empty, buf); const ansi = serializeChanges(changes, undefined, linksEnabled); // Cleanup dispose(); yogaRoot.free(); return { ansi, height: contentRows }; }