/ ink / frame.ts
frame.ts
  1  import type { Cursor } from './cursor.js'
  2  import type { Size } from './layout/geometry.js'
  3  import type { ScrollHint } from './render-node-to-output.js'
  4  import {
  5    type CharPool,
  6    createScreen,
  7    type HyperlinkPool,
  8    type Screen,
  9    type StylePool,
 10  } from './screen.js'
 11  
 12  export type Frame = {
 13    readonly screen: Screen
 14    readonly viewport: Size
 15    readonly cursor: Cursor
 16    /** DECSTBM scroll optimization hint (alt-screen only, null otherwise). */
 17    readonly scrollHint?: ScrollHint | null
 18    /** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */
 19    readonly scrollDrainPending?: boolean
 20  }
 21  
 22  export function emptyFrame(
 23    rows: number,
 24    columns: number,
 25    stylePool: StylePool,
 26    charPool: CharPool,
 27    hyperlinkPool: HyperlinkPool,
 28  ): Frame {
 29    return {
 30      screen: createScreen(0, 0, stylePool, charPool, hyperlinkPool),
 31      viewport: { width: columns, height: rows },
 32      cursor: { x: 0, y: 0, visible: true },
 33    }
 34  }
 35  
 36  export type FlickerReason = 'resize' | 'offscreen' | 'clear'
 37  
 38  export type FrameEvent = {
 39    durationMs: number
 40    /** Phase breakdown in ms + patch count. Populated when the ink instance
 41     *  has frame-timing instrumentation enabled (via onFrame wiring). */
 42    phases?: {
 43      /** createRenderer output: DOM → yoga layout → screen buffer */
 44      renderer: number
 45      /** LogUpdate.render(): screen diff → Patch[] (the hot path this PR optimizes) */
 46      diff: number
 47      /** optimize(): patch merge/dedupe */
 48      optimize: number
 49      /** writeDiffToTerminal(): serialize patches → ANSI → stdout */
 50      write: number
 51      /** Pre-optimize patch count (proxy for how much changed this frame) */
 52      patches: number
 53      /** yoga calculateLayout() time (runs in resetAfterCommit, before onRender) */
 54      yoga: number
 55      /** React reconcile time: scrollMutated → resetAfterCommit. 0 if no commit. */
 56      commit: number
 57      /** layoutNode() calls this frame (recursive, includes cache-hit returns) */
 58      yogaVisited: number
 59      /** measureFunc (text wrap/width) calls — the expensive part */
 60      yogaMeasured: number
 61      /** early returns via _hasL single-slot cache */
 62      yogaCacheHits: number
 63      /** total yoga Node instances alive (create - free). Growth = leak. */
 64      yogaLive: number
 65    }
 66    flickers: Array<{
 67      desiredHeight: number
 68      availableHeight: number
 69      reason: FlickerReason
 70    }>
 71  }
 72  
 73  export type Patch =
 74    | { type: 'stdout'; content: string }
 75    | { type: 'clear'; count: number }
 76    | {
 77        type: 'clearTerminal'
 78        reason: FlickerReason
 79        // Populated by log-update when a scrollback diff triggers the reset.
 80        // ink.tsx uses triggerY with findOwnerChainAtRow to attribute the
 81        // flicker to its source React component.
 82        debug?: { triggerY: number; prevLine: string; nextLine: string }
 83      }
 84    | { type: 'cursorHide' }
 85    | { type: 'cursorShow' }
 86    | { type: 'cursorMove'; x: number; y: number }
 87    | { type: 'cursorTo'; col: number }
 88    | { type: 'carriageReturn' }
 89    | { type: 'hyperlink'; uri: string }
 90    // Pre-serialized style transition string from StylePool.transition() —
 91    // cached by (fromId, toId), zero allocations after warmup.
 92    | { type: 'styleStr'; str: string }
 93  
 94  export type Diff = Patch[]
 95  
 96  /**
 97   * Determines whether the screen should be cleared based on the current and previous frame.
 98   * Returns the reason for clearing, or undefined if no clear is needed.
 99   *
100   * Screen clearing is triggered when:
101   * 1. Terminal has been resized (viewport dimensions changed) → 'resize'
102   * 2. Current frame screen height exceeds available terminal rows → 'offscreen'
103   * 3. Previous frame screen height exceeded available terminal rows → 'offscreen'
104   */
105  export function shouldClearScreen(
106    prevFrame: Frame,
107    frame: Frame,
108  ): FlickerReason | undefined {
109    const didResize =
110      frame.viewport.height !== prevFrame.viewport.height ||
111      frame.viewport.width !== prevFrame.viewport.width
112    if (didResize) {
113      return 'resize'
114    }
115  
116    const currentFrameOverflows = frame.screen.height >= frame.viewport.height
117    const previousFrameOverflowed =
118      prevFrame.screen.height >= prevFrame.viewport.height
119    if (currentFrameOverflows || previousFrameOverflowed) {
120      return 'offscreen'
121    }
122  
123    return undefined
124  }