refactor temrinal input for more performace

This commit is contained in:
Olivier Gagnon 2021-09-16 15:37:01 -04:00
parent 07721e1cc5
commit 22648df857
5 changed files with 412 additions and 333 deletions

@ -45,6 +45,11 @@ interface IDefaultSettings {
*/
MaxPortCapacity: number;
/**
* Limit the number of entries in the terminal.
*/
MaxTerminalCapacity: number;
/**
* Whether the player should be asked to confirm purchasing each and every augmentation.
*/
@ -104,6 +109,7 @@ const defaultSettings: IDefaultSettings = {
Locale: "en",
MaxLogCapacity: 50,
MaxPortCapacity: 50,
MaxTerminalCapacity: 200,
SuppressBuyAugmentationConfirmation: false,
SuppressFactionInvites: false,
SuppressHospitalizationPopup: false,
@ -125,6 +131,7 @@ export const Settings: ISettings & ISelfInitializer & ISelfLoading = {
Locale: "en",
MaxLogCapacity: defaultSettings.MaxLogCapacity,
MaxPortCapacity: defaultSettings.MaxPortCapacity,
MaxTerminalCapacity: defaultSettings.MaxTerminalCapacity,
OwnedAugmentationsOrder: OwnedAugmentationsOrderSetting.AcquirementTime,
PurchaseAugmentationsOrder: PurchaseAugmentationsOrderSetting.Default,
SuppressBuyAugmentationConfirmation: defaultSettings.SuppressBuyAugmentationConfirmation,

@ -23,6 +23,7 @@ import { TerminalHelpText } from "./HelpText";
import { GetServerByHostname, getServer, getServerOnNetwork } from "../Server/ServerHelpers";
import { ParseCommand, ParseCommands } from "./Parser";
import { SpecialServerIps, SpecialServerNames } from "../Server/SpecialServerIps";
import { Settings } from "../Settings/Settings";
import { createProgressBarText } from "../../utils/helpers/createProgressBarText";
import {
calculateHackingChance,
@ -101,6 +102,13 @@ export class Terminal implements ITerminal {
return false;
}
append(item: Output | Link): void {
this.outputHistory.push(item);
if (this.outputHistory.length > Settings.MaxTerminalCapacity) {
this.outputHistory.slice(this.outputHistory.length - Settings.MaxTerminalCapacity);
}
}
print(s: string, config?: any): void {
this.outputHistory.push(new Output(s, "primary"));
this.hasChanges = true;

@ -0,0 +1,362 @@
import React, { useState, useEffect, useRef } from "react";
import Typography from "@material-ui/core/Typography";
import { makeStyles, createStyles, Theme } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import { KEY } from "../../../utils/helpers/keyCodes";
import { ITerminal } from "../ITerminal";
import { IEngine } from "../../IEngine";
import { IPlayer } from "../../PersonObjects/IPlayer";
import { determineAllPossibilitiesForTabCompletion } from "../determineAllPossibilitiesForTabCompletion";
import { tabCompletion } from "../tabCompletion";
import { FconfSettings } from "../../Fconf/FconfSettings";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
textfield: {
margin: theme.spacing(0),
width: "100%",
},
input: {
backgroundColor: "#000",
},
nopadding: {
padding: theme.spacing(0),
},
preformatted: {
whiteSpace: "pre-wrap",
margin: theme.spacing(0),
},
list: {
padding: theme.spacing(0),
height: "100%",
},
}),
);
interface IProps {
terminal: ITerminal;
engine: IEngine;
player: IPlayer;
}
export function TerminalInput({ terminal, engine, player }: IProps): React.ReactElement {
const terminalInput = useRef<HTMLInputElement>(null);
const [value, setValue] = useState("");
const [possibilities, setPossibilities] = useState<string[]>([]);
const classes = useStyles();
function handleValueChange(event: React.ChangeEvent<HTMLInputElement>): void {
setValue(event.target.value);
setPossibilities([]);
}
function modifyInput(mod: string): void {
const ref = terminalInput.current;
if (!ref) return;
const inputLength = value.length;
const start = ref.selectionStart;
if (start === null) return;
const inputText = ref.value;
switch (mod.toLowerCase()) {
case "backspace":
if (start > 0 && start <= inputLength + 1) {
setValue(inputText.substr(0, start - 1) + inputText.substr(start));
}
break;
case "deletewordbefore": // Delete rest of word before the cursor
for (let delStart = start - 1; delStart > 0; --delStart) {
if (inputText.charAt(delStart) === " ") {
setValue(inputText.substr(0, delStart) + inputText.substr(start));
return;
}
}
break;
case "deletewordafter": // Delete rest of word after the cursor
for (let delStart = start + 1; delStart <= value.length + 1; ++delStart) {
if (inputText.charAt(delStart) === " ") {
setValue(inputText.substr(0, start) + inputText.substr(delStart));
return;
}
}
break;
case "clearafter": // Deletes everything after cursor
break;
case "clearbefore:": // Deleetes everything before cursor
break;
}
}
function moveTextCursor(loc: string): void {
const ref = terminalInput.current;
if (!ref) return;
const inputLength = value.length;
const start = ref.selectionStart;
if (start === null) return;
switch (loc.toLowerCase()) {
case "home":
ref.setSelectionRange(0, 0);
break;
case "end":
ref.setSelectionRange(inputLength, inputLength);
break;
case "prevchar":
if (start > 0) {
ref.setSelectionRange(start - 1, start - 1);
}
break;
case "prevword":
for (let i = start - 2; i >= 0; --i) {
if (ref.value.charAt(i) === " ") {
ref.setSelectionRange(i + 1, i + 1);
return;
}
}
ref.setSelectionRange(0, 0);
break;
case "nextchar":
ref.setSelectionRange(start + 1, start + 1);
break;
case "nextword":
for (let i = start + 1; i <= inputLength; ++i) {
if (ref.value.charAt(i) === " ") {
ref.setSelectionRange(i, i);
return;
}
}
ref.setSelectionRange(inputLength, inputLength);
break;
default:
console.warn("Invalid loc argument in Terminal.moveTextCursor()");
break;
}
}
// Catch all key inputs and redirect them to the terminal.
useEffect(() => {
function keyDown(this: Document, event: KeyboardEvent): void {
if (terminal.contractOpen) return;
const ref = terminalInput.current;
if (ref) ref.focus();
// Cancel action
if (event.keyCode === KEY.C && event.ctrlKey) {
terminal.finishAction(player, true);
}
}
document.addEventListener("keydown", keyDown);
return () => document.removeEventListener("keydown", keyDown);
});
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>): void {
// Run command.
if (event.keyCode === KEY.ENTER && value !== "") {
event.preventDefault();
terminal.print(`[${player.getCurrentServer().hostname} ~${terminal.cwd()}]> ${value}`);
terminal.executeCommands(engine, player, value);
setValue("");
return;
}
// Autocomplete
if (event.keyCode === KEY.TAB && value !== "") {
event.preventDefault();
let copy = value;
const semiColonIndex = copy.lastIndexOf(";");
if (semiColonIndex !== -1) {
copy = copy.slice(semiColonIndex + 1);
}
copy = copy.trim();
copy = copy.replace(/\s\s+/g, " ");
const commandArray = copy.split(" ");
let index = commandArray.length - 2;
if (index < -1) {
index = 0;
}
const allPos = determineAllPossibilitiesForTabCompletion(player, copy, index, terminal.cwd());
if (allPos.length == 0) {
return;
}
let arg = "";
let command = "";
if (commandArray.length == 0) {
return;
}
if (commandArray.length == 1) {
command = commandArray[0];
} else if (commandArray.length == 2) {
command = commandArray[0];
arg = commandArray[1];
} else if (commandArray.length == 3) {
command = commandArray[0] + " " + commandArray[1];
arg = commandArray[2];
} else {
arg = commandArray.pop() + "";
command = commandArray.join(" ");
}
const newValue = tabCompletion(command, arg, allPos, value);
if (typeof newValue === "string" && newValue !== "") {
setValue(newValue);
}
if (Array.isArray(newValue)) {
setPossibilities(newValue);
}
}
// Clear screen.
if (event.keyCode === KEY.L && event.ctrlKey) {
event.preventDefault();
terminal.clear();
}
// Select previous command.
if (
event.keyCode === KEY.UPARROW ||
(FconfSettings.ENABLE_BASH_HOTKEYS && event.keyCode === KEY.P && event.ctrlKey)
) {
if (FconfSettings.ENABLE_BASH_HOTKEYS) {
event.preventDefault();
}
const i = terminal.commandHistoryIndex;
const len = terminal.commandHistory.length;
if (len == 0) {
return;
}
if (i < 0 || i > len) {
terminal.commandHistoryIndex = len;
}
if (i != 0) {
--terminal.commandHistoryIndex;
}
const prevCommand = terminal.commandHistory[terminal.commandHistoryIndex];
setValue(prevCommand);
const ref = terminalInput.current;
if (ref) {
setTimeout(function () {
ref.selectionStart = ref.selectionEnd = 10000;
}, 10);
}
}
// Select next command
if (
event.keyCode === KEY.DOWNARROW ||
(FconfSettings.ENABLE_BASH_HOTKEYS && event.keyCode === KEY.M && event.ctrlKey)
) {
if (FconfSettings.ENABLE_BASH_HOTKEYS) {
event.preventDefault();
}
const i = terminal.commandHistoryIndex;
const len = terminal.commandHistory.length;
if (len == 0) {
return;
}
if (i < 0 || i > len) {
terminal.commandHistoryIndex = len;
}
// Latest command, put nothing
if (i == len || i == len - 1) {
terminal.commandHistoryIndex = len;
setValue("");
} else {
++terminal.commandHistoryIndex;
const prevCommand = terminal.commandHistory[terminal.commandHistoryIndex];
setValue(prevCommand);
}
}
// Extra Bash Emulation Hotkeys, must be enabled through .fconf
if (FconfSettings.ENABLE_BASH_HOTKEYS) {
if (event.keyCode === KEY.A && event.ctrlKey) {
event.preventDefault();
moveTextCursor("home");
}
if (event.keyCode === KEY.E && event.ctrlKey) {
event.preventDefault();
moveTextCursor("end");
}
if (event.keyCode === KEY.B && event.ctrlKey) {
event.preventDefault();
moveTextCursor("prevchar");
}
if (event.keyCode === KEY.B && event.altKey) {
event.preventDefault();
moveTextCursor("prevword");
}
if (event.keyCode === KEY.F && event.ctrlKey) {
event.preventDefault();
moveTextCursor("nextchar");
}
if (event.keyCode === KEY.F && event.altKey) {
event.preventDefault();
moveTextCursor("nextword");
}
if ((event.keyCode === KEY.H || event.keyCode === KEY.D) && event.ctrlKey) {
modifyInput("backspace");
event.preventDefault();
}
// TODO AFTER THIS:
// alt + d deletes word after cursor
// ^w deletes word before cursor
// ^k clears line after cursor
// ^u clears line before cursor
}
}
return (
<>
{possibilities.length > 0 && (
<>
<Typography classes={{ root: classes.preformatted }} color={"primary"} paragraph={false}>
Possible autocomplete candidate:
</Typography>
<Typography classes={{ root: classes.preformatted }} color={"primary"} paragraph={false}>
{possibilities.join(" ")}
</Typography>
</>
)}
<TextField
color={terminal.action === null ? "primary" : "secondary"}
autoFocus
disabled={terminal.action !== null}
autoComplete="off"
classes={{ root: classes.textfield }}
value={value}
onChange={handleValueChange}
inputRef={terminalInput}
InputProps={{
// for players to hook in
id: "terminal-input",
className: classes.input,
startAdornment: (
<>
<Typography color={terminal.action === null ? "primary" : "secondary"}>
[{player.getCurrentServer().hostname}&nbsp;~{terminal.cwd()}]&gt;&nbsp;
</Typography>
</>
),
spellCheck: false,
onKeyDown: onKeyDown,
}}
></TextField>
</>
);
}

@ -1,19 +1,14 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import Typography from "@material-ui/core/Typography";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import { Link as MuiLink } from "@material-ui/core";
import { makeStyles, createStyles, Theme } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import Box from "@material-ui/core/Box";
import { KEY } from "../../../utils/helpers/keyCodes";
import { ITerminal, Output, Link, TTimer } from "../ITerminal";
import { ITerminal, Output, Link } from "../ITerminal";
import { IEngine } from "../../IEngine";
import { IPlayer } from "../../PersonObjects/IPlayer";
import { determineAllPossibilitiesForTabCompletion } from "../determineAllPossibilitiesForTabCompletion";
import { tabCompletion } from "../tabCompletion";
import { FconfSettings } from "../../Fconf/FconfSettings";
import { createProgressBarText } from "../../../utils/helpers/createProgressBarText";
import { TerminalInput } from "./TerminalInput";
interface IActionTimerProps {
terminal: ITerminal;
@ -29,24 +24,16 @@ function ActionTimer({ terminal }: IActionTimerProps): React.ReactElement {
const useStyles = makeStyles((theme: Theme) =>
createStyles({
textfield: {
margin: 0,
width: "100%",
},
input: {
backgroundColor: "#000",
},
nopadding: {
padding: 0,
padding: theme.spacing(0),
},
preformatted: {
whiteSpace: "pre-wrap",
margin: 0,
margin: theme.spacing(0),
},
list: {
padding: 0,
padding: theme.spacing(0),
height: "100%",
overflowY: "scroll",
},
}),
);
@ -58,7 +45,6 @@ interface IProps {
}
export function TerminalRoot({ terminal, engine, player }: IProps): React.ReactElement {
const terminalInput = useRef<HTMLInputElement>(null);
const setRerender = useState(false)[1];
function rerender(): void {
setRerender((old) => !old);
@ -71,286 +57,7 @@ export function TerminalRoot({ terminal, engine, player }: IProps): React.ReactE
return () => clearInterval(id);
}, []);
const [value, setValue] = useState("");
const [possibilities, setPossibilities] = useState<string[]>([]);
const classes = useStyles();
function handleValueChange(event: React.ChangeEvent<HTMLInputElement>): void {
setValue(event.target.value);
setPossibilities([]);
}
function modifyInput(mod: string): void {
const ref = terminalInput.current;
if (!ref) return;
const inputLength = value.length;
const start = ref.selectionStart;
if (start === null) return;
const inputText = ref.value;
switch (mod.toLowerCase()) {
case "backspace":
if (start > 0 && start <= inputLength + 1) {
setValue(inputText.substr(0, start - 1) + inputText.substr(start));
}
break;
case "deletewordbefore": // Delete rest of word before the cursor
for (var delStart = start - 1; delStart > 0; --delStart) {
if (inputText.charAt(delStart) === " ") {
setValue(inputText.substr(0, delStart) + inputText.substr(start));
return;
}
}
break;
case "deletewordafter": // Delete rest of word after the cursor
for (var delStart = start + 1; delStart <= value.length + 1; ++delStart) {
if (inputText.charAt(delStart) === " ") {
setValue(inputText.substr(0, start) + inputText.substr(delStart));
return;
}
}
break;
case "clearafter": // Deletes everything after cursor
break;
case "clearbefore:": // Deleetes everything before cursor
break;
}
}
function moveTextCursor(loc: string) {
const ref = terminalInput.current;
if (!ref) return;
const inputLength = value.length;
const start = ref.selectionStart;
if (start === null) return;
switch (loc.toLowerCase()) {
case "home":
ref.setSelectionRange(0, 0);
break;
case "end":
ref.setSelectionRange(inputLength, inputLength);
break;
case "prevchar":
if (start > 0) {
ref.setSelectionRange(start - 1, start - 1);
}
break;
case "prevword":
for (let i = start - 2; i >= 0; --i) {
if (ref.value.charAt(i) === " ") {
ref.setSelectionRange(i + 1, i + 1);
return;
}
}
ref.setSelectionRange(0, 0);
break;
case "nextchar":
ref.setSelectionRange(start + 1, start + 1);
break;
case "nextword":
for (let i = start + 1; i <= inputLength; ++i) {
if (ref.value.charAt(i) === " ") {
ref.setSelectionRange(i, i);
return;
}
}
ref.setSelectionRange(inputLength, inputLength);
break;
default:
console.warn("Invalid loc argument in Terminal.moveTextCursor()");
break;
}
}
// Catch all key inputs and redirect them to the terminal.
useEffect(() => {
function keyDown(this: Document, event: KeyboardEvent): void {
if (terminal.contractOpen) return;
const ref = terminalInput.current;
if (ref) ref.focus();
// Cancel action
if (event.keyCode === KEY.C && event.ctrlKey) {
terminal.finishAction(player, true);
}
}
document.addEventListener("keydown", keyDown);
return () => document.removeEventListener("keydown", keyDown);
});
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>): void {
// Run command.
if (event.keyCode === KEY.ENTER && value !== "") {
event.preventDefault();
terminal.print(`[${player.getCurrentServer().hostname} ~${terminal.cwd()}]> ${value}`);
terminal.executeCommands(engine, player, value);
setValue("");
return;
}
// Autocomplete
if (event.keyCode === KEY.TAB && value !== "") {
event.preventDefault();
let copy = value;
const semiColonIndex = copy.lastIndexOf(";");
if (semiColonIndex !== -1) {
copy = copy.slice(semiColonIndex + 1);
}
copy = copy.trim();
copy = copy.replace(/\s\s+/g, " ");
const commandArray = copy.split(" ");
let index = commandArray.length - 2;
if (index < -1) {
index = 0;
}
const allPos = determineAllPossibilitiesForTabCompletion(player, copy, index, terminal.cwd());
if (allPos.length == 0) {
return;
}
let arg = "";
let command = "";
if (commandArray.length == 0) {
return;
}
if (commandArray.length == 1) {
command = commandArray[0];
} else if (commandArray.length == 2) {
command = commandArray[0];
arg = commandArray[1];
} else if (commandArray.length == 3) {
command = commandArray[0] + " " + commandArray[1];
arg = commandArray[2];
} else {
arg = commandArray.pop() + "";
command = commandArray.join(" ");
}
const newValue = tabCompletion(command, arg, allPos, value);
if (typeof newValue === "string" && newValue !== "") {
setValue(newValue);
}
if (Array.isArray(newValue)) {
setPossibilities(newValue);
}
}
// Clear screen.
if (event.keyCode === KEY.L && event.ctrlKey) {
event.preventDefault();
terminal.clear();
rerender();
}
// Select previous command.
if (
event.keyCode === KEY.UPARROW ||
(FconfSettings.ENABLE_BASH_HOTKEYS && event.keyCode === KEY.P && event.ctrlKey)
) {
if (FconfSettings.ENABLE_BASH_HOTKEYS) {
event.preventDefault();
}
const i = terminal.commandHistoryIndex;
const len = terminal.commandHistory.length;
if (len == 0) {
return;
}
if (i < 0 || i > len) {
terminal.commandHistoryIndex = len;
}
if (i != 0) {
--terminal.commandHistoryIndex;
}
const prevCommand = terminal.commandHistory[terminal.commandHistoryIndex];
setValue(prevCommand);
const ref = terminalInput.current;
if (ref) {
setTimeout(function () {
ref.selectionStart = ref.selectionEnd = 10000;
}, 10);
}
}
// Select next command
if (
event.keyCode === KEY.DOWNARROW ||
(FconfSettings.ENABLE_BASH_HOTKEYS && event.keyCode === KEY.M && event.ctrlKey)
) {
if (FconfSettings.ENABLE_BASH_HOTKEYS) {
event.preventDefault();
}
const i = terminal.commandHistoryIndex;
const len = terminal.commandHistory.length;
if (len == 0) {
return;
}
if (i < 0 || i > len) {
terminal.commandHistoryIndex = len;
}
// Latest command, put nothing
if (i == len || i == len - 1) {
terminal.commandHistoryIndex = len;
setValue("");
} else {
++terminal.commandHistoryIndex;
const prevCommand = terminal.commandHistory[terminal.commandHistoryIndex];
setValue(prevCommand);
}
}
// Extra Bash Emulation Hotkeys, must be enabled through .fconf
if (FconfSettings.ENABLE_BASH_HOTKEYS) {
if (event.keyCode === KEY.A && event.ctrlKey) {
event.preventDefault();
moveTextCursor("home");
}
if (event.keyCode === KEY.E && event.ctrlKey) {
event.preventDefault();
moveTextCursor("end");
}
if (event.keyCode === KEY.B && event.ctrlKey) {
event.preventDefault();
moveTextCursor("prevchar");
}
if (event.keyCode === KEY.B && event.altKey) {
event.preventDefault();
moveTextCursor("prevword");
}
if (event.keyCode === KEY.F && event.ctrlKey) {
event.preventDefault();
moveTextCursor("nextchar");
}
if (event.keyCode === KEY.F && event.altKey) {
event.preventDefault();
moveTextCursor("nextword");
}
if ((event.keyCode === KEY.H || event.keyCode === KEY.D) && event.ctrlKey) {
modifyInput("backspace");
event.preventDefault();
}
// TODO AFTER THIS:
// alt + d deletes word after cursor
// ^w deletes word before cursor
// ^k clears line after cursor
// ^u clears line before cursor
}
}
return (
<Box position="fixed" bottom="0" width="100%" px={1}>
<List classes={{ root: classes.list }}>
@ -378,41 +85,8 @@ export function TerminalRoot({ terminal, engine, player }: IProps): React.ReactE
);
})}
</List>
{possibilities.length > 0 && (
<>
<Typography classes={{ root: classes.preformatted }} color={"primary"} paragraph={false}>
Possible autocomplete candidate:
</Typography>
<Typography classes={{ root: classes.preformatted }} color={"primary"} paragraph={false}>
{possibilities.join(" ")}
</Typography>
</>
)}
{terminal.action !== null && <ActionTimer terminal={terminal} />}
<TextField
color={terminal.action === null ? "primary" : "secondary"}
autoFocus
disabled={terminal.action !== null}
autoComplete="off"
classes={{ root: classes.textfield }}
value={value}
onChange={handleValueChange}
inputRef={terminalInput}
InputProps={{
// for players to hook in
id: "terminal-input",
className: classes.input,
startAdornment: (
<>
<Typography color={terminal.action === null ? "primary" : "secondary"}>
[{player.getCurrentServer().hostname}&nbsp;~{terminal.cwd()}]&gt;&nbsp;
</Typography>
</>
),
spellCheck: false,
onKeyDown: onKeyDown,
}}
></TextField>
<TerminalInput player={player} engine={engine} terminal={terminal} />
</Box>
);
}

@ -50,6 +50,8 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
const [execTime, setExecTime] = useState(Settings.CodeInstructionRunTime);
const [logSize, setLogSize] = useState(Settings.MaxLogCapacity);
const [portSize, setPortSize] = useState(Settings.MaxPortCapacity);
const [terminalSize, setTerminalSize] = useState(Settings.MaxTerminalCapacity);
const [autosaveInterval, setAutosaveInterval] = useState(Settings.AutosaveInterval);
const [suppressMessages, setSuppressMessages] = useState(Settings.SuppressMessages);
@ -86,6 +88,11 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
Settings.MaxPortCapacity = newValue as number;
}
function handleTerminalSizeChange(event: any, newValue: number | number[]): void {
setTerminalSize(newValue as number);
Settings.MaxTerminalCapacity = newValue as number;
}
function handleAutosaveIntervalChange(event: any, newValue: number | number[]): void {
setAutosaveInterval(newValue as number);
Settings.AutosaveInterval = newValue as number;
@ -209,6 +216,27 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
valueLabelDisplay="auto"
/>
</ListItem>
<ListItem>
<Tooltip
title={
<Typography>
The maximum number of entries that can be written to a the terminal. Setting this too high can cause
the game to use a lot of memory.
</Typography>
}
>
<Typography>Terminal capacity</Typography>
</Tooltip>
<Slider
value={terminalSize}
onChange={handleTerminalSizeChange}
step={50}
min={50}
max={500}
valueLabelDisplay="auto"
marks
/>
</ListItem>
<ListItem>
<Tooltip
title={