/ ink / hooks / use-selection.ts
use-selection.ts
  1  import { useContext, useMemo, useSyncExternalStore } from 'react'
  2  import StdinContext from '../components/StdinContext.js'
  3  import instances from '../instances.js'
  4  import {
  5    type FocusMove,
  6    type SelectionState,
  7    shiftAnchor,
  8  } from '../selection.js'
  9  
 10  /**
 11   * Access to text selection operations on the Ink instance (fullscreen only).
 12   * Returns no-op functions when fullscreen mode is disabled.
 13   */
 14  export function useSelection(): {
 15    copySelection: () => string
 16    /** Copy without clearing the highlight (for copy-on-select). */
 17    copySelectionNoClear: () => string
 18    clearSelection: () => void
 19    hasSelection: () => boolean
 20    /** Read the raw mutable selection state (for drag-to-scroll). */
 21    getState: () => SelectionState | null
 22    /** Subscribe to selection mutations (start/update/finish/clear). */
 23    subscribe: (cb: () => void) => () => void
 24    /** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
 25    shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
 26    /** Shift anchor AND focus by dRow (keyboard scroll: whole selection
 27     *  tracks content). Clamped points get col reset to the full-width edge
 28     *  since their content was captured by captureScrolledRows. Reads
 29     *  screen.width from the ink instance for the col-reset boundary. */
 30    shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
 31    /** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
 32     *  Left/right wrap across rows; up/down clamp at viewport edges. */
 33    moveFocus: (move: FocusMove) => void
 34    /** Capture text from rows about to scroll out of the viewport (call
 35     *  BEFORE scrollBy so the screen buffer still has the outgoing rows). */
 36    captureScrolledRows: (
 37      firstRow: number,
 38      lastRow: number,
 39      side: 'above' | 'below',
 40    ) => void
 41    /** Set the selection highlight bg color (theme-piping; solid bg
 42     *  replaces the old SGR-7 inverse so syntax highlighting stays readable
 43     *  under selection). Call once on mount + whenever theme changes. */
 44    setSelectionBgColor: (color: string) => void
 45  } {
 46    // Look up the Ink instance via stdout — same pattern as instances map.
 47    // StdinContext is available (it's always provided), and the Ink instance
 48    // is keyed by stdout which we can get from process.stdout since there's
 49    // only one Ink instance per process in practice.
 50    useContext(StdinContext) // anchor to App subtree for hook rules
 51    const ink = instances.get(process.stdout)
 52    // Memoize so callers can safely use the return value in dependency arrays.
 53    // ink is a singleton per stdout — stable across renders.
 54    return useMemo(() => {
 55      if (!ink) {
 56        return {
 57          copySelection: () => '',
 58          copySelectionNoClear: () => '',
 59          clearSelection: () => {},
 60          hasSelection: () => false,
 61          getState: () => null,
 62          subscribe: () => () => {},
 63          shiftAnchor: () => {},
 64          shiftSelection: () => {},
 65          moveFocus: () => {},
 66          captureScrolledRows: () => {},
 67          setSelectionBgColor: () => {},
 68        }
 69      }
 70      return {
 71        copySelection: () => ink.copySelection(),
 72        copySelectionNoClear: () => ink.copySelectionNoClear(),
 73        clearSelection: () => ink.clearTextSelection(),
 74        hasSelection: () => ink.hasTextSelection(),
 75        getState: () => ink.selection,
 76        subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
 77        shiftAnchor: (dRow: number, minRow: number, maxRow: number) =>
 78          shiftAnchor(ink.selection, dRow, minRow, maxRow),
 79        shiftSelection: (dRow, minRow, maxRow) =>
 80          ink.shiftSelectionForScroll(dRow, minRow, maxRow),
 81        moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
 82        captureScrolledRows: (firstRow, lastRow, side) =>
 83          ink.captureScrolledRows(firstRow, lastRow, side),
 84        setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
 85      }
 86    }, [ink])
 87  }
 88  
 89  const NO_SUBSCRIBE = () => () => {}
 90  const ALWAYS_FALSE = () => false
 91  
 92  /**
 93   * Reactive selection-exists state. Re-renders the caller when a text
 94   * selection is created or cleared. Always returns false outside
 95   * fullscreen mode (selection is only available in alt-screen).
 96   */
 97  export function useHasSelection(): boolean {
 98    useContext(StdinContext)
 99    const ink = instances.get(process.stdout)
100    return useSyncExternalStore(
101      ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
102      ink ? ink.hasTextSelection : ALWAYS_FALSE,
103    )
104  }