Implement basic execution system and UI
This is a rather large commit that includes all of the following: - React UI with code editor, runtime renderer and input-output panes - Language providers for a sample language and Brainfuck - Implementation of code execution in a web worker - All-at-once unabortable execution of program fully functional
This commit is contained in:
58
ui/MainLayout.tsx
Normal file
58
ui/MainLayout.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { Mosaic, MosaicNode, MosaicWindow } from "react-mosaic-component";
|
||||
|
||||
// IDs of windows in the mosaic layout
|
||||
type WINDOW_ID = "editor" | "renderer" | "input" | "output";
|
||||
|
||||
const WindowTitles = {
|
||||
editor: "Code Editor",
|
||||
renderer: "Visualization",
|
||||
input: "User Input",
|
||||
output: "Execution Output",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
renderEditor: () => React.ReactNode;
|
||||
renderRenderer: () => React.ReactNode;
|
||||
renderInput: () => React.ReactNode;
|
||||
renderOutput: () => React.ReactNode;
|
||||
};
|
||||
|
||||
export const MainLayout = (props: Props) => {
|
||||
const MOSAIC_MAP = {
|
||||
editor: props.renderEditor,
|
||||
renderer: props.renderRenderer,
|
||||
input: props.renderInput,
|
||||
output: props.renderOutput,
|
||||
};
|
||||
|
||||
const INITIAL_LAYOUT: MosaicNode<WINDOW_ID> = {
|
||||
direction: "row",
|
||||
first: "editor",
|
||||
second: {
|
||||
direction: "column",
|
||||
first: "renderer",
|
||||
second: {
|
||||
direction: "row",
|
||||
first: "input",
|
||||
second: "output",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Mosaic<keyof typeof MOSAIC_MAP>
|
||||
className="mosaic-blueprint-theme bp3-dark"
|
||||
initialValue={INITIAL_LAYOUT}
|
||||
renderTile={(windowId, path) => (
|
||||
<MosaicWindow<number>
|
||||
path={path}
|
||||
title={WindowTitles[windowId]}
|
||||
toolbarControls={<span />}
|
||||
>
|
||||
{MOSAIC_MAP[windowId]()}
|
||||
</MosaicWindow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
78
ui/Mainframe.tsx
Normal file
78
ui/Mainframe.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { CodeEditor, CodeEditorRef } from "../ui/code-editor";
|
||||
import { InputEditor, InputEditorRef } from "../ui/input-editor";
|
||||
import { MainLayout } from "../ui/MainLayout";
|
||||
import { useExecController } from "../ui/use-exec-controller";
|
||||
import { DocumentRange, LanguageProvider } from "../engines/types";
|
||||
import SampleLangProvider from "../engines/sample-lang";
|
||||
import BrainfuckProvider from "../engines/brainfuck";
|
||||
import { OutputViewer } from "../ui/output-viewer";
|
||||
|
||||
export const Mainframe = () => {
|
||||
const codeEditorRef = React.useRef<CodeEditorRef>(null);
|
||||
const inputEditorRef = React.useRef<InputEditorRef>(null);
|
||||
// const providerRef = React.useRef<LanguageProvider<any>>(SampleLangProvider);
|
||||
const providerRef = React.useRef<LanguageProvider<any>>(BrainfuckProvider);
|
||||
const execController = useExecController();
|
||||
|
||||
// UI states used in execution time
|
||||
const [rendererState, setRendererState] = React.useState<any>(null);
|
||||
const [output, setOutput] = React.useState<string | null>(null);
|
||||
const [codeHighlights, setCodeHighlights] = React.useState<
|
||||
DocumentRange | undefined
|
||||
>();
|
||||
|
||||
const testDrive = React.useCallback(async () => {
|
||||
console.info("=== RUNNING TEST DRIVE ===");
|
||||
|
||||
// Check that controller is ready to execute
|
||||
const readyStates = ["empty", "ready", "done"];
|
||||
if (!readyStates.includes(execController.state)) {
|
||||
console.error(`Controller not ready: state is ${execController.state}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare for execution
|
||||
setOutput("");
|
||||
await execController.resetState();
|
||||
await execController.prepare(
|
||||
codeEditorRef.current!.getValue(),
|
||||
inputEditorRef.current!.getValue()
|
||||
);
|
||||
|
||||
// Begin execution
|
||||
await execController.executeAll((result) => {
|
||||
setRendererState(result.rendererState);
|
||||
setCodeHighlights(result.nextStepLocation || undefined);
|
||||
setOutput((o) => (o || "") + (result.output || ""));
|
||||
}, 20);
|
||||
}, [execController.state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
if (!(ev.ctrlKey && ev.code === "KeyY")) return;
|
||||
testDrive();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [testDrive]);
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
renderEditor={() => (
|
||||
<CodeEditor
|
||||
ref={codeEditorRef}
|
||||
languageId="brainfuck"
|
||||
highlights={codeHighlights}
|
||||
defaultValue={providerRef.current.sampleProgram}
|
||||
tokensProvider={providerRef.current.editorTokensProvider}
|
||||
/>
|
||||
)}
|
||||
renderRenderer={() => (
|
||||
<providerRef.current.Renderer state={rendererState} />
|
||||
)}
|
||||
renderInput={() => <InputEditor ref={inputEditorRef} />}
|
||||
renderOutput={() => <OutputViewer value={output} />}
|
||||
/>
|
||||
);
|
||||
};
|
85
ui/code-editor/index.tsx
Normal file
85
ui/code-editor/index.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
import monaco from "monaco-editor";
|
||||
import { DocumentRange, MonacoTokensProvider } from "../../engines/types";
|
||||
import { useEditorConfig } from "./use-editor-config";
|
||||
|
||||
// Type aliases for the Monaco editor
|
||||
type EditorInstance = monaco.editor.IStandaloneCodeEditor;
|
||||
|
||||
/** Create Monaco decoration range object from highlights */
|
||||
const createRange = (
|
||||
monacoInstance: typeof monaco,
|
||||
highlights: DocumentRange
|
||||
) => {
|
||||
const lineNum = highlights.line;
|
||||
const startChar = highlights.charRange?.start || 0;
|
||||
const endChar = highlights.charRange?.end || 1000;
|
||||
const range = new monacoInstance.Range(lineNum, startChar, lineNum, endChar);
|
||||
const isWholeLine = !highlights.charRange;
|
||||
return { range, options: { isWholeLine, inlineClassName: "code-highlight" } };
|
||||
};
|
||||
|
||||
// Interface for interacting with the editor
|
||||
export interface CodeEditorRef {
|
||||
/**
|
||||
* Get the current text content of the editor.
|
||||
*/
|
||||
getValue: () => string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** ID of the active language */
|
||||
languageId: string;
|
||||
/** Default code to display in editor */
|
||||
defaultValue: string;
|
||||
/** Code range to highlight in the editor */
|
||||
highlights?: DocumentRange;
|
||||
/** Tokens provider for the language */
|
||||
tokensProvider?: MonacoTokensProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the Monaco editor that reveals
|
||||
* only the required functionality to the parent container.
|
||||
*/
|
||||
const CodeEditorComponent = (props: Props, ref: React.Ref<CodeEditorRef>) => {
|
||||
const editorRef = React.useRef<EditorInstance | null>(null);
|
||||
const monacoInstance = useMonaco();
|
||||
const { highlights } = props;
|
||||
useEditorConfig({
|
||||
languageId: props.languageId,
|
||||
tokensProvider: props.tokensProvider,
|
||||
});
|
||||
|
||||
// Change editor highlights when prop changes
|
||||
React.useEffect(() => {
|
||||
if (!editorRef.current || !highlights) return;
|
||||
const range = createRange(monacoInstance!, highlights);
|
||||
const decors = editorRef.current!.deltaDecorations([], [range]);
|
||||
return () => {
|
||||
editorRef.current!.deltaDecorations(decors, []);
|
||||
};
|
||||
}, [highlights]);
|
||||
|
||||
// Provide handle to parent for accessing editor contents
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getValue: () => editorRef.current!.getValue(),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
defaultLanguage="brainfuck"
|
||||
defaultValue={props.defaultValue}
|
||||
onMount={(editor) => (editorRef.current = editor)}
|
||||
options={{ minimap: { enabled: false } }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeEditor = React.forwardRef(CodeEditorComponent);
|
28
ui/code-editor/use-editor-config.ts
Normal file
28
ui/code-editor/use-editor-config.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { useMonaco } from "@monaco-editor/react";
|
||||
import { MonacoTokensProvider } from "../../engines/types";
|
||||
|
||||
type ConfigParams = {
|
||||
languageId: string;
|
||||
tokensProvider?: MonacoTokensProvider;
|
||||
};
|
||||
|
||||
/** Add custom language and relevant providers to Monaco */
|
||||
export const useEditorConfig = (params: ConfigParams) => {
|
||||
const monaco = useMonaco();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
// Register language
|
||||
monaco.languages.register({ id: params.languageId });
|
||||
|
||||
// If provided, register token provider for language
|
||||
if (params.tokensProvider) {
|
||||
monaco.languages.setMonarchTokensProvider(
|
||||
params.languageId,
|
||||
params.tokensProvider
|
||||
);
|
||||
}
|
||||
}, [monaco]);
|
||||
};
|
38
ui/input-editor.tsx
Normal file
38
ui/input-editor.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { TextArea } from "@blueprintjs/core";
|
||||
|
||||
// Interface for interacting with the editor
|
||||
export interface InputEditorRef {
|
||||
/**
|
||||
* Get the current text content of the editor.
|
||||
*/
|
||||
getValue: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A very simple text editor for user input
|
||||
*/
|
||||
const InputEditorComponent = (_: {}, ref: React.Ref<InputEditorRef>) => {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getValue: () => textareaRef.current!.value,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
fill
|
||||
large
|
||||
growVertically
|
||||
inputRef={textareaRef}
|
||||
placeholder="Enter program input here..."
|
||||
style={{ height: "100%", resize: "none", boxShadow: "none" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const InputEditor = React.forwardRef(InputEditorComponent);
|
33
ui/output-viewer.tsx
Normal file
33
ui/output-viewer.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { TextArea } from "@blueprintjs/core";
|
||||
|
||||
/**
|
||||
* For aesthetic reasons, we use readonly textarea for displaying output.
|
||||
* Textarea displays placeholder if value passed is empty string, which is undesired.
|
||||
* This function is a fake-whitespace workaround.
|
||||
*
|
||||
* @param value Value received from parent. Placeholder shown on `null`.
|
||||
* @returns Value to pass as prop to Blueprint TextArea
|
||||
*/
|
||||
const toTextareaValue = (value: string | null): string | undefined => {
|
||||
if (value == null) return undefined; // Placeholder shown
|
||||
if (value === "") return "\u0020"; // Fake whitespace to hide placeholder
|
||||
return value; // Non-empty output value
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export const OutputViewer = ({ value }: Props) => {
|
||||
return (
|
||||
<TextArea
|
||||
fill
|
||||
large
|
||||
readOnly
|
||||
growVertically
|
||||
value={toTextareaValue(value)}
|
||||
placeholder="Run code to see output..."
|
||||
style={{ height: "100%", resize: "none", boxShadow: "none" }}
|
||||
/>
|
||||
);
|
||||
};
|
135
ui/use-exec-controller.ts
Normal file
135
ui/use-exec-controller.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import React from "react";
|
||||
import { StepExecutionResult } from "../engines/types";
|
||||
import {
|
||||
WorkerRequestData,
|
||||
WorkerResponseData,
|
||||
} from "../engines/worker-constants";
|
||||
|
||||
/** Possible states for the worker to be in */
|
||||
type WorkerState =
|
||||
| "loading" // Worker is not initialized yet
|
||||
| "empty" // Worker loaded, no code loaded yet
|
||||
| "ready" // Code loaded, ready to execute
|
||||
| "processing" // Executing code
|
||||
| "done"; // Program ended, reset now
|
||||
|
||||
/**
|
||||
* React Hook that manages initialization, communication and
|
||||
* cleanup for the worker thread used for code execution.
|
||||
*
|
||||
* Also abstracts away the details of message-passing and exposes
|
||||
* an imperative API to the parent component.
|
||||
*/
|
||||
export const useExecController = <RS>() => {
|
||||
const workerRef = React.useRef<Worker | null>(null);
|
||||
const [workerState, setWorkerState] = React.useState<WorkerState>("loading");
|
||||
|
||||
/**
|
||||
* Semi-typesafe wrapper to abstract request-response cycle into
|
||||
* a simple imperative asynchronous call. Returns Promise that resolves
|
||||
* with response data.
|
||||
*
|
||||
* Note that if the worker misbehaves due to any reason, the returned response data
|
||||
* (or `onData` argument) may not correspond to the request. Check this in the caller.
|
||||
*
|
||||
* @param request Data to send in request
|
||||
* @param onData Optional argument - if passed, function enters response-streaming mode.
|
||||
* Callback called with response data. Return `true` to keep the connection alive, `false` to end.
|
||||
* On end, promise resolves with last (already used) response data.
|
||||
*/
|
||||
const requestWorker = (
|
||||
request: WorkerRequestData,
|
||||
onData?: (data: WorkerResponseData<RS>) => boolean
|
||||
): Promise<WorkerResponseData<RS>> => {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (ev: MessageEvent<WorkerResponseData<RS>>) => {
|
||||
if (!onData) {
|
||||
// Normal mode
|
||||
workerRef.current!.removeEventListener("message", handler);
|
||||
resolve(ev.data);
|
||||
} else {
|
||||
// Persistent connection mode
|
||||
const keepAlive = onData(ev.data);
|
||||
if (keepAlive) return;
|
||||
// keepAlive is false: terminate connection
|
||||
workerRef.current!.removeEventListener("message", handler);
|
||||
resolve(ev.data);
|
||||
}
|
||||
};
|
||||
workerRef.current!.addEventListener("message", handler);
|
||||
workerRef.current!.postMessage(request);
|
||||
});
|
||||
};
|
||||
|
||||
// Initialization and cleanup of web worker
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (workerRef.current) throw new Error("Tried to reinitialize worker");
|
||||
workerRef.current = new Worker(
|
||||
new URL("../engines/worker.ts", import.meta.url)
|
||||
);
|
||||
const resp = await requestWorker({ type: "Init" });
|
||||
if (resp.type === "state" && resp.data === "empty")
|
||||
setWorkerState("empty");
|
||||
else throw new Error(`Unexpected response on init: ${resp}`);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
// Terminate worker and clean up
|
||||
workerRef.current!.terminate();
|
||||
workerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Load code and user input into the execution controller.
|
||||
* @param code Code content
|
||||
* @param input User input
|
||||
*/
|
||||
const prepare = React.useCallback(async (code: string, input: string) => {
|
||||
const res = await requestWorker({
|
||||
type: "Prepare",
|
||||
params: { code, input },
|
||||
});
|
||||
if (res.type === "state" && res.data === "ready") setWorkerState("ready");
|
||||
else throw new Error(`Unexpected response on loadCode: ${res.toString()}`);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset the state of the controller and engine.
|
||||
*/
|
||||
const resetState = React.useCallback(async () => {
|
||||
const res = await requestWorker({ type: "Reset" });
|
||||
if (res.type === "state" && res.data === "empty") setWorkerState("empty");
|
||||
else
|
||||
throw new Error(`Unexpected response on resetState: ${res.toString()}`);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Execute the code loaded into the engine
|
||||
* @param onResult Callback used when an execution result is received
|
||||
*/
|
||||
const executeAll = React.useCallback(
|
||||
async (
|
||||
onResult: (result: StepExecutionResult<RS>) => void,
|
||||
interval?: number
|
||||
) => {
|
||||
setWorkerState("processing");
|
||||
// Set up a streaming-response cycle with the worker
|
||||
await requestWorker({ type: "Execute", params: { interval } }, (res) => {
|
||||
if (res.type !== "result") return false; // TODO: Throw error here
|
||||
onResult(res.data);
|
||||
if (res.data.nextStepLocation) return true;
|
||||
// Clean up and terminate response stream
|
||||
setWorkerState("done");
|
||||
return false;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return React.useMemo(
|
||||
() => ({ state: workerState, resetState, prepare, executeAll }),
|
||||
[workerState, resetState, prepare, executeAll]
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user