mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2024-12-24 07:02:26 +01:00
Merge pull request #3903 from danielyxie/ansii-logbox
TAILBOX: Now displays ANSII colors.
This commit is contained in:
commit
686ca3e31e
@ -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<string, string> = {
|
||||
error: classes.error,
|
||||
success: classes.success,
|
||||
info: classes.info,
|
||||
warn: classes.warning,
|
||||
};
|
||||
return lineClassMap[s] || classes.primary;
|
||||
}
|
||||
|
||||
function ansiCodeStyle(code: string | null): Record<string, any> {
|
||||
// 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<string | number, string> = {
|
||||
0: "#404040",
|
||||
1: "#ff0000",
|
||||
2: "#00ff00",
|
||||
3: "#ffff00",
|
||||
4: "#0000ff",
|
||||
5: "#ff00ff",
|
||||
6: "#00ffff",
|
||||
7: "#ffffff",
|
||||
};
|
||||
const COLOR_MAP_DARK: Record<string | number, string> = {
|
||||
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}\\[(?<code>.*?)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 (
|
||||
<Typography classes={{ root: lineClass(item.color) }} paragraph={false}>
|
||||
{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 (
|
||||
<Typography key={index} paragraph={false} component="span" sx={spanStyle}>
|
||||
{part.text}
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
// ... otherwise yield the unmodified, unwrapped part.
|
||||
return part.text;
|
||||
}
|
||||
})}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<>
|
||||
@ -330,9 +112,7 @@ export function TerminalRoot({ terminal, router, player }: IProps): React.ReactE
|
||||
if (item instanceof Output)
|
||||
return (
|
||||
<ListItem key={i} classes={{ root: classes.nopadding }}>
|
||||
<Typography classes={{ root: lineClass(item.color) }} paragraph={false}>
|
||||
{parseOutputText(item)}
|
||||
</Typography>
|
||||
<ANSIITypography text={item.text} color={item.color} />
|
||||
</ListItem>
|
||||
);
|
||||
if (item instanceof RawOutput)
|
||||
|
235
src/ui/React/ANSIITypography.tsx
Normal file
235
src/ui/React/ANSIITypography.tsx
Normal file
@ -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}\\[(?<code>.*?)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<string, string>, s: string): string => {
|
||||
const lineClassMap: Record<string, string> = {
|
||||
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 (
|
||||
<Typography classes={{ root: lineClass(classes, props.color) }} paragraph={false}>
|
||||
{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 (
|
||||
<Typography key={index} paragraph={false} component="span" sx={spanStyle}>
|
||||
{part.text}
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
// ... otherwise yield the unmodified, unwrapped part.
|
||||
return part.text;
|
||||
}
|
||||
})}
|
||||
</Typography>
|
||||
);
|
||||
});
|
||||
|
||||
function ansiCodeStyle(code: string | null): Record<string, any> {
|
||||
// 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<number, string> = {
|
||||
0: "#404040",
|
||||
1: "#ff0000",
|
||||
2: "#00ff00",
|
||||
3: "#ffff00",
|
||||
4: "#0000ff",
|
||||
5: "#ff00ff",
|
||||
6: "#00ffff",
|
||||
7: "#ffffff",
|
||||
};
|
||||
const COLOR_MAP_DARK: Record<number, string> = {
|
||||
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;
|
||||
}
|
@ -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 {
|
||||
<span style={{ display: "flex", flexDirection: "column" }}>
|
||||
{script.logs.map(
|
||||
(line: string, i: number): JSX.Element => (
|
||||
<Typography key={i} sx={{ color: lineColor(line) }}>
|
||||
{line}
|
||||
<React.Fragment key={i}>
|
||||
<ANSIITypography text={line} color={lineColor(line)} />
|
||||
<br />
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
|
Loading…
Reference in New Issue
Block a user