/ ink / hooks / use-declared-cursor.ts
use-declared-cursor.ts
 1  import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
 2  import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
 3  import type { DOMElement } from '../dom.js'
 4  
 5  /**
 6   * Declares where the terminal cursor should be parked after each frame.
 7   *
 8   * Terminal emulators render IME preedit text at the physical cursor
 9   * position, and screen readers / screen magnifiers track the native
10   * cursor — so parking it at the text input's caret makes CJK input
11   * appear inline and lets accessibility tools follow the input.
12   *
13   * Returns a ref callback to attach to the Box that contains the input.
14   * The declared (line, column) is interpreted relative to that Box's
15   * nodeCache rect (populated by renderNodeToOutput).
16   *
17   * Timing: Both ref attach and useLayoutEffect fire in React's layout
18   * phase — after resetAfterCommit calls scheduleRender. scheduleRender
19   * defers onRender via queueMicrotask, so onRender runs AFTER layout
20   * effects commit and reads the fresh declaration on the first frame
21   * (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
22   * no microtask), so tests compensate by calling ink.onRender()
23   * explicitly after render.
24   */
25  export function useDeclaredCursor({
26    line,
27    column,
28    active,
29  }: {
30    line: number
31    column: number
32    active: boolean
33  }): (element: DOMElement | null) => void {
34    const setCursorDeclaration = useContext(CursorDeclarationContext)
35    const nodeRef = useRef<DOMElement | null>(null)
36  
37    const setNode = useCallback((node: DOMElement | null) => {
38      nodeRef.current = node
39    }, [])
40  
41    // When active, set unconditionally. When inactive, clear conditionally
42    // (only if the currently-declared node is ours). The node-identity check
43    // handles two hazards:
44    //   1. A memo()ized active instance elsewhere (e.g. the search input in
45    //      a memo'd Footer) doesn't re-render this commit — an inactive
46    //      instance re-rendering here must not clobber it.
47    //   2. Sibling handoff (menu focus moving between list items) — when
48    //      focus moves opposite to sibling order, the newly-inactive item's
49    //      effect runs AFTER the newly-active item's set. Without the node
50    //      check it would clobber.
51    // No dep array: must re-declare every commit so the active instance
52    // re-claims the declaration after another instance's unmount-cleanup or
53    // sibling handoff nulls it.
54    useLayoutEffect(() => {
55      const node = nodeRef.current
56      if (active && node) {
57        setCursorDeclaration({ relativeX: column, relativeY: line, node })
58      } else {
59        setCursorDeclaration(null, node)
60      }
61    })
62  
63    // Clear on unmount (conditionally — another instance may own by then).
64    // Separate effect with empty deps so cleanup only fires once — not on
65    // every line/column change, which would transiently null between commits.
66    useLayoutEffect(() => {
67      return () => {
68        setCursorDeclaration(null, nodeRef.current)
69      }
70    }, [setCursorDeclaration])
71  
72    return setNode
73  }