A
This commit is contained in:
30
webui/src/app/layout.tsx
Normal file
30
webui/src/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import "@/styles/globals.css";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Rhai Playground",
|
||||
description: "An interactive Rhai scripting language playground",
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.materialdesignicons.com/5.3.45/css/materialdesignicons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
78
webui/src/app/page.tsx
Normal file
78
webui/src/app/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Playground = dynamic(() => import("@/components/Playground"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
padding: "8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
Loading{" "}
|
||||
<a
|
||||
href="https://github.com/rhaiscript/playground"
|
||||
target="_blank"
|
||||
>
|
||||
Rhai Playground
|
||||
</a>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function Home() {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div
|
||||
id="loading"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
padding: "8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
Loading{" "}
|
||||
<a
|
||||
href="https://github.com/rhaiscript/playground"
|
||||
target="_blank"
|
||||
>
|
||||
Rhai Playground
|
||||
</a>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Playground />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
95
webui/src/components/Editor.tsx
Normal file
95
webui/src/components/Editor.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
220
webui/src/components/Playground.tsx
Normal file
220
webui/src/components/Playground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
webui/src/components/Terminal.tsx
Normal file
89
webui/src/components/Terminal.tsx
Normal 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%" }} />;
|
||||
});
|
||||
56
webui/src/components/ui/Button.tsx
Normal file
56
webui/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
webui/src/components/ui/Dropdown.tsx
Normal file
103
webui/src/components/ui/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
webui/src/lib/runner.ts
Normal file
53
webui/src/lib/runner.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
let worker: Worker | null = null;
|
||||
|
||||
export async function runScript(
|
||||
script: string,
|
||||
appendOutput: (line: string) => void,
|
||||
appendTerminal?: (line: string) => void
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (worker) {
|
||||
worker.terminate();
|
||||
}
|
||||
|
||||
worker = new Worker(new URL('./script-runner.worker.ts', import.meta.url));
|
||||
|
||||
worker.onmessage = (event) => {
|
||||
const { type, line, error } = event.data;
|
||||
|
||||
if (type === 'output') {
|
||||
appendOutput(line);
|
||||
} else if (type === 'terminal') {
|
||||
appendTerminal?.(line);
|
||||
} else if (type === 'complete') {
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
resolve();
|
||||
} else if (type === 'error') {
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
reject(new Error(error));
|
||||
} else if (type === 'stopped') {
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
worker.postMessage({ type: 'run', script });
|
||||
});
|
||||
}
|
||||
|
||||
export function stopScript(): void {
|
||||
if (worker) {
|
||||
worker.postMessage({ type: 'stop' });
|
||||
}
|
||||
}
|
||||
73
webui/src/lib/script-runner.worker.ts
Normal file
73
webui/src/lib/script-runner.worker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import init, { GameState } from "./wasm/script_runner";
|
||||
|
||||
let wasmReady = false;
|
||||
let wasmInitPromise: Promise<void> | null = null;
|
||||
let currentGame: GameState | null = null;
|
||||
|
||||
async function initWasm(): Promise<void> {
|
||||
if (wasmReady) return;
|
||||
|
||||
if (wasmInitPromise) {
|
||||
return wasmInitPromise;
|
||||
}
|
||||
|
||||
wasmInitPromise = (async () => {
|
||||
await init();
|
||||
wasmReady = true;
|
||||
})();
|
||||
|
||||
return wasmInitPromise;
|
||||
}
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const { type, script } = event.data;
|
||||
|
||||
if (type === "run") {
|
||||
try {
|
||||
await initWasm();
|
||||
|
||||
const appendOutput = (line: string) => {
|
||||
self.postMessage({ type: "output", line });
|
||||
};
|
||||
|
||||
const appendDebug = (line: string) => {
|
||||
self.postMessage({ type: "output", line }); // debug also goes to output
|
||||
};
|
||||
|
||||
const appendTerminal = (line: string) => {
|
||||
self.postMessage({ type: "terminal", line });
|
||||
};
|
||||
|
||||
currentGame = new GameState(
|
||||
script,
|
||||
"max",
|
||||
appendOutput,
|
||||
appendDebug,
|
||||
|
||||
script,
|
||||
"min",
|
||||
appendOutput,
|
||||
appendDebug,
|
||||
|
||||
appendTerminal
|
||||
);
|
||||
|
||||
while (currentGame && !currentGame.is_done()) {
|
||||
const res = currentGame.step();
|
||||
if (res === undefined) {
|
||||
self.postMessage({ type: "complete" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGame) {
|
||||
self.postMessage({ type: "complete" });
|
||||
}
|
||||
} catch (error) {
|
||||
self.postMessage({ type: "error", error: String(error) });
|
||||
}
|
||||
} else if (type === "stop") {
|
||||
currentGame = null;
|
||||
self.postMessage({ type: "stopped" });
|
||||
}
|
||||
};
|
||||
118
webui/src/styles/Button.module.css
Normal file
118
webui/src/styles/Button.module.css
Normal file
@@ -0,0 +1,118 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
background: #3a3a3a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.isLoading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* Button types */
|
||||
.is-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.is-primary:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.is-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.is-success:hover:not(:disabled) {
|
||||
background: #1e7e34;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
.is-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.is-danger:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
border-color: #c82333;
|
||||
}
|
||||
|
||||
.is-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.is-warning:hover:not(:disabled) {
|
||||
background: #e0a800;
|
||||
border-color: #e0a800;
|
||||
}
|
||||
|
||||
.is-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
border-color: #17a2b8;
|
||||
}
|
||||
|
||||
.is-info:hover:not(:disabled) {
|
||||
background: #138496;
|
||||
border-color: #138496;
|
||||
}
|
||||
|
||||
.is-light {
|
||||
background: #f8f9fa;
|
||||
color: #212529;
|
||||
border-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.is-light:hover:not(:disabled) {
|
||||
background: #e2e6ea;
|
||||
border-color: #e2e6ea;
|
||||
}
|
||||
|
||||
.is-dark {
|
||||
background: #343a40;
|
||||
color: white;
|
||||
border-color: #343a40;
|
||||
}
|
||||
|
||||
.is-dark:hover:not(:disabled) {
|
||||
background: #23272b;
|
||||
border-color: #23272b;
|
||||
}
|
||||
100
webui/src/styles/Dropdown.module.css
Normal file
100
webui/src/styles/Dropdown.module.css
Normal file
@@ -0,0 +1,100 @@
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdownTrigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.dropdownTrigger:hover:not(:disabled) {
|
||||
background: #3a3a3a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.dropdownTrigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.isActive .dropdownTrigger {
|
||||
background: #3a3a3a;
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.25);
|
||||
}
|
||||
|
||||
.dropdownMenu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
min-width: 160px;
|
||||
max-width: 90vw;
|
||||
margin-top: 4px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
|
||||
animation: dropdownFadeIn 0.15s ease-out;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dropdownMenu.alignRight {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownContent {
|
||||
padding: 4px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
clear: both;
|
||||
font-weight: 400;
|
||||
color: #e0e0e0;
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
white-space: normal;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.dropdownItem:hover,
|
||||
.dropdownItem:focus {
|
||||
background-color: #3a3a3a;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
17
webui/src/styles/Editor.module.css
Normal file
17
webui/src/styles/Editor.module.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.editorContainer {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editorContainer textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #ccc;
|
||||
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
133
webui/src/styles/Playground.module.css
Normal file
133
webui/src/styles/Playground.module.css
Normal file
@@ -0,0 +1,133 @@
|
||||
.playgroundRoot {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #252526;
|
||||
}
|
||||
|
||||
.headerField {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.leftPanel {
|
||||
width: 50%;
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminalPanel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.outputPanel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
background: #2d2d30;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #cccccc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.terminalContainer {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
resize: none;
|
||||
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
||||
flex: 1;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.configPanel {
|
||||
padding: 16px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.helpPanel {
|
||||
padding: 16px;
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.helpPanel h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.helpPanel p {
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.5;
|
||||
color: #cccccc;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.helpPanel a {
|
||||
color: #4fc3f7;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.helpPanel a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
border-top: 1px solid #444;
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
119
webui/src/styles/globals.css
Normal file
119
webui/src/styles/globals.css
Normal file
@@ -0,0 +1,119 @@
|
||||
/* Global styles based on the original site */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: auto !important;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* CodeMirror base styles */
|
||||
.CodeMirror {
|
||||
border: 1px solid #444;
|
||||
height: 100% !important;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.95em;
|
||||
line-height: initial;
|
||||
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.CodeMirror .rhai-error {
|
||||
text-decoration: underline wavy red;
|
||||
}
|
||||
|
||||
.CodeMirror .cm-matchhighlight {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.CodeMirror .CodeMirror-selection-highlight-scrollbar {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Basic button styles */
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #555;
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #3a3a3a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 8px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
select {
|
||||
background: #2d2d2d;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Kbd styling */
|
||||
kbd {
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
color: #e0e0e0;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin: 0 2px;
|
||||
padding: 1px 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
10
webui/src/types/codemirror.d.ts
vendored
Normal file
10
webui/src/types/codemirror.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// Type declarations for CodeMirror modules
|
||||
declare module "codemirror/mode/javascript/javascript";
|
||||
declare module "codemirror/addon/edit/matchbrackets";
|
||||
declare module "codemirror/addon/edit/closebrackets";
|
||||
declare module "codemirror/addon/selection/active-line";
|
||||
declare module "codemirror/addon/fold/foldcode";
|
||||
declare module "codemirror/addon/fold/foldgutter";
|
||||
declare module "codemirror/addon/fold/brace-fold";
|
||||
declare module "codemirror/lib/codemirror.css";
|
||||
declare module "codemirror/addon/fold/foldgutter.css";
|
||||
Reference in New Issue
Block a user