Added cmatrix command
This commit is contained in:
parent
9cad04edcd
commit
3bcb70ab95
7 changed files with 158 additions and 40 deletions
|
|
@ -151,6 +151,7 @@ export default function Ansi(props: { children?: string | undefined; linkify?: b
|
|||
"code",
|
||||
{ className },
|
||||
ansiToJSON(children ?? "", useClasses ?? false).map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
convertBundleIntoReact.bind(null, linkify ?? false, useClasses ?? false)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@
|
|||
|
||||
.Terminal p, .Terminal pre {
|
||||
margin: 0;
|
||||
min-height: 1.25rem;
|
||||
height: 1.25rem;
|
||||
line-height: inherit;
|
||||
font-size: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
|
||||
.Prefix {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,44 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { MouseEventHandler, useEffect, useRef, useState } from "react";
|
||||
import styles from "./Terminal.module.css";
|
||||
import { useVirtualRoot } from "../../../hooks/virtual-drive/virtualRootContext";
|
||||
import { clamp } from "../../../features/math/clamp";
|
||||
import { OutputLine } from "./OutputLine";
|
||||
import { InputLine } from "./InputLine";
|
||||
import { ANSI, HOSTNAME, USERNAME } from "../../../config/apps/terminal.config";
|
||||
import { ANSI, HOSTNAME, USERNAME, WELCOME_MESSAGE } from "../../../config/apps/terminal.config";
|
||||
import CommandsManager from "../../../features/apps/terminal/commands";
|
||||
import { removeFromArray } from "../../../features/_utils/array.utils";
|
||||
import Stream from "../../../features/apps/terminal/stream";
|
||||
import { formatError } from "../../../features/apps/terminal/_utils/terminal.utils";
|
||||
import { WindowProps } from "../../windows/WindowView";
|
||||
import { VirtualFolder } from "../../../features/virtual-drive/folder/virtualFolder";
|
||||
import { CommandResponse } from "../../../features/apps/terminal/command";
|
||||
import { APP_NAMES } from "../../../config/apps.config";
|
||||
|
||||
interface TerminalProps extends WindowProps {
|
||||
startPath: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
text: string;
|
||||
isInput: boolean;
|
||||
value?: string;
|
||||
clear?: boolean;
|
||||
}
|
||||
|
||||
export function Terminal({ startPath, input, setTitle, close: exit, active }: TerminalProps) {
|
||||
const [inputKey, setInputKey] = useState(0);
|
||||
const [inputValue, setInputValue] = useState(input ?? "");
|
||||
const [history, setHistory] = useState([]);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([{
|
||||
text: WELCOME_MESSAGE.replace("$APP_NAME", APP_NAMES.TERMINAL),
|
||||
isInput: false,
|
||||
}]);
|
||||
const virtualRoot = useVirtualRoot();
|
||||
const [currentDirectory, setCurrentDirectory] = useState(virtualRoot.navigate(startPath ?? "~"));
|
||||
const inputRef = useRef(null);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const [stream, setStream] = useState(null);
|
||||
const [streamOutput, setStreamOutput] = useState(null);
|
||||
const [stream, setStream] = useState<Stream>(null);
|
||||
const [streamOutput, setStreamOutput] = useState<string>(null);
|
||||
const streamRef = useRef(null);
|
||||
const [streamFocused, setStreamFocused] = useState(false);
|
||||
|
||||
|
|
@ -38,7 +50,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
if (streamFocused || streamRef.current == null || streamOutput == null)
|
||||
return;
|
||||
|
||||
streamRef.current.scrollTop = streamRef.current.scrollHeight;
|
||||
(streamRef.current as unknown as HTMLDivElement).scrollTop = (streamRef.current as unknown as HTMLDivElement).scrollHeight;
|
||||
setStreamFocused(true);
|
||||
}, [streamFocused, streamOutput, streamRef]);
|
||||
|
||||
|
|
@ -46,48 +58,48 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
if (!inputRef.current || !active)
|
||||
return;
|
||||
|
||||
inputRef.current.focus();
|
||||
(inputRef.current as unknown as HTMLInputElement).focus();
|
||||
}, [inputRef, active]);
|
||||
|
||||
const prefix = `${ANSI.fg.cyan + USERNAME}@${HOSTNAME + ANSI.reset}:`
|
||||
+ `${ANSI.fg.blue + (currentDirectory.root ? "/" : currentDirectory.path) + ANSI.reset}$ `;
|
||||
|
||||
const updatedHistory = history;
|
||||
const pushHistory = (entry) => {
|
||||
const pushHistory = (entry: HistoryEntry) => {
|
||||
updatedHistory.push(entry);
|
||||
setHistory(updatedHistory);
|
||||
};
|
||||
|
||||
const promptOutput = (text) => {
|
||||
const promptOutput = (text: string) => {
|
||||
pushHistory({
|
||||
text,
|
||||
isInput: false
|
||||
});
|
||||
};
|
||||
|
||||
const connectStream = (stream, pipes) => {
|
||||
const connectStream = (stream: Stream, pipes: string[]) => {
|
||||
setStream(stream);
|
||||
setStreamFocused(false);
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (active && (event.ctrlKey || event.metaKey) && event.key === "c") {
|
||||
stream.stop();
|
||||
}
|
||||
};
|
||||
|
||||
let lastOutput = null;
|
||||
let lastOutput: CommandResponse = null;
|
||||
|
||||
stream.on(Stream.EVENT_NAMES.new, (text) => {
|
||||
let output = text;
|
||||
stream.on(Stream.EVENT_NAMES.new, (text: string) => {
|
||||
let output: CommandResponse = text;
|
||||
pipes.forEach((pipe) => {
|
||||
if (output instanceof Stream)
|
||||
return;
|
||||
|
||||
// Output from the previous command gets added as an argument for the next command
|
||||
output = handleInput(output ? `${pipe} ${output}` : pipe);
|
||||
output = handleInput(output ? `${pipe} ${output as string}` : pipe);
|
||||
});
|
||||
|
||||
if (output instanceof Stream) {
|
||||
if ((output as unknown) instanceof Stream) {
|
||||
stream.stop();
|
||||
promptOutput(ANSI.fg.red + "Stream failed");
|
||||
return;
|
||||
|
|
@ -100,7 +112,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
stream.on(Stream.EVENT_NAMES.stop, () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
|
||||
promptOutput(lastOutput);
|
||||
promptOutput(lastOutput as string);
|
||||
|
||||
setStream(null);
|
||||
setStreamOutput(null);
|
||||
|
|
@ -109,7 +121,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
document.addEventListener("keydown", onKeyDown);
|
||||
};
|
||||
|
||||
const handleInput = (value) => {
|
||||
const handleInput = (value: string): CommandResponse => {
|
||||
const rawInputValueStart = value.indexOf(" ") + 1;
|
||||
const rawInputValue = rawInputValueStart <= 0 ? "" : value.substr(rawInputValueStart);
|
||||
const timestamp = Date.now();
|
||||
|
|
@ -133,10 +145,10 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
return formatError(commandName, "Command not found");
|
||||
|
||||
// Get options
|
||||
const options = [];
|
||||
const options: string[] = [];
|
||||
const inputs = {};
|
||||
args.filter((arg) => arg.startsWith("-")).forEach((option, index) => {
|
||||
const addOption = (key) => {
|
||||
args.filter((arg: string) => arg.startsWith("-")).forEach((option: string) => {
|
||||
const addOption = (key: string) => {
|
||||
if (options.includes(key))
|
||||
return;
|
||||
|
||||
|
|
@ -156,7 +168,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
addOption(longOption);
|
||||
} else {
|
||||
const shortOptions = option.substring(1).split("");
|
||||
shortOptions.forEach((shortOption) => {
|
||||
shortOptions.forEach((shortOption: string) => {
|
||||
addOption(shortOption);
|
||||
});
|
||||
}
|
||||
|
|
@ -172,7 +184,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
return formatError(commandName, `Incorrect usage: ${commandName} requires at least 1 option`);
|
||||
|
||||
// Execute command
|
||||
let response = null;
|
||||
let response: CommandResponse = null;
|
||||
|
||||
try {
|
||||
response = command.execute(args, {
|
||||
|
|
@ -193,7 +205,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
if (response == null)
|
||||
return formatError(commandName, "Command failed");
|
||||
|
||||
if (!response.blank)
|
||||
if (!(response as { blank: boolean }).blank)
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
@ -201,7 +213,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
}
|
||||
};
|
||||
|
||||
const submitInput = (value) => {
|
||||
const submitInput = (value: string) => {
|
||||
pushHistory({
|
||||
text: prefix + value,
|
||||
isInput: true,
|
||||
|
|
@ -236,7 +248,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
}
|
||||
};
|
||||
|
||||
const updateHistoryIndex = (delta) => {
|
||||
const updateHistoryIndex = (delta: number) => {
|
||||
const inputHistory = history.filter(({ isInput }) => isInput);
|
||||
const index = clamp(historyIndex + delta, 0, inputHistory.length);
|
||||
|
||||
|
|
@ -257,8 +269,8 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
setHistoryIndex(index);
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
const value = event.target.value;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
const { key } = event;
|
||||
|
||||
if (key === "Enter") {
|
||||
|
|
@ -275,8 +287,8 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
}
|
||||
};
|
||||
|
||||
const onChange = (event) => {
|
||||
const value = event.target.value;
|
||||
const onChange = (event: KeyboardEvent) => {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
return setInputValue(value);
|
||||
};
|
||||
|
||||
|
|
@ -294,7 +306,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
});
|
||||
};
|
||||
|
||||
const onMouseDown = (event) => {
|
||||
const onMouseDown = (event: { button: number; preventDefault: () => void; }) => {
|
||||
if (event.button === 2) {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
@ -306,7 +318,7 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
}
|
||||
};
|
||||
|
||||
const onContextMenu = (event) => {
|
||||
const onContextMenu = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
|
|
@ -315,11 +327,11 @@ export function Terminal({ startPath, input, setTitle, close: exit, active }: Te
|
|||
ref={streamRef}
|
||||
className={styles.Terminal}
|
||||
onMouseDown={onMouseDown}
|
||||
onContextMenu={onContextMenu}
|
||||
onContextMenu={onContextMenu as unknown as MouseEventHandler}
|
||||
onClick={(event) => {
|
||||
if (window.getSelection().toString() === "") {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
(inputRef.current as HTMLInputElement)?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,4 +20,7 @@ export const ANSI = {
|
|||
dim: "\u001b[2m",
|
||||
},
|
||||
reset: "\u001b[0m",
|
||||
};
|
||||
};
|
||||
|
||||
export const WELCOME_MESSAGE = `${ANSI.fg.cyan + ANSI.decoration.dim}$APP_NAME - Made by Prozilla${ANSI.reset}`
|
||||
+ `\n${ANSI.decoration.dim}Type 'help' for a list of commands.${ANSI.reset}\n`;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ type Option = {
|
|||
isInput: boolean
|
||||
};
|
||||
|
||||
export type CommandResponse = string | { blank: boolean } | void | Stream;
|
||||
|
||||
type Execute = (args?: string[], options?: {
|
||||
promptOutput?: Function,
|
||||
pushHistory?: Function,
|
||||
|
|
@ -21,7 +23,7 @@ type Execute = (args?: string[], options?: {
|
|||
exit?: Function,
|
||||
inputs?: Record<string, string>;
|
||||
timestamp: number,
|
||||
}) => string | { blank: boolean } | void | Stream;
|
||||
}) => CommandResponse;
|
||||
|
||||
type Manual = {
|
||||
purpose?: string,
|
||||
|
|
|
|||
97
src/features/apps/terminal/commands/cmatrix.ts
Normal file
97
src/features/apps/terminal/commands/cmatrix.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { ANSI } from "../../../../config/apps/terminal.config";
|
||||
import { randomFromArray, removeFromArray } from "../../../_utils/array.utils";
|
||||
import { randomRange } from "../../../math/random";
|
||||
import Vector2 from "../../../math/vector2";
|
||||
import Command from "../command";
|
||||
import Stream from "../stream";
|
||||
|
||||
const ANIMATION_SPEED = 1.25;
|
||||
const SCREEN_WIDTH = 75;
|
||||
const SCREEN_HEIGHT = 20;
|
||||
const CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.*\\/()#@&$!?%°:<>[]";
|
||||
|
||||
const PARTICLES = {
|
||||
framesBetweenSpawn: 30,
|
||||
fallSpeed: 1,
|
||||
minSize: 5,
|
||||
maxSize: 25
|
||||
};
|
||||
|
||||
type Particle = {
|
||||
position: Vector2,
|
||||
size: number;
|
||||
};
|
||||
|
||||
let particles: Particle[] = [];
|
||||
|
||||
function generateScreen(frame: number): string {
|
||||
// Spawn new particles
|
||||
if (frame % PARTICLES.framesBetweenSpawn) {
|
||||
const newParticle: Particle = {
|
||||
position: new Vector2(randomRange(0, SCREEN_WIDTH), SCREEN_HEIGHT).round(),
|
||||
size: Math.round(randomRange(PARTICLES.minSize, PARTICLES.maxSize))
|
||||
};
|
||||
particles.push(newParticle);
|
||||
}
|
||||
|
||||
// Create screen
|
||||
const screen: string[][] = [];
|
||||
for (let y = 0; y < SCREEN_HEIGHT; y++) {
|
||||
const row: string[] = [];
|
||||
for (let x = 0; x < SCREEN_WIDTH; x++) {
|
||||
row.push(" ");
|
||||
}
|
||||
screen.push(row);
|
||||
}
|
||||
|
||||
// Move and render particles
|
||||
particles.forEach((particle) => {
|
||||
particle.position.y -= PARTICLES.fallSpeed;
|
||||
|
||||
// Remove offscreen particles
|
||||
if (particle.position.y + particle.size <= 0 || particle.position.x >= SCREEN_WIDTH)
|
||||
return removeFromArray(particle, particles);
|
||||
|
||||
for (let i = 0; i < particle.size; i++) {
|
||||
const character = randomFromArray(CHARACTERS.split(""));
|
||||
let color = i == 0 ? ANSI.fg.white : ANSI.fg.green;
|
||||
|
||||
if (i > particle.size / 2)
|
||||
color = ANSI.fg.green + ANSI.decoration.dim;
|
||||
|
||||
const positionX = particle.position.x;
|
||||
const positionY = particle.position.y + i;
|
||||
|
||||
if (positionX < SCREEN_WIDTH && positionY < SCREEN_HEIGHT && positionY > 0) {
|
||||
screen[positionY][positionX] = color + character + ANSI.reset;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return screen.map((row) => row.join("")).reverse().join("\n");
|
||||
}
|
||||
|
||||
export const cmatrix = new Command()
|
||||
.setManual({
|
||||
purpose: "Show a scrolling 'Matrix' like screen",
|
||||
usage: "cmatrix",
|
||||
})
|
||||
.setExecute(function() {
|
||||
const stream = new Stream();
|
||||
particles = [];
|
||||
|
||||
let frame = 0;
|
||||
const interval = setInterval(() => {
|
||||
const text = generateScreen(frame);
|
||||
stream.send(text);
|
||||
frame++;
|
||||
}, 100 / ANIMATION_SPEED);
|
||||
|
||||
stream.on(Stream.EVENT_NAMES.stop, () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
stream.start();
|
||||
|
||||
return stream;
|
||||
});
|
||||
|
|
@ -136,7 +136,7 @@ const EXTRA_WAGONS = [
|
|||
],
|
||||
];
|
||||
|
||||
function getLocomotive(frame, wagonCount = 1) {
|
||||
function generateLocomotive(frame: number, wagonCount = 1) {
|
||||
const smokeHeight = LOCOMOTIVE_SMOKE[0].length;
|
||||
const locomotiveHeight = LOCOMOTIVE_TOP.length + LOCOMOTIVE_BOTTOM[0].length;
|
||||
const wagonHeight = COAL_WAGON.length;
|
||||
|
|
@ -193,7 +193,7 @@ export const sl = new Command()
|
|||
wagonCount = parseInt(inputs.w);
|
||||
|
||||
if (!wagonCount || wagonCount < 0) {
|
||||
return formatError(this.name, "Please specify a valid amount of wagons");
|
||||
return formatError((this as Command).name, "Please specify a valid amount of wagons");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ export const sl = new Command()
|
|||
|
||||
let frame = 0;
|
||||
const interval = setInterval(() => {
|
||||
const text = getLocomotive(frame, wagonCount);
|
||||
const text = generateLocomotive(frame, wagonCount);
|
||||
stream.send(text);
|
||||
frame++;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue