bitburner-src/src/Terminal/ui/TerminalRoot.tsx

378 lines
12 KiB
TypeScript
Raw Normal View History

2021-09-17 02:14:09 +02:00
import React, { useState, useEffect, useRef } from "react";
2021-09-17 01:23:03 +02:00
import Typography from "@mui/material/Typography";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import { Link as MuiLink } from "@mui/material";
import { Theme } from "@mui/material/styles";
2021-09-17 02:14:09 +02:00
import makeStyles from "@mui/styles/makeStyles";
import createStyles from "@mui/styles/createStyles";
2021-09-17 01:23:03 +02:00
import Box from "@mui/material/Box";
2021-10-27 02:26:05 +02:00
import { ITerminal, Output, Link, RawOutput } from "../ITerminal";
2021-09-17 08:58:02 +02:00
import { IRouter } from "../../ui/Router";
2021-09-16 08:52:45 +02:00
import { IPlayer } from "../../PersonObjects/IPlayer";
import { TerminalInput } from "./TerminalInput";
2021-09-20 06:14:30 +02:00
import { TerminalEvents, TerminalClearEvents } from "../TerminalEvents";
2021-10-01 19:08:37 +02:00
import { BitFlumeModal } from "../../BitNode/ui/BitFlumeModal";
import { CodingContractModal } from "../../ui/React/CodingContractModal";
2021-09-24 00:47:43 +02:00
import _ from "lodash";
2021-09-16 20:43:39 +02:00
interface IActionTimerProps {
terminal: ITerminal;
}
function ActionTimer({ terminal }: IActionTimerProps): React.ReactElement {
return (
<Typography color={"primary"} paragraph={false}>
{terminal.getProgressText()}
</Typography>
);
}
2021-09-16 08:52:45 +02:00
const useStyles = makeStyles((theme: Theme) =>
createStyles({
nopadding: {
padding: theme.spacing(0),
2021-09-16 08:52:45 +02:00
},
preformatted: {
2021-09-16 20:55:55 +02:00
whiteSpace: "pre-wrap",
2021-09-18 08:21:48 +02:00
overflowWrap: "anywhere",
margin: theme.spacing(0),
2021-09-16 08:52:45 +02:00
},
2021-09-16 21:04:20 +02:00
list: {
padding: theme.spacing(0),
2021-09-16 21:04:20 +02:00
height: "100%",
},
2021-11-19 21:44:12 +01:00
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,
},
2021-09-16 08:52:45 +02:00
}),
);
interface IProps {
terminal: ITerminal;
2021-09-17 08:58:02 +02:00
router: IRouter;
2021-09-16 08:52:45 +02:00
player: IPlayer;
}
2021-09-17 08:58:02 +02:00
export function TerminalRoot({ terminal, router, player }: IProps): React.ReactElement {
2021-09-17 02:14:09 +02:00
const scrollHook = useRef<HTMLDivElement>(null);
2021-09-19 06:46:39 +02:00
const setRerender = useState(0)[1];
2021-09-20 06:14:30 +02:00
const [key, setKey] = useState(0);
2021-09-16 08:52:45 +02:00
function rerender(): void {
2021-09-19 06:46:39 +02:00
setRerender((old) => old + 1);
2021-09-16 08:52:45 +02:00
}
2021-09-20 06:14:30 +02:00
function clear(): void {
setKey((key) => key + 1);
}
useEffect(() => {
const debounced = _.debounce(async () => rerender(), 25, { maxWait: 50 });
const unsubscribe = TerminalEvents.subscribe(debounced);
return () => {
debounced.cancel();
unsubscribe();
2022-04-07 01:30:08 +02:00
};
}, []);
useEffect(() => {
const debounced = _.debounce(async () => clear(), 25, { maxWait: 50 });
const unsubscribe = TerminalClearEvents.subscribe(debounced);
return () => {
debounced.cancel();
unsubscribe();
2022-04-07 01:30:08 +02:00
};
}, []);
2021-09-16 08:52:45 +02:00
function doScroll(): number | undefined {
2021-09-17 03:49:38 +02:00
const hook = scrollHook.current;
if (hook !== null) {
return window.setTimeout(() => hook.scrollIntoView(true), 50);
2021-09-17 03:49:38 +02:00
}
2021-09-17 02:14:09 +02:00
}
2021-09-17 03:49:38 +02:00
doScroll();
useEffect(() => {
let scrollId: number;
const id = setTimeout(() => {
scrollId = doScroll() ?? 0;
}, 50);
return () => {
clearTimeout(id);
clearTimeout(scrollId);
2022-04-07 01:30:08 +02:00
};
2021-09-17 03:49:38 +02:00
}, []);
2021-11-19 21:44:12 +01:00
function lineClass(s: string): string {
2022-05-04 18:21:16 +02:00
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;
2021-11-19 21:44:12 +01:00
}
2022-05-04 18:21:16 +02:00
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";
2021-11-19 21:44:12 +01:00
}
2022-05-04 18:21:16 +02:00
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 });
}
});
}
2021-11-19 21:44:12 +01:00
}
2022-05-04 18:21:16 +02:00
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 });
2021-11-19 21:44:12 +01:00
}
2022-05-04 18:21:16 +02:00
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>
);
2021-11-19 21:44:12 +01:00
}
2021-09-16 08:52:45 +02:00
const classes = useStyles();
return (
2021-09-17 02:14:09 +02:00
<>
2021-09-17 08:04:44 +02:00
<Box width="100%" minHeight="100vh" display={"flex"} alignItems={"flex-end"}>
2021-09-20 06:14:30 +02:00
<List key={key} id="terminal" classes={{ root: classes.list }}>
2021-09-17 02:14:09 +02:00
{terminal.outputHistory.map((item, i) => {
if (item instanceof Output)
return (
<ListItem key={i} classes={{ root: classes.nopadding }}>
2021-11-19 21:44:12 +01:00
<Typography classes={{ root: lineClass(item.color) }} paragraph={false}>
2022-05-04 18:21:16 +02:00
{parseOutputText(item)}
2021-09-17 02:14:09 +02:00
</Typography>
</ListItem>
);
2021-10-27 02:26:05 +02:00
if (item instanceof RawOutput)
return (
<ListItem key={i} classes={{ root: classes.nopadding }}>
<Typography classes={{ root: classes.preformatted }} paragraph={false}>
{item.raw}
</Typography>
</ListItem>
);
2021-09-17 02:14:09 +02:00
if (item instanceof Link)
return (
<ListItem key={i} classes={{ root: classes.nopadding }}>
2021-09-22 18:56:55 +02:00
<Typography>{item.dashes}&gt;&nbsp;</Typography>
<MuiLink
2021-09-17 02:14:09 +02:00
classes={{ root: classes.preformatted }}
color={"secondary"}
paragraph={false}
onClick={() => terminal.connectToServer(player, item.hostname)}
>
2021-10-01 22:42:07 +02:00
<Typography>{item.hostname}</Typography>
2021-09-17 02:14:09 +02:00
</MuiLink>
</ListItem>
);
})}
2021-09-17 09:08:15 +02:00
{terminal.action !== null && (
<ListItem classes={{ root: classes.nopadding }}>
<ActionTimer terminal={terminal} />{" "}
</ListItem>
)}
2021-09-17 02:14:09 +02:00
</List>
<div ref={scrollHook}></div>
</Box>
2021-09-17 08:04:44 +02:00
<Box position="sticky" bottom={0} width="100%" px={0}>
2021-09-17 08:58:02 +02:00
<TerminalInput player={player} router={router} terminal={terminal} />
2021-09-17 02:14:09 +02:00
</Box>
2021-10-01 19:08:37 +02:00
<BitFlumeModal />
<CodingContractModal />
2021-09-17 02:14:09 +02:00
</>
2021-09-16 08:52:45 +02:00
);
}