UNPKG

ink

Version:
471 lines 19.7 kB
import { EventEmitter } from 'node:events'; import process from 'node:process'; import React, { useState, useRef, useCallback, useMemo, useEffect, } from 'react'; import cliCursor from 'cli-cursor'; import { createInputParser } from '../input-parser.js'; import AppContext from './AppContext.js'; import StdinContext from './StdinContext.js'; import StdoutContext from './StdoutContext.js'; import StderrContext from './StderrContext.js'; import FocusContext from './FocusContext.js'; import AnimationContext from './AnimationContext.js'; import CursorContext from './CursorContext.js'; import ErrorBoundary from './ErrorBoundary.js'; const tab = '\t'; const shiftTab = '\u001B[Z'; const escape = '\u001B'; // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed // It also handles Ctrl+C exiting and cursor visibility function App({ children, stdin, stdout, stderr, writeToStdout, writeToStderr, exitOnCtrlC, onExit, onWaitUntilRenderFlush, setCursorPosition, interactive, renderThrottleMs, }) { const [isFocusEnabled, setIsFocusEnabled] = useState(true); const [activeFocusId, setActiveFocusId] = useState(undefined); // Focusables array is managed internally via setFocusables callback pattern // eslint-disable-next-line react/hook-use-state const [, setFocusables] = useState([]); // Track focusables count for tab navigation check (avoids stale closure) const focusablesCountRef = useRef(0); const animationSubscribersRef = useRef(new Map()); const animationTimerRef = useRef(undefined); // Count how many components enabled raw mode to avoid disabling // raw mode until all components don't need it anymore const rawModeEnabledCount = useRef(0); // Count how many components enabled bracketed paste mode const bracketedPasteModeEnabledCount = useRef(0); // eslint-disable-next-line @typescript-eslint/naming-convention const internal_eventEmitter = useRef(new EventEmitter()); // Each useInput hook adds a listener, so the count can legitimately exceed the default limit of 10. internal_eventEmitter.current.setMaxListeners(Infinity); // Store the currently attached readable listener to avoid stale closure issues const readableListenerRef = useRef(undefined); const inputParserRef = useRef(createInputParser()); const pendingInputFlushRef = useRef(undefined); // Small delay to let chunked escape sequences complete before flushing as literal input. const pendingInputFlushDelayMilliseconds = 20; const clearPendingInputFlush = useCallback(() => { if (!pendingInputFlushRef.current) { return; } clearTimeout(pendingInputFlushRef.current); pendingInputFlushRef.current = undefined; }, []); const clearAnimationTimer = useCallback(() => { if (!animationTimerRef.current) { return; } clearTimeout(animationTimerRef.current); animationTimerRef.current = undefined; }, []); const scheduleAnimationTick = useCallback(() => { clearAnimationTimer(); if (animationSubscribersRef.current.size === 0) { return; } let nextDueTime = Number.POSITIVE_INFINITY; for (const subscriber of animationSubscribersRef.current.values()) { // One shared timer is enough as long as it wakes at the earliest // subscriber deadline and lets slower animations skip that tick. nextDueTime = Math.min(nextDueTime, subscriber.nextDueTime); } const delay = Math.max(0, nextDueTime - performance.now()); animationTimerRef.current = setTimeout(() => { animationTimerRef.current = undefined; const currentTime = performance.now(); for (const subscriber of animationSubscribersRef.current.values()) { if (currentTime < subscriber.nextDueTime) { continue; } subscriber.callback(currentTime); const elapsedTime = currentTime - subscriber.startTime; const elapsedFrames = Math.floor(elapsedTime / subscriber.interval) + 1; // Advance from elapsed time rather than callback count so delayed // ticks catch up instead of stretching the animation timeline. subscriber.nextDueTime = subscriber.startTime + elapsedFrames * subscriber.interval; } scheduleAnimationTick(); }, delay); // Keep the timer ref'd while animations are active so `useAnimation()` // can drive process lifetime in both interactive and non-interactive apps. }, [clearAnimationTimer]); const animationSubscribe = useCallback((callback, interval) => { const startTime = performance.now(); // The scheduler owns the start timestamp so hooks can derive frames from // the exact same origin that determines each subscriber's due time. animationSubscribersRef.current.set(callback, { callback, interval, startTime, nextDueTime: startTime + interval, }); scheduleAnimationTick(); return { startTime, unsubscribe() { animationSubscribersRef.current.delete(callback); if (animationSubscribersRef.current.size === 0) { clearAnimationTimer(); return; } scheduleAnimationTick(); }, }; }, [clearAnimationTimer, scheduleAnimationTick]); useEffect(() => { return () => { clearAnimationTimer(); }; }, [clearAnimationTimer]); // Determines if TTY is supported on the provided stdin const isRawModeSupported = stdin.isTTY; const detachReadableListener = useCallback(() => { if (!readableListenerRef.current) { return; } stdin.removeListener('readable', readableListenerRef.current); readableListenerRef.current = undefined; }, [stdin]); const disableRawMode = useCallback(() => { stdin.setRawMode(false); detachReadableListener(); stdin.unref(); rawModeEnabledCount.current = 0; inputParserRef.current.reset(); clearPendingInputFlush(); }, [stdin, detachReadableListener, clearPendingInputFlush]); const handleExit = useCallback((errorOrResult) => { if (isRawModeSupported && rawModeEnabledCount.current > 0) { disableRawMode(); } onExit(errorOrResult); }, [isRawModeSupported, disableRawMode, onExit]); const handleInput = useCallback((input) => { // Exit on Ctrl+C // eslint-disable-next-line unicorn/no-hex-escape if (input === '\x03' && exitOnCtrlC) { handleExit(); return; } // Reset focus when there's an active focused component on Esc if (input === escape) { setActiveFocusId(undefined); } }, [exitOnCtrlC, handleExit]); const emitInput = useCallback((input) => { handleInput(input); internal_eventEmitter.current.emit('input', input); }, [handleInput]); const schedulePendingInputFlush = useCallback(() => { clearPendingInputFlush(); pendingInputFlushRef.current = setTimeout(() => { pendingInputFlushRef.current = undefined; const pendingEscape = inputParserRef.current.flushPendingEscape(); if (!pendingEscape) { return; } emitInput(pendingEscape); }, pendingInputFlushDelayMilliseconds); }, [clearPendingInputFlush, emitInput]); const handleReadable = useCallback(() => { clearPendingInputFlush(); let chunk; // eslint-disable-next-line @typescript-eslint/no-restricted-types while ((chunk = stdin.read()) !== null) { const inputEvents = inputParserRef.current.push(chunk); for (const event of inputEvents) { if (typeof event === 'string') { emitInput(event); } else { // Keep paste on a separate channel from `useInput` so key handlers // don't need to branch on mixed key-vs-paste event shapes. if (internal_eventEmitter.current.listenerCount('paste') === 0) { emitInput(event.paste); continue; } internal_eventEmitter.current.emit('paste', event.paste); } } } if (inputParserRef.current.hasPendingEscape()) { schedulePendingInputFlush(); } }, [stdin, emitInput, clearPendingInputFlush, schedulePendingInputFlush]); const handleSetRawMode = useCallback((isEnabled) => { if (!isRawModeSupported) { if (stdin === process.stdin) { throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); } else { throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); } } stdin.setEncoding('utf8'); if (isEnabled) { // Ensure raw mode is enabled only once if (rawModeEnabledCount.current === 0) { stdin.ref(); stdin.setRawMode(true); // Store the listener reference to avoid stale closure when removing readableListenerRef.current = handleReadable; stdin.addListener('readable', handleReadable); } rawModeEnabledCount.current++; return; } // Disable raw mode only when no components left that are using it if (rawModeEnabledCount.current === 0) { return; } if (--rawModeEnabledCount.current === 0) { disableRawMode(); } }, [isRawModeSupported, stdin, handleReadable, disableRawMode]); const handleSetBracketedPasteMode = useCallback((isEnabled) => { if (!stdout.isTTY) { return; } if (isEnabled) { if (bracketedPasteModeEnabledCount.current === 0) { stdout.write('\u001B[?2004h'); } bracketedPasteModeEnabledCount.current++; return; } if (bracketedPasteModeEnabledCount.current === 0) { return; } if (--bracketedPasteModeEnabledCount.current === 0) { stdout.write('\u001B[?2004l'); } }, [stdout]); // Focus navigation helpers const findNextFocusable = useCallback((currentFocusables, currentActiveFocusId) => { const activeIndex = currentFocusables.findIndex(focusable => { return focusable.id === currentActiveFocusId; }); for (let index = activeIndex + 1; index < currentFocusables.length; index++) { const focusable = currentFocusables[index]; if (focusable?.isActive) { return focusable.id; } } return undefined; }, []); const findPreviousFocusable = useCallback((currentFocusables, currentActiveFocusId) => { const activeIndex = currentFocusables.findIndex(focusable => { return focusable.id === currentActiveFocusId; }); for (let index = activeIndex - 1; index >= 0; index--) { const focusable = currentFocusables[index]; if (focusable?.isActive) { return focusable.id; } } return undefined; }, []); const focusNext = useCallback(() => { setFocusables(currentFocusables => { setActiveFocusId(currentActiveFocusId => { const firstFocusableId = currentFocusables.find(focusable => focusable.isActive)?.id; const nextFocusableId = findNextFocusable(currentFocusables, currentActiveFocusId); return nextFocusableId ?? firstFocusableId; }); return currentFocusables; }); }, [findNextFocusable]); const focusPrevious = useCallback(() => { setFocusables(currentFocusables => { setActiveFocusId(currentActiveFocusId => { const lastFocusableId = currentFocusables.findLast(focusable => focusable.isActive)?.id; const previousFocusableId = findPreviousFocusable(currentFocusables, currentActiveFocusId); return previousFocusableId ?? lastFocusableId; }); return currentFocusables; }); }, [findPreviousFocusable]); // Handle tab navigation via effect that subscribes to input events useEffect(() => { const handleTabNavigation = (input) => { if (!isFocusEnabled || focusablesCountRef.current === 0) return; if (input === tab) { focusNext(); } if (input === shiftTab) { focusPrevious(); } }; internal_eventEmitter.current.on('input', handleTabNavigation); const emitter = internal_eventEmitter.current; return () => { emitter.off('input', handleTabNavigation); }; }, [isFocusEnabled, focusNext, focusPrevious]); const enableFocus = useCallback(() => { setIsFocusEnabled(true); }, []); const disableFocus = useCallback(() => { setIsFocusEnabled(false); }, []); const focus = useCallback((id) => { setFocusables(currentFocusables => { const hasFocusableId = currentFocusables.some(focusable => focusable?.id === id); if (hasFocusableId) { setActiveFocusId(id); } return currentFocusables; }); }, []); const addFocusable = useCallback((id, { autoFocus }) => { setFocusables(currentFocusables => { focusablesCountRef.current = currentFocusables.length + 1; return [ ...currentFocusables, { id, isActive: true, }, ]; }); if (autoFocus) { setActiveFocusId(currentActiveFocusId => { if (!currentActiveFocusId) { return id; } return currentActiveFocusId; }); } }, []); const removeFocusable = useCallback((id) => { setActiveFocusId(currentActiveFocusId => { if (currentActiveFocusId === id) { return undefined; } return currentActiveFocusId; }); setFocusables(currentFocusables => { const filtered = currentFocusables.filter(focusable => { return focusable.id !== id; }); focusablesCountRef.current = filtered.length; return filtered; }); }, []); const activateFocusable = useCallback((id) => { setFocusables(currentFocusables => currentFocusables.map(focusable => { if (focusable.id !== id) { return focusable; } return { id, isActive: true, }; })); }, []); const deactivateFocusable = useCallback((id) => { setActiveFocusId(currentActiveFocusId => { if (currentActiveFocusId === id) { return undefined; } return currentActiveFocusId; }); setFocusables(currentFocusables => currentFocusables.map(focusable => { if (focusable.id !== id) { return focusable; } return { id, isActive: false, }; })); }, []); // Handle cursor visibility, raw mode, and bracketed paste mode cleanup on unmount useEffect(() => { return () => { const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded; if (interactive && canWriteToStdout) { cliCursor.show(stdout); } if (isRawModeSupported && rawModeEnabledCount.current > 0) { disableRawMode(); } if (bracketedPasteModeEnabledCount.current > 0) { if (stdout.isTTY && canWriteToStdout) { stdout.write('\u001B[?2004l'); } bracketedPasteModeEnabledCount.current = 0; } }; }, [stdout, isRawModeSupported, disableRawMode, interactive]); // Memoize context values to prevent unnecessary re-renders const appContextValue = useMemo(() => ({ exit: handleExit, waitUntilRenderFlush: onWaitUntilRenderFlush, }), [handleExit, onWaitUntilRenderFlush]); const stdinContextValue = useMemo(() => ({ stdin, setRawMode: handleSetRawMode, setBracketedPasteMode: handleSetBracketedPasteMode, isRawModeSupported, // eslint-disable-next-line @typescript-eslint/naming-convention internal_exitOnCtrlC: exitOnCtrlC, // eslint-disable-next-line @typescript-eslint/naming-convention internal_eventEmitter: internal_eventEmitter.current, }), [ stdin, handleSetRawMode, handleSetBracketedPasteMode, isRawModeSupported, exitOnCtrlC, ]); const stdoutContextValue = useMemo(() => ({ stdout, write: writeToStdout, }), [stdout, writeToStdout]); const stderrContextValue = useMemo(() => ({ stderr, write: writeToStderr, }), [stderr, writeToStderr]); const cursorContextValue = useMemo(() => ({ setCursorPosition, }), [setCursorPosition]); const focusContextValue = useMemo(() => ({ activeId: activeFocusId, add: addFocusable, remove: removeFocusable, activate: activateFocusable, deactivate: deactivateFocusable, enableFocus, disableFocus, focusNext, focusPrevious, focus, }), [ activeFocusId, addFocusable, removeFocusable, activateFocusable, deactivateFocusable, enableFocus, disableFocus, focusNext, focusPrevious, focus, ]); const animationContextValue = useMemo(() => ({ renderThrottleMs, subscribe: animationSubscribe, }), [animationSubscribe, renderThrottleMs]); return (React.createElement(AppContext.Provider, { value: appContextValue }, React.createElement(StdinContext.Provider, { value: stdinContextValue }, React.createElement(StdoutContext.Provider, { value: stdoutContextValue }, React.createElement(StderrContext.Provider, { value: stderrContextValue }, React.createElement(FocusContext.Provider, { value: focusContextValue }, React.createElement(AnimationContext.Provider, { value: animationContextValue }, React.createElement(CursorContext.Provider, { value: cursorContextValue }, React.createElement(ErrorBoundary, { onError: handleExit }, children))))))))); } App.displayName = 'InternalApp'; export default App; //# sourceMappingURL=App.js.map