feat: add useStdout and useIsScreenReaderEnabled hooks for ink-picture
Browse files- Add StdoutContext.Provider and AccessibilityContext.Provider to App render tree
- Export useStdout/useIsScreenReaderEnabled from src/ink entry point
- Pass writeToStdout from Ink class to App for raw escape sequence output
- Create LocalPicture.test.tsx visual test for local image display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/ink-picture/__tests__/LocalImageShow.test.ts +0 -0
- src/ink-picture/__tests__/LocalPicture.test.tsx +38 -0
- src/ink.ts +2 -0
- src/ink/components/AccessibilityContext.tsx +17 -0
- src/ink/components/App.tsx +8 -1
- src/ink/components/StdoutContext.tsx +10 -1
- src/ink/hooks/use-is-screen-reader-enabled.ts +11 -0
- src/ink/hooks/use-stdout.ts +11 -0
- src/ink/ink.tsx +1 -1
src/ink-picture/__tests__/LocalImageShow.test.ts
DELETED
|
File without changes
|
src/ink-picture/__tests__/LocalPicture.test.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bun
|
| 2 |
+
/**
|
| 3 |
+
* Visual test: display a local image using ink-picture + Ink.
|
| 4 |
+
*
|
| 5 |
+
* Usage:
|
| 6 |
+
* bun run src/ink-picture/__tests__/LocalPicture.test.tsx
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import React, { useEffect } from 'react'
|
| 10 |
+
import { render, Box, useApp } from 'ink'
|
| 11 |
+
import Image, { InkPictureProvider } from '../index.ts'
|
| 12 |
+
|
| 13 |
+
const IMAGE_PATH = '/home/yuki/Pictures/Wallpapers/3god.jpg'
|
| 14 |
+
|
| 15 |
+
const cols = process.stdout.columns ?? 80
|
| 16 |
+
const rows = process.stdout.rows ?? 40
|
| 17 |
+
const imgWidth = Math.floor(cols * 0.6)
|
| 18 |
+
const imgHeight = Math.floor(rows * 0.4)
|
| 19 |
+
|
| 20 |
+
function App() {
|
| 21 |
+
const { exit } = useApp()
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
const timer = setTimeout(() => exit(), 3000)
|
| 25 |
+
return () => clearTimeout(timer)
|
| 26 |
+
}, [exit])
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<Box flexDirection="column">
|
| 30 |
+
<InkPictureProvider>
|
| 31 |
+
<Image src={IMAGE_PATH} width={imgWidth} height={imgHeight} alt="3god" />
|
| 32 |
+
</InkPictureProvider>
|
| 33 |
+
</Box>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const { waitUntilExit } = render(<App />)
|
| 38 |
+
await waitUntilExit()
|
src/ink.ts
CHANGED
|
@@ -76,6 +76,8 @@ export { default as useInput } from './ink/hooks/use-input.js'
|
|
| 76 |
export { useAnimationTimer, useInterval } from './ink/hooks/use-interval.js'
|
| 77 |
export { useSelection } from './ink/hooks/use-selection.js'
|
| 78 |
export { default as useStdin } from './ink/hooks/use-stdin.js'
|
|
|
|
|
|
|
| 79 |
export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
| 80 |
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
| 81 |
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
|
|
|
| 76 |
export { useAnimationTimer, useInterval } from './ink/hooks/use-interval.js'
|
| 77 |
export { useSelection } from './ink/hooks/use-selection.js'
|
| 78 |
export { default as useStdin } from './ink/hooks/use-stdin.js'
|
| 79 |
+
export { default as useStdout } from './ink/hooks/use-stdout.js'
|
| 80 |
+
export { default as useIsScreenReaderEnabled } from './ink/hooks/use-is-screen-reader-enabled.js'
|
| 81 |
export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
| 82 |
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
| 83 |
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
src/ink/components/AccessibilityContext.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext } from 'react'
|
| 2 |
+
|
| 3 |
+
type AccessibilityContextValue = {
|
| 4 |
+
readonly isScreenReaderEnabled: boolean
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
const AccessibilityContext = createContext<AccessibilityContextValue>({
|
| 8 |
+
isScreenReaderEnabled: false,
|
| 9 |
+
})
|
| 10 |
+
|
| 11 |
+
AccessibilityContext.displayName = 'AccessibilityContext'
|
| 12 |
+
|
| 13 |
+
export default AccessibilityContext
|
| 14 |
+
|
| 15 |
+
export function useAccessibility(): AccessibilityContextValue {
|
| 16 |
+
return useContext(AccessibilityContext)
|
| 17 |
+
}
|
src/ink/components/App.tsx
CHANGED
|
@@ -21,6 +21,8 @@ import { ClockProvider } from './ClockContext.js';
|
|
| 21 |
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
|
| 22 |
import ErrorOverview from './ErrorOverview.js';
|
| 23 |
import StdinContext from './StdinContext.js';
|
|
|
|
|
|
|
| 24 |
import { TerminalFocusProvider } from './TerminalFocusContext.js';
|
| 25 |
import { TerminalSizeContext } from './TerminalSizeContext.js';
|
| 26 |
|
|
@@ -38,6 +40,7 @@ type Props = {
|
|
| 38 |
readonly stdin: NodeJS.ReadStream;
|
| 39 |
readonly stdout: NodeJS.WriteStream;
|
| 40 |
readonly stderr: NodeJS.WriteStream;
|
|
|
|
| 41 |
readonly exitOnCtrlC: boolean;
|
| 42 |
readonly onExit: (error?: Error) => void;
|
| 43 |
readonly terminalColumns: number;
|
|
@@ -170,7 +173,11 @@ export default class App extends PureComponent<Props, State> {
|
|
| 170 |
<TerminalFocusProvider>
|
| 171 |
<ClockProvider>
|
| 172 |
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
</CursorDeclarationContext.Provider>
|
| 175 |
</ClockProvider>
|
| 176 |
</TerminalFocusProvider>
|
|
|
|
| 21 |
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
|
| 22 |
import ErrorOverview from './ErrorOverview.js';
|
| 23 |
import StdinContext from './StdinContext.js';
|
| 24 |
+
import StdoutContext from './StdoutContext.js';
|
| 25 |
+
import AccessibilityContext from './AccessibilityContext.js';
|
| 26 |
import { TerminalFocusProvider } from './TerminalFocusContext.js';
|
| 27 |
import { TerminalSizeContext } from './TerminalSizeContext.js';
|
| 28 |
|
|
|
|
| 40 |
readonly stdin: NodeJS.ReadStream;
|
| 41 |
readonly stdout: NodeJS.WriteStream;
|
| 42 |
readonly stderr: NodeJS.WriteStream;
|
| 43 |
+
readonly writeToStdout: (data: string) => void;
|
| 44 |
readonly exitOnCtrlC: boolean;
|
| 45 |
readonly onExit: (error?: Error) => void;
|
| 46 |
readonly terminalColumns: number;
|
|
|
|
| 173 |
<TerminalFocusProvider>
|
| 174 |
<ClockProvider>
|
| 175 |
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
| 176 |
+
<StdoutContext.Provider value={{ stdout: this.props.stdout, write: this.props.writeToStdout }}>
|
| 177 |
+
<AccessibilityContext.Provider value={{ isScreenReaderEnabled: false }}>
|
| 178 |
+
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
|
| 179 |
+
</AccessibilityContext.Provider>
|
| 180 |
+
</StdoutContext.Provider>
|
| 181 |
</CursorDeclarationContext.Provider>
|
| 182 |
</ClockProvider>
|
| 183 |
</TerminalFocusProvider>
|
src/ink/components/StdoutContext.tsx
CHANGED
|
@@ -2,6 +2,11 @@ import { createContext, useContext } from 'react'
|
|
| 2 |
|
| 3 |
export type Props = {
|
| 4 |
readonly stdout: NodeJS.WriteStream & { readonly rows: number; readonly columns: number }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
readonly write: (data: string) => void
|
| 6 |
}
|
| 7 |
|
|
@@ -12,4 +17,8 @@ const StdoutContext = createContext<Props>({
|
|
| 12 |
|
| 13 |
StdoutContext.displayName = 'StdoutContext'
|
| 14 |
|
| 15 |
-
export default StdoutContext
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export type Props = {
|
| 4 |
readonly stdout: NodeJS.WriteStream & { readonly rows: number; readonly columns: number }
|
| 5 |
+
/**
|
| 6 |
+
* Write any string to stdout while preserving Ink's output.
|
| 7 |
+
* Useful for emitting raw escape sequences (e.g. image protocols)
|
| 8 |
+
* outside of Ink's normal render loop.
|
| 9 |
+
*/
|
| 10 |
readonly write: (data: string) => void
|
| 11 |
}
|
| 12 |
|
|
|
|
| 17 |
|
| 18 |
StdoutContext.displayName = 'StdoutContext'
|
| 19 |
|
| 20 |
+
export default StdoutContext
|
| 21 |
+
|
| 22 |
+
export function useStdout(): Props {
|
| 23 |
+
return useContext(StdoutContext)
|
| 24 |
+
}
|
src/ink/hooks/use-is-screen-reader-enabled.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useContext } from 'react'
|
| 2 |
+
import { useAccessibility } from '../components/AccessibilityContext.js'
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Hook that returns whether a screen reader is enabled.
|
| 6 |
+
* Useful when components need to render different output for screen readers.
|
| 7 |
+
*/
|
| 8 |
+
export default function useIsScreenReaderEnabled(): boolean {
|
| 9 |
+
const { isScreenReaderEnabled } = useAccessibility()
|
| 10 |
+
return isScreenReaderEnabled
|
| 11 |
+
}
|
src/ink/hooks/use-stdout.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useContext } from 'react'
|
| 2 |
+
import StdoutContext from '../components/StdoutContext.js'
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* A React hook that returns the stdout stream where Ink renders your app,
|
| 6 |
+
* plus a `write` function for emitting raw data to stdout outside of Ink's
|
| 7 |
+
* normal render loop (e.g. for inline image protocols).
|
| 8 |
+
*/
|
| 9 |
+
export default function useStdout() {
|
| 10 |
+
return useContext(StdoutContext)
|
| 11 |
+
}
|
src/ink/ink.tsx
CHANGED
|
@@ -1441,7 +1441,7 @@ export default class Ink {
|
|
| 1441 |
};
|
| 1442 |
render(node: ReactNode): void {
|
| 1443 |
this.currentNode = node;
|
| 1444 |
-
const tree = <App stdin={this.options.stdin} stdout={this.options.stdout} stderr={this.options.stderr} exitOnCtrlC={this.options.exitOnCtrlC} onExit={this.unmount} terminalColumns={this.terminalColumns} terminalRows={this.terminalRows} selection={this.selection} onSelectionChange={this.notifySelectionChange} onClickAt={this.dispatchClick} onHoverAt={this.dispatchHover} getHyperlinkAt={this.getHyperlinkAt} onOpenHyperlink={this.openHyperlink} onMultiClick={this.handleMultiClick} onSelectionDrag={this.handleSelectionDrag} onStdinResume={this.reassertTerminalModes} onCursorDeclaration={this.setCursorDeclaration} dispatchKeyboardEvent={this.dispatchKeyboardEvent}>
|
| 1445 |
<TerminalWriteProvider value={this.writeRaw}>
|
| 1446 |
{node}
|
| 1447 |
</TerminalWriteProvider>
|
|
|
|
| 1441 |
};
|
| 1442 |
render(node: ReactNode): void {
|
| 1443 |
this.currentNode = node;
|
| 1444 |
+
const tree = <App stdin={this.options.stdin} stdout={this.options.stdout} stderr={this.options.stderr} writeToStdout={this.writeRaw} exitOnCtrlC={this.options.exitOnCtrlC} onExit={this.unmount} terminalColumns={this.terminalColumns} terminalRows={this.terminalRows} selection={this.selection} onSelectionChange={this.notifySelectionChange} onClickAt={this.dispatchClick} onHoverAt={this.dispatchHover} getHyperlinkAt={this.getHyperlinkAt} onOpenHyperlink={this.openHyperlink} onMultiClick={this.handleMultiClick} onSelectionDrag={this.handleSelectionDrag} onStdinResume={this.reassertTerminalModes} onCursorDeclaration={this.setCursorDeclaration} dispatchKeyboardEvent={this.dispatchKeyboardEvent}>
|
| 1445 |
<TerminalWriteProvider value={this.writeRaw}>
|
| 1446 |
{node}
|
| 1447 |
</TerminalWriteProvider>
|