Added cmatrix command

This commit is contained in:
Prozilla 2024-06-11 18:39:38 +02:00
parent 9cad04edcd
commit 3bcb70ab95
No known key found for this signature in database
GPG key ID: 5858DFE71CAF31EE
7 changed files with 158 additions and 40 deletions

View file

@ -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)
)
);

View file

@ -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 {

View file

@ -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();
}
}}
>

View file

@ -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`;

View file

@ -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,

View 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;
});

View file

@ -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++;