From ad098e1f054eb729369b88348347d489a867a8e0 Mon Sep 17 00:00:00 2001 From: Olivier Gagnon Date: Thu, 14 Jul 2022 16:25:20 -0400 Subject: [PATCH 1/2] refactor ANSII colors into it's own component --- src/Terminal/ui/TerminalRoot.tsx | 224 +---------------------------- src/ui/React/ANSIITypography.tsx | 235 +++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 222 deletions(-) create mode 100644 src/ui/React/ANSIITypography.tsx diff --git a/src/Terminal/ui/TerminalRoot.tsx b/src/Terminal/ui/TerminalRoot.tsx index 960e79d54..f2f065ddb 100644 --- a/src/Terminal/ui/TerminalRoot.tsx +++ b/src/Terminal/ui/TerminalRoot.tsx @@ -16,6 +16,7 @@ import { BitFlumeModal } from "../../BitNode/ui/BitFlumeModal"; import { CodingContractModal } from "../../ui/React/CodingContractModal"; import _ from "lodash"; +import { ANSIITypography } from "../../ui/React/ANSIITypography"; interface IActionTimerProps { terminal: ITerminal; @@ -43,37 +44,6 @@ const useStyles = makeStyles((theme: Theme) => padding: theme.spacing(0), height: "100%", }, - - success: { - whiteSpace: "pre-wrap", - overflowWrap: "anywhere", - margin: theme.spacing(0), - color: theme.colors.success, - }, - error: { - whiteSpace: "pre-wrap", - overflowWrap: "anywhere", - margin: theme.spacing(0), - color: theme.palette.error.main, - }, - primary: { - whiteSpace: "pre-wrap", - overflowWrap: "anywhere", - margin: theme.spacing(0), - color: theme.palette.primary.main, - }, - info: { - whiteSpace: "pre-wrap", - overflowWrap: "anywhere", - margin: theme.spacing(0), - color: theme.palette.info.main, - }, - warning: { - whiteSpace: "pre-wrap", - overflowWrap: "anywhere", - margin: theme.spacing(0), - color: theme.palette.warning.main, - }, }), ); @@ -133,194 +103,6 @@ export function TerminalRoot({ terminal, router, player }: IProps): React.ReactE }; }, []); - function lineClass(s: string): string { - 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; - } - - 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(); return ( <> @@ -330,9 +112,7 @@ export function TerminalRoot({ terminal, router, player }: IProps): React.ReactE if (item instanceof Output) return ( - - {parseOutputText(item)} - + ); if (item instanceof RawOutput) diff --git a/src/ui/React/ANSIITypography.tsx b/src/ui/React/ANSIITypography.tsx new file mode 100644 index 000000000..af9f35da6 --- /dev/null +++ b/src/ui/React/ANSIITypography.tsx @@ -0,0 +1,235 @@ +import { Typography } from "@mui/material"; +import React from "react"; +import makeStyles from "@mui/styles/makeStyles"; +import createStyles from "@mui/styles/createStyles"; +import { Theme } from "@mui/material/styles"; +import _ from "lodash"; + +// This particular eslint-disable is correct. +// In this super specific weird case we in fact do want a regex on an ANSII character. +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE = new RegExp("\u{001b}\\[(?.*?)m", "ug"); + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + success: { + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + margin: theme.spacing(0), + color: theme.colors.success, + }, + error: { + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + margin: theme.spacing(0), + color: theme.palette.error.main, + }, + primary: { + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + margin: theme.spacing(0), + color: theme.palette.primary.main, + }, + info: { + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + margin: theme.spacing(0), + color: theme.palette.info.main, + }, + warning: { + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + margin: theme.spacing(0), + color: theme.palette.warning.main, + }, + }), +); + +const lineClass = (classes: Record, s: string): string => { + const lineClassMap: Record = { + error: classes.error, + success: classes.success, + info: classes.info, + warn: classes.warning, + }; + return lineClassMap[s] || classes.primary; +}; + +interface IProps { + text: string; + color: "primary" | "error" | "success" | "info" | "warn"; +} + +export const ANSIITypography = React.memo((props: IProps): React.ReactElement => { + const classes = useStyles(); + const parts = []; + + // 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, ...props.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 : props.text.length; + const partText = props.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: props.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; + } + })} + + ); +}); + +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; + } + + 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; +} From 00ddeb751cd714c3da32a192b89bb2fcdb5cc106 Mon Sep 17 00:00:00 2001 From: Olivier Gagnon Date: Thu, 14 Jul 2022 16:34:42 -0400 Subject: [PATCH 2/2] Make tail box display ANSII colors --- src/ui/React/LogBoxManager.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/ui/React/LogBoxManager.tsx b/src/ui/React/LogBoxManager.tsx index 08d5e2586..7f608a020 100644 --- a/src/ui/React/LogBoxManager.tsx +++ b/src/ui/React/LogBoxManager.tsx @@ -19,6 +19,7 @@ import { findRunningScript } from "../../Script/ScriptHelpers"; import { Player } from "../../Player"; import { debounce } from "lodash"; import { Settings } from "../../Settings/Settings"; +import { ANSIITypography } from "./ANSIITypography"; let layerCounter = 0; @@ -185,20 +186,20 @@ function LogWindow(props: IProps): React.ReactElement { setMinimized(!minimized); } - function lineColor(s: string): string { + function lineColor(s: string): "error" | "success" | "warn" | "info" | "primary" { if (s.match(/(^\[[^\]]+\] )?ERROR/) || s.match(/(^\[[^\]]+\] )?FAIL/)) { - return Settings.theme.error; + return "error"; } if (s.match(/(^\[[^\]]+\] )?SUCCESS/)) { - return Settings.theme.success; + return "success"; } if (s.match(/(^\[[^\]]+\] )?WARN/)) { - return Settings.theme.warning; + return "warn"; } if (s.match(/(^\[[^\]]+\] )?INFO/)) { - return Settings.theme.info; + return "info"; } - return Settings.theme.primary; + return "primary"; } // And trigger fakeDrag when the window is resized @@ -319,10 +320,10 @@ function LogWindow(props: IProps): React.ReactElement { {script.logs.map( (line: string, i: number): JSX.Element => ( - - {line} + +
-
+ ), )}