This commit is contained in:
2025-10-29 20:36:09 -07:00
commit d90a9b5826
33 changed files with 3239 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from "react";
import styles from "@/styles/Editor.module.css";
// Dynamic import for CodeMirror to avoid SSR issues
let CodeMirror: any = null;
if (typeof window !== "undefined") {
import("codemirror")
.then(async (cm) => {
CodeMirror = cm.default;
await import("codemirror/mode/javascript/javascript");
await import("codemirror/addon/edit/matchbrackets");
await import("codemirror/addon/edit/closebrackets");
await import("codemirror/addon/selection/active-line");
await import("codemirror/lib/codemirror.css");
await import("codemirror/theme/monokai.css");
});
}
interface EditorProps {
initialValue?: string;
onChange?: (editor: any, changes: any) => void;
onRequestRun?: () => void;
onReady?: (editor: any) => void;
}
export const Editor = forwardRef<any, EditorProps>(function Editor(
{ initialValue = "", onChange, onRequestRun, onReady },
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<any>(null);
const [content, setContent] = useState(initialValue);
useImperativeHandle(ref, () => editorRef.current);
// Initialize editor only once
useEffect(() => {
if (!CodeMirror || !textareaRef.current || editorRef.current) return;
const editor = CodeMirror.fromTextArea(textareaRef.current, {
lineNumbers: true,
mode: "javascript", // Placeholder mode, will be 'rhai' when WASM is integrated
theme: "monokai",
matchBrackets: true,
autoCloseBrackets: true,
styleActiveLine: true,
extraKeys: {
"Ctrl-Enter": () => {
onRequestRun?.();
},
},
});
editor.setValue(content);
editor.on("change", (instance: any, changes: any) => {
const newContent = instance.getValue();
setContent(newContent);
onChange?.(instance, changes);
});
editorRef.current = editor;
onReady?.(editor);
return () => {
if (editorRef.current) {
editorRef.current.toTextArea();
editorRef.current = null;
}
};
}, []); // Empty dependency array - only initialize once
// Update keyboard shortcut when onRequestRun changes
useEffect(() => {
if (editorRef.current && onRequestRun) {
editorRef.current.setOption("extraKeys", {
"Ctrl-Enter": () => {
onRequestRun();
},
});
}
}, [onRequestRun]);
return (
<div className={styles.editorContainer}>
<textarea
ref={textareaRef}
defaultValue={initialValue}
placeholder="Enter your Rhai code here..."
/>
</div>
);
});

View File

@@ -0,0 +1,220 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/Button";
import { Dropdown } from "@/components/ui/Dropdown";
import { Editor } from "@/components/Editor";
import { Terminal, TerminalRef } from "@/components/Terminal";
import { runScript, stopScript } from "@/lib/runner";
import styles from "@/styles/Playground.module.css";
const initialCode = `
fn random_action(board) {
let symb = rand_symb();
let pos = rand_int(0, 10);
let action = Action(symb, pos);
while !board.can_play(action) {
let symb = rand_symb();
let pos = rand_int(0, 10);
action = Action(symb, pos);
}
return action
}
fn step_min(board) {
random_action(board)
}
fn step_max(board) {
random_action(board)
}
`;
const exampleScriptList = [
{ value: "./hello.rhai", text: "hello.rhai" },
{ value: "./fibonacci.rhai", text: "fibonacci.rhai" },
{ value: "./arrays.rhai", text: "arrays.rhai" },
];
export default function Playground() {
const [isScriptRunning, setIsScriptRunning] = useState(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const editorRef = useRef<any>(null);
const resultRef = useRef<HTMLTextAreaElement>(null);
const terminalRef = useRef<TerminalRef>(null);
const runDisabled = isScriptRunning || !isEditorReady;
const stopDisabled = !isScriptRunning;
const requestRun = useCallback(async () => {
if (runDisabled || !editorRef.current) return;
setIsScriptRunning(true);
try {
terminalRef.current?.clear();
await runScript(
editorRef.current.getValue(),
(line: string) => {
// Only script prints go to output text area
if (resultRef.current) {
let v = resultRef.current.value + line + "\n";
if (v.length > 10000) {
v = v.substring(v.length - 10000);
}
resultRef.current.value = v;
resultRef.current.scrollTop =
resultRef.current.scrollHeight -
resultRef.current.clientHeight;
}
},
(line: string) => {
// Game state and debug info go to terminal
terminalRef.current?.write(line + '\r\n');
}
);
} catch (ex) {
const errorMsg = `\nEXCEPTION: "${ex}"\n`;
if (resultRef.current) {
resultRef.current.value += errorMsg;
}
terminalRef.current?.write('\r\n\x1B[1;31mEXCEPTION:\x1B[0m ' + String(ex) + '\r\n');
}
setIsScriptRunning(false);
}, [runDisabled]);
const stopScriptHandler = useCallback(() => {
stopScript();
}, []);
return (
<div className={styles.playgroundRoot}>
<header className={styles.header}>
<div className={styles.headerField}>
<div className={styles.buttonGroup}>
<Button
variant="success"
iconLeft="play"
onClick={() => {
if (resultRef.current) {
resultRef.current.value = "";
}
requestRun();
}}
loading={isScriptRunning}
disabled={runDisabled}
>
Run
</Button>
<Button
variant="danger"
iconLeft="stop"
onClick={stopScriptHandler}
disabled={stopDisabled}
>
Stop
</Button>
</div>
<div className={styles.buttonGroup}>
<Dropdown
trigger="Example Scripts"
disabled={isScriptRunning}
items={exampleScriptList.map((item) => ({
text: item.text,
onClick: () => {},
}))}
/>
<Dropdown
triggerIcon="help-circle"
align="right"
customContent={
<div className={styles.helpPanel}>
<h1>What is Rhai?</h1>
<p>
<a
href="https://rhai.rs"
target="_blank"
rel="noopener noreferrer"
>
Rhai
</a>{" "}
is an embedded scripting language and
evaluation engine for Rust that gives a
safe and easy way to add scripting to
any application.
</p>
<h1>Hotkeys</h1>
<p>
You can run the script by pressing{" "}
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> when
focused in the editor.
</p>
<div className={styles.footer}>
<span>
<a
href="https://github.com/rhaiscript/playground"
target="_blank"
rel="noopener noreferrer"
>
Rhai Playground
</a>{" "}
version: 0.1.0
</span>
<br />
<span>
compiled with Rhai (placeholder)
</span>
</div>
</div>
}
/>
</div>
</div>
</header>
<div className={styles.mainContent}>
<div className={styles.leftPanel}>
<Editor
ref={editorRef}
initialValue={initialCode}
onChange={() => {}}
onRequestRun={() => {
if (resultRef.current) {
resultRef.current.value = "";
}
requestRun();
}}
onReady={() => setIsEditorReady(true)}
/>
</div>
<div className={styles.rightPanel}>
<div className={styles.terminalPanel}>
<div className={styles.panelHeader}>Terminal</div>
<div className={styles.terminalContainer}>
<Terminal ref={terminalRef} />
</div>
</div>
<div className={styles.outputPanel}>
<div className={styles.panelHeader}>Output</div>
<textarea
ref={resultRef}
className={styles.result}
readOnly
autoComplete="off"
placeholder="Script output will appear here..."
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
import '@xterm/xterm/css/xterm.css';
export interface TerminalRef {
write: (data: string) => void;
clear: () => void;
}
export const Terminal = forwardRef<TerminalRef, {}>(function Terminal(_props, ref) {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
useImperativeHandle(ref, () => ({
write: (data: string) => {
if (xtermRef.current) {
xtermRef.current.write(data);
}
},
clear: () => {
if (xtermRef.current) {
xtermRef.current.clear();
}
}
}));
useEffect(() => {
let mounted = true;
const initTerminal = async () => {
if (!terminalRef.current) return;
try {
const { Terminal } = await import('@xterm/xterm');
if (!mounted) return;
const term = new Terminal({
//"fontFamily": "Fantasque",
"rows": 24,
"fontSize": 16,
"tabStopWidth": 8,
"cursorBlink": true,
"theme": {
"background": "#1D1F21",
"foreground": "#F8F8F8",
"cursor": "#F8F8F2",
"black": "#282828",
"blue": "#0087AF",
"brightBlack": "#555555",
"brightBlue": "#87DFFF",
"brightCyan": "#28D1E7",
"brightGreen": "#A8FF60",
"brightMagenta": "#985EFF",
"brightRed": "#FFAA00",
"brightWhite": "#D0D0D0",
"brightYellow": "#F1FF52",
"cyan": "#87DFEB",
"green": "#B4EC85",
"magenta": "#BD99FF",
"red": "#FF6600",
"white": "#F8F8F8",
"yellow": "#FFFFB6"
}
});
term.open(terminalRef.current);
term.write('Terminal ready...\r\n');
xtermRef.current = term;
} catch (error) {
console.error('Failed to initialize terminal:', error);
}
};
initTerminal();
return () => {
mounted = false;
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
};
}, []);
return <div ref={terminalRef} style={{ height: "100%", width: "100%" }} />;
});

View File

@@ -0,0 +1,56 @@
"use client";
import { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import styles from "@/styles/Button.module.css";
interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
variant?:
| "primary"
| "success"
| "danger"
| "warning"
| "info"
| "light"
| "dark";
iconLeft?: string;
iconRight?: string;
loading?: boolean;
children: ReactNode;
tooltip?: string;
type?: "submit" | "reset" | "button";
}
export function Button({
variant = "primary",
iconLeft,
iconRight,
loading = false,
children,
className,
disabled,
tooltip,
type = "button",
...props
}: ButtonProps) {
return (
<button
type={type}
className={clsx(
styles.button,
styles[`is-${variant}`],
loading && styles.isLoading,
className,
)}
disabled={disabled || loading}
title={tooltip}
{...props}
>
{iconLeft && !loading && <i className={`mdi mdi-${iconLeft}`} />}
{loading && <i className="mdi mdi-loading mdi-spin" />}
<span>{children}</span>
{iconRight && !loading && <i className={`mdi mdi-${iconRight}`} />}
</button>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useState, useRef, useEffect, ReactNode } from "react";
import clsx from "clsx";
import styles from "@/styles/Dropdown.module.css";
interface DropdownItem {
text: string;
onClick: () => void;
}
interface DropdownProps {
trigger?: string;
triggerIcon?: string;
disabled?: boolean;
items?: DropdownItem[];
customContent?: ReactNode;
align?: "left" | "right";
}
export function Dropdown({
trigger,
triggerIcon,
disabled = false,
items = [],
customContent,
align = "left",
}: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
return (
<div
className={clsx(styles.dropdown, isOpen && styles.isActive)}
ref={dropdownRef}
>
<button
className={styles.dropdownTrigger}
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
>
{triggerIcon && <i className={`mdi mdi-${triggerIcon}`} />}
{trigger && <span>{trigger}</span>}
{!triggerIcon && !trigger && (
<i className="mdi mdi-dots-horizontal" />
)}
<i className="mdi mdi-menu-down" />
</button>
{isOpen && (
<div
className={clsx(
styles.dropdownMenu,
align === "right" && styles.alignRight,
)}
>
<div className={styles.dropdownContent}>
{customContent ? (
<div className={styles.dropdownItem}>
{customContent}
</div>
) : (
items.map((item, index) => (
<a
key={index}
href="#"
className={styles.dropdownItem}
onClick={(e) => {
e.preventDefault();
item.onClick();
setIsOpen(false);
}}
>
{item.text}
</a>
))
)}
</div>
</div>
)}
</div>
);
}