diff --git a/src/components/apps/terminal/Ansi.tsx b/src/components/apps/terminal/Ansi.tsx index 7a1927d..895785d 100644 --- a/src/components/apps/terminal/Ansi.tsx +++ b/src/components/apps/terminal/Ansi.tsx @@ -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) ) ); diff --git a/src/components/apps/terminal/Terminal.module.css b/src/components/apps/terminal/Terminal.module.css index 6a49904..58f2f4c 100644 --- a/src/components/apps/terminal/Terminal.module.css +++ b/src/components/apps/terminal/Terminal.module.css @@ -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 { diff --git a/src/components/apps/terminal/Terminal.tsx b/src/components/apps/terminal/Terminal.tsx index ccf0293..035e052 100644 --- a/src/components/apps/terminal/Terminal.tsx +++ b/src/components/apps/terminal/Terminal.tsx @@ -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([{ + 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(null); + const [streamOutput, setStreamOutput] = useState(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(); } }} > diff --git a/src/config/apps/terminal.config.ts b/src/config/apps/terminal.config.ts index 823c6cb..e4e3334 100644 --- a/src/config/apps/terminal.config.ts +++ b/src/config/apps/terminal.config.ts @@ -20,4 +20,7 @@ export const ANSI = { dim: "\u001b[2m", }, reset: "\u001b[0m", -}; \ No newline at end of file +}; + +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`; diff --git a/src/features/apps/terminal/command.ts b/src/features/apps/terminal/command.ts index a5694e5..8935021 100644 --- a/src/features/apps/terminal/command.ts +++ b/src/features/apps/terminal/command.ts @@ -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; timestamp: number, -}) => string | { blank: boolean } | void | Stream; +}) => CommandResponse; type Manual = { purpose?: string, diff --git a/src/features/apps/terminal/commands/cmatrix.ts b/src/features/apps/terminal/commands/cmatrix.ts new file mode 100644 index 0000000..a7f2e28 --- /dev/null +++ b/src/features/apps/terminal/commands/cmatrix.ts @@ -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; + }); \ No newline at end of file diff --git a/src/features/apps/terminal/commands/sl.ts b/src/features/apps/terminal/commands/sl.ts index 14e2c0c..11fcb68 100644 --- a/src/features/apps/terminal/commands/sl.ts +++ b/src/features/apps/terminal/commands/sl.ts @@ -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++;