diff --git a/src/Terminal/ui/TerminalRoot.tsx b/src/Terminal/ui/TerminalRoot.tsx index c495671d6..960e79d54 100644 --- a/src/Terminal/ui/TerminalRoot.tsx +++ b/src/Terminal/ui/TerminalRoot.tsx @@ -134,20 +134,191 @@ export function TerminalRoot({ terminal, router, player }: IProps): React.ReactE }, []); function lineClass(s: string): string { - if (s === "error") { - return classes.error; - } - if (s === "success") { - return classes.success; - } - if (s === "info") { - return classes.info; - } - if (s === "warn") { - return classes.warning; + const lineClassMap: Record = { + error: classes.error, + success: classes.success, + info: classes.info, + warn: classes.warning, + }; + return lineClassMap[s] || classes.primary; + } + + function ansiCodeStyle(code: string | null): Record { + // The ANSI colors actually have the dark color set as default and require extra work to get + // bright colors. But these are rarely used or, if they are, are often re-mapped by the + // terminal emulator to brighter colors. So for foreground colors we use the bright color set + // and for background colors we use the dark color set. Of course, all colors are available + // via the longer ESC[n8;5;c] sequence (n={3,4}, c=color). Ideally, these 8-bit maps could + // be managed in the user preferences/theme. + const COLOR_MAP_BRIGHT: Record = { + 0: "#404040", + 1: "#ff0000", + 2: "#00ff00", + 3: "#ffff00", + 4: "#0000ff", + 5: "#ff00ff", + 6: "#00ffff", + 7: "#ffffff", + }; + const COLOR_MAP_DARK: Record = { + 0: "#000000", + 1: "#800000", + 2: "#008000", + 3: "#808000", + 4: "#000080", + 5: "#800080", + 6: "#008080", + 7: "#c0c0c0", + }; + + const ansi2rgb = (code: number): string => { + /* eslint-disable yoda */ + if (0 <= code && code < 8) { + // x8 RGB + return COLOR_MAP_BRIGHT[code]; + } + if (8 <= code && code < 16) { + // x8 RGB - "High Intensity" (but here, actually the dark set) + return COLOR_MAP_DARK[code]; + } + if (16 <= code && code < 232) { + // x216 RGB + const base = code - 16; + const ir = Math.floor(base / 36); + const ig = Math.floor((base % 36) / 6); + const ib = Math.floor((base % 6) / 1); + const r = ir <= 0 ? 0 : 55 + ir * 40; + const g = ig <= 0 ? 0 : 55 + ig * 40; + const b = ib <= 0 ? 0 : 55 + ib * 40; + return `rgb(${r}, ${g}, ${b})`; + } + if (232 <= code && code < 256) { + // x32 greyscale + const base = code - 232; + const grey = base * 10 + 8; + return `rgb(${grey}, ${grey}, ${grey})`; + } + // shouldn't get here (under normal circumstances), but just in case + return "initial"; + }; + + type styleKey = "fontWeight" | "textDecoration" | "color" | "backgroundColor" | "display"; + const style: { + fontWeight?: string; + textDecoration?: string; + color?: string; + backgroundColor?: string; + display?: string; + } = {}; + + if (code === null || code === "0") { + return style; } - return classes.primary; + const codeParts = code + .split(";") + .map((p) => parseInt(p)) + .filter( + (p, i, arr) => + // If the sequence is 38;5 (x256 foreground color) or 48;5 (x256 background color), + // filter out the 5 so the next codePart after {38,48} is the color code. + p != 5 || i == 0 || (arr[i - 1] != 38 && arr[i - 1] != 48), + ); + + let nextStyleKey: styleKey | null = null; + codeParts.forEach((codePart) => { + /* eslint-disable yoda */ + if (nextStyleKey !== null) { + style[nextStyleKey] = ansi2rgb(codePart); + nextStyleKey = null; + } + // Decorations + else if (codePart == 1) { + style.fontWeight = "bold"; + } else if (codePart == 4) { + style.textDecoration = "underline"; + } + // Forground Color (x8) + else if (30 <= codePart && codePart < 38) { + if (COLOR_MAP_BRIGHT[codePart % 10]) { + style.color = COLOR_MAP_BRIGHT[codePart % 10]; + } + } + // Background Color (x8) + else if (40 <= codePart && codePart < 48) { + if (COLOR_MAP_DARK[codePart % 10]) { + style.backgroundColor = COLOR_MAP_DARK[codePart % 10]; + } + } + // Forground Color (x256) + else if (codePart == 38) { + nextStyleKey = "color"; + } + // Background Color (x256) + else if (codePart == 48) { + nextStyleKey = "backgroundColor"; + } + }); + // If a background color is set, render this as an inline block element (instead of inline) + // so that the background between lines (at least those that don't wrap) is uninterrupted. + if (style.backgroundColor !== undefined) { + style.display = "inline-block"; + } + return style; + } + + function parseOutputText(item: Output): React.ReactElement { + const parts = []; + // Some things, oddly, can cause item.text to not be a string (e.g. expr from the CLI), which + // causes .matchAll to throw. Ensure it's a string immediately + if (typeof item.text === "string") { + // eslint-disable-next-line no-control-regex + const ANSI_ESCAPE = new RegExp("\u{001b}\\[(?.*?)m", "ug"); + // Build a look-alike regex match to place at the front of the matches list + const INITIAL = { + 0: "", + index: 0, + groups: { code: null }, + }; + const matches = [INITIAL, ...item.text.matchAll(ANSI_ESCAPE), null]; + if (matches.length > 2) { + matches.slice(0, -1).forEach((m, i) => { + const n = matches[i + 1]; + if (!m || m.index === undefined || m.groups === undefined) { + return; + } + const startIndex = m.index + m[0].length; + const stopIndex = n ? n.index : item.text.length; + const partText = item.text.slice(startIndex, stopIndex); + if (startIndex !== stopIndex) { + // Don't generate "empty" spans + parts.push({ code: m.groups.code, text: partText }); + } + }); + } + } + if (parts.length === 0) { + // For example, if the string was empty or there were no escape sequence matches + parts.push({ code: null, text: item.text }); + } + return ( + + {parts.map((part, index) => { + const spanStyle = ansiCodeStyle(part.code); + if (!_.isEmpty(spanStyle)) { + // Only wrap parts with spans if the calculated spanStyle is non-empty... + return ( + + {part.text} + + ); + } else { + // ... otherwise yield the unmodified, unwrapped part. + return part.text; + } + })} + + ); } const classes = useStyles(); @@ -160,7 +331,7 @@ export function TerminalRoot({ terminal, router, player }: IProps): React.ReactE return ( - {item.text} + {parseOutputText(item)} );