import React from 'react'; import Link from './components/Link.js'; import Text from './components/Text.js'; import type { Color } from './styles.js'; import { type NamedColor, Parser, type Color as TermioColor, type TextStyle, } from './termio.js'; type Props = { children: string; /** When true, force all text to be rendered with dim styling. */ dimColor?: boolean; }; type SpanProps = { color?: Color; backgroundColor?: Color; dim?: boolean; bold?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean; inverse?: boolean; hyperlink?: string; }; type Span = { text: string; props: SpanProps; }; export const Ansi = React.memo(function Ansi({ children, dimColor }: Props) { if (typeof children !== 'string') { return dimColor ? {String(children)} : {String(children)}; } if (children === '') return null; const spans = parseToSpans(children); if (spans.length === 0) return null; // Single-span fast path: skip the array map and avoid the wrapper // when the only span has no styling of its own. if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) { return dimColor ? {spans[0]!.text} : {spans[0]!.text}; } const content = spans.map((span, i) => { const hyperlink = span.props.hyperlink; if (dimColor) span.props.dim = true; const hasTextProps = hasAnyTextProps(span.props); if (hyperlink) { return hasTextProps ? ( {span.text} ) : ( {span.text} ); } return hasTextProps ? ( {span.text} ) : ( span.text ); }); return dimColor ? {content} : {content}; }); function parseToSpans(input: string): Span[] { const parser = new Parser(); const actions = parser.feed(input); const spans: Span[] = []; let currentHyperlink: string | undefined; for (const action of actions) { if (action.type === 'link') { currentHyperlink = action.action.type === 'start' ? action.action.url : undefined; continue; } if (action.type === 'text') { const text = action.graphemes.map((g) => g.value).join(''); if (!text) continue; const props = textStyleToSpanProps(action.style); if (currentHyperlink) props.hyperlink = currentHyperlink; const lastSpan = spans[spans.length - 1]; if (lastSpan && propsEqual(lastSpan.props, props)) { lastSpan.text += text; } else { spans.push({ text, props }); } } } return spans; } function textStyleToSpanProps(style: TextStyle): SpanProps { const props: SpanProps = {}; if (style.bold) props.bold = true; if (style.dim) props.dim = true; if (style.italic) props.italic = true; if (style.underline !== 'none') props.underline = true; if (style.strikethrough) props.strikethrough = true; if (style.inverse) props.inverse = true; const fg = colorToString(style.fg); if (fg) props.color = fg; const bg = colorToString(style.bg); if (bg) props.backgroundColor = bg; return props; } /** Map termio's named-colour identifiers to the `ansi:*` form Ink uses. */ const NAMED_COLOR_MAP: Record = { black: 'ansi:black', red: 'ansi:red', green: 'ansi:green', yellow: 'ansi:yellow', blue: 'ansi:blue', magenta: 'ansi:magenta', cyan: 'ansi:cyan', white: 'ansi:white', brightBlack: 'ansi:blackBright', brightRed: 'ansi:redBright', brightGreen: 'ansi:greenBright', brightYellow: 'ansi:yellowBright', brightBlue: 'ansi:blueBright', brightMagenta: 'ansi:magentaBright', brightCyan: 'ansi:cyanBright', brightWhite: 'ansi:whiteBright', }; function colorToString(color: TermioColor): Color | undefined { switch (color.type) { case 'named': return NAMED_COLOR_MAP[color.name] as Color; case 'indexed': return `ansi256(${color.index})` as Color; case 'rgb': return `rgb(${color.r},${color.g},${color.b})` as Color; case 'default': return undefined; } } function propsEqual(a: SpanProps, b: SpanProps): boolean { return ( a.color === b.color && a.backgroundColor === b.backgroundColor && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline && a.strikethrough === b.strikethrough && a.inverse === b.inverse && a.hyperlink === b.hyperlink ); } function hasAnyProps(props: SpanProps): boolean { return ( props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true || props.hyperlink !== undefined ); } function hasAnyTextProps(props: SpanProps): boolean { return ( props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true ); } type BaseTextStyleProps = { color?: Color; backgroundColor?: Color; italic?: boolean; underline?: boolean; strikethrough?: boolean; inverse?: boolean; }; type StyledTextProps = BaseTextStyleProps & { bold?: boolean; dim?: boolean; children: React.ReactNode; }; function StyledText({ bold, dim, children, ...rest }: StyledTextProps) { if (dim) { return ( {children} ); } if (bold) { return ( {children} ); } return {children}; }