statebuddy/src/App/App.tsx

418 lines
14 KiB
TypeScript

import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { emptyStatechart, Statechart, Transition } from "../statecharts/abstract_syntax";
import { handleInputEvent, initialize, RuntimeError } from "../statecharts/interpreter";
import { BigStep, BigStepOutput, RT_Event } from "../statecharts/runtime_types";
import { InsertMode, VisualEditor, VisualEditorState } from "../VisualEditor/VisualEditor";
import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
import "../index.css";
import "./App.css";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { TopPanel } from "./TopPanel";
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
import { TraceableError } from "../statecharts/parser";
import { getKeyHandler } from "./shortcut_handler";
import { BottomPanel } from "./BottomPanel";
import { emptyState } from "@/statecharts/concrete_syntax";
import { PersistentDetails } from "./PersistentDetails";
import { DigitalWatchPlant } from "@/Plant/DigitalWatch/DigitalWatch";
import { DummyPlant } from "@/Plant/Dummy/Dummy";
import { Plant } from "@/Plant/Plant";
import { usePersistentState } from "@/util/persistent_state";
import { RTHistory } from "./RTHistory";
export type EditHistory = {
current: VisualEditorState,
history: VisualEditorState[],
future: VisualEditorState[],
}
const plants: [string, Plant<any>][] = [
["dummy", DummyPlant],
["digital watch", DigitalWatchPlant],
]
export type BigStepError = {
inputEvent: string,
simtime: number,
error: RuntimeError,
}
export type TraceItem = { kind: "error" } & BigStepError | { kind: "bigstep", plantState: any } & BigStep;
export type TraceState = {
trace: [TraceItem, ...TraceItem[]], // non-empty
idx: number,
}; // <-- null if there is no trace
function current(ts: TraceState) {
return ts.trace[ts.idx]!;
}
function getPlantState<T>(plant: Plant<T>, trace: TraceItem[], idx: number): T | null {
if (idx === -1) {
return plant.initial;
}
let plantState = getPlantState(plant, trace, idx-1);
if (plantState !== null) {
const currentConfig = trace[idx];
if (currentConfig.kind === "bigstep") {
for (const o of currentConfig.outputEvents) {
plantState = plant.reduce(o, plantState);
}
}
return plantState;
}
return null;
}
export function App() {
const [mode, setMode] = useState<InsertMode>("and");
const [historyState, setHistoryState] = useState<EditHistory>({current: emptyState, history: [], future: []});
const [ast, setAST] = useState<Statechart>(emptyStatechart);
const [errors, setErrors] = useState<TraceableError[]>([]);
const [trace, setTrace] = useState<TraceState|null>(null);
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [modal, setModal] = useState<ReactElement|null>(null);
const [plantName, setPlantName] = usePersistentState("plant", "dummy");
const [zoom, setZoom] = usePersistentState("zoom", 1);
const [showKeys, setShowKeys] = usePersistentState("shortcuts", true);
const plant = plants.find(([pn, p]) => pn === plantName)![1];
const editorState = historyState.current;
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
setHistoryState(historyState => ({...historyState, current: cb(historyState.current)}));
}, [setHistoryState]);
const refRightSideBar = useRef<HTMLDivElement>(null);
// append editor state to undo history
function makeCheckPoint() {
setHistoryState(historyState => ({
...historyState,
history: [...historyState.history, historyState.current],
future: [],
}));
}
function onUndo() {
setHistoryState(historyState => {
if (historyState.history.length === 0) {
return historyState; // no change
}
return {
current: historyState.history.at(-1)!,
history: historyState.history.slice(0,-1),
future: [...historyState.future, historyState.current],
}
})
}
function onRedo() {
setHistoryState(historyState => {
if (historyState.future.length === 0) {
return historyState; // no change
}
return {
current: historyState.future.at(-1)!,
history: [...historyState.history, historyState.current],
future: historyState.future.slice(0,-1),
}
});
}
function onInit() {
const timestampedEvent = {simtime: 0, inputEvent: "<init>"};
let config;
try {
config = initialize(ast);
const item = {kind: "bigstep", ...timestampedEvent, ...config};
const plantState = getPlantState(plant, [item], 0);
setTrace({trace: [{...item, plantState}], idx: 0});
}
catch (error) {
if (error instanceof RuntimeError) {
setTrace({trace: [{kind: "error", ...timestampedEvent, error}], idx: 0});
}
else {
throw error; // probably a bug in the interpreter
}
}
setTime({kind: "paused", simtime: 0});
scrollDownSidebar();
}
function onClear() {
setTrace(null);
setTime({kind: "paused", simtime: 0});
}
// raise input event, producing a new runtime configuration (or a runtime error)
function onRaise(inputEvent: string, param: any) {
if (trace !== null && ast.inputEvents.some(e => e.event === inputEvent)) {
const config = current(trace);
if (config.kind === "bigstep") {
const simtime = getSimTime(time, Math.round(performance.now()));
produceNextConfig(simtime, {kind: "input", name: inputEvent, param}, config);
}
}
}
// timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
useEffect(() => {
let timeout: NodeJS.Timeout | undefined;
if (trace !== null) {
const config = current(trace);
if (config.kind === "bigstep") {
const timers = config.environment.get("_timers") || [];
if (timers.length > 0) {
const [nextInterrupt, timeElapsedEvent] = timers[0];
const raiseTimeEvent = () => {
produceNextConfig(nextInterrupt, timeElapsedEvent, config);
}
// depending on whether paused or realtime, raise immediately or in the future:
if (time.kind === "realtime") {
const wallclkDelay = getWallClkDelay(time, nextInterrupt, Math.round(performance.now()));
timeout = setTimeout(raiseTimeEvent, wallclkDelay);
}
else if (time.kind === "paused") {
if (nextInterrupt <= time.simtime) {
raiseTimeEvent();
}
}
}
}
}
return () => {
if (timeout) clearTimeout(timeout);
}
}, [time, trace]); // <-- todo: is this really efficient?
function produceNextConfig(simtime: number, event: RT_Event, config: TraceItem) {
const timedEvent = {
simtime,
inputEvent: event.kind === "timer" ? "<timer>" : event.name,
};
let newItem: TraceItem;
try {
const nextConfig = handleInputEvent(simtime, event, ast, config as BigStep); // may throw
let plantState = config.plantState;
for (const o of nextConfig.outputEvents) {
plantState = plant.reduce(o, plantState);
}
console.log({plantState});
newItem = {kind: "bigstep", plantState, ...timedEvent, ...nextConfig};
}
catch (error) {
if (error instanceof RuntimeError) {
newItem = {kind: "error", ...timedEvent, error};
}
else {
throw error;
}
}
// @ts-ignore
setTrace(trace => ({
trace: [
...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
newItem,
],
idx: trace!.idx+1,
}));
scrollDownSidebar();
}
function onBack() {
if (trace !== null) {
setTime(() => {
if (trace !== null) {
return {
kind: "paused",
simtime: trace.trace[trace.idx-1].simtime,
}
}
return { kind: "paused", simtime: 0 };
});
setTrace({
...trace,
idx: trace.idx-1,
});
}
}
function scrollDownSidebar() {
if (refRightSideBar.current) {
const el = refRightSideBar.current;
// hack: we want to scroll to the new element, but we have to wait until it is rendered...
setTimeout(() => {
el.scrollIntoView({block: "end", behavior: "smooth"});
}, 50);
}
}
useEffect(() => {
console.log("Welcome to StateBuddy!");
() => {
console.log("Goodbye!");
}
}, []);
useEffect(() => {
const onKeyDown = getKeyHandler(setMode);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, []);
let highlightActive: Set<string>;
let highlightTransitions: string[];
if (trace === null) {
highlightActive = new Set();
highlightTransitions = [];
}
else {
const item = current(trace);
console.log(trace);
if (item.kind === "bigstep") {
highlightActive = item.mode;
highlightTransitions = item.firedTransitions;
}
else {
highlightActive = new Set();
highlightTransitions = [];
}
}
// const plantState = trace && getPlantState(plant, trace.trace, trace.idx);
const [showExecutionTrace, setShowExecutionTrace] = usePersistentState("showExecutionTrace", true);
return <>
{/* Modal dialog */}
{modal && <div
className="modalOuter"
onMouseDown={() => setModal(null)}>
<div className="modalInner">
<span onMouseDown={e => e.stopPropagation()}>
{modal}
</span>
</div>
</div>}
<Stack sx={{height:'100%'}}>
<Stack direction="row" sx={{flexGrow:1, overflow: "auto"}}>
{/* Left: top bar and main editor */}
<Box sx={{flexGrow:1, overflow: "auto"}}>
<Stack sx={{height:'100%'}}>
{/* Top bar */}
<Box
className="shadowBelow"
sx={{
display: "flex",
borderBottom: 1,
borderColor: "divider",
alignItems: 'center',
flex: '0 0 content',
}}
>
<TopPanel
{...{trace, ast, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, mode, setMode, setModal, zoom, setZoom, showKeys, setShowKeys, history: historyState}}
/>
</Box>
{/* Below the top bar: Editor */}
<Box sx={{flexGrow:1, overflow: "auto"}}>
<VisualEditor {...{state: editorState, setState: setEditorState, ast, setAST, trace, setTrace, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>
</Box>
</Stack>
</Box>
{/* Right: sidebar */}
<Box sx={{
borderLeft: 1,
borderColor: "divider",
flex: '0 0 content',
overflowY: "auto",
overflowX: "visible",
maxWidth: 'min(300px, 30vw)',
}}>
<Stack sx={{height:'100%'}}>
<Box
className={showExecutionTrace ? "shadowBelow" : ""}
sx={{flex: '0 0 content', backgroundColor: ''}}
>
<PersistentDetails localStorageKey="showStateTree" initiallyOpen={true}>
<summary>state tree</summary>
<ul>
<ShowAST {...{...ast, trace, highlightActive}}/>
</ul>
</PersistentDetails>
<PersistentDetails localStorageKey="showInputEvents" initiallyOpen={true}>
<summary>input events</summary>
<ShowInputEvents
inputEvents={ast.inputEvents}
onRaise={onRaise}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={showKeys}/>
</PersistentDetails>
<PersistentDetails localStorageKey="showInternalEvents" initiallyOpen={true}>
<summary>internal events</summary>
<ShowInternalEvents internalEvents={ast.internalEvents}/>
</PersistentDetails>
<PersistentDetails localStorageKey="showOutputEvents" initiallyOpen={true}>
<summary>output events</summary>
<ShowOutputEvents outputEvents={ast.outputEvents}/>
</PersistentDetails>
<PersistentDetails localStorageKey="showPlant" initiallyOpen={true}>
<summary>plant</summary>
<select
value={plantName}
onChange={e => setPlantName(() => e.target.value)}>
{plants.map(([plantName, p]) =>
<option>{plantName}</option>
)}
</select>
{trace !== null &&
plant.render(trace.trace[trace.idx].plantState, event => onRaise(event.name, event.param))}
</PersistentDetails>
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary></details>
</Box>
{showExecutionTrace &&
<Box sx={{
flexGrow:1,
overflow:'auto',
minHeight: '50vh',
// minHeight: '75%', // <-- allows us to always scroll down the sidebar far enough such that the execution history is enough in view
}}>
{/* <PersistentDetails localStorageKey="showExecutionTrace" initiallyOpen={true}> */}
{/* <summary>execution trace</summary> */}
<div ref={refRightSideBar}>
<RTHistory {...{ast, trace, setTrace, setTime}}/>
</div>
{/* </PersistentDetails> */}
</Box>}
<Box sx={{flex: '0 0 content'}}>
</Box>
</Stack>
</Box>
</Stack>
{/* Bottom panel */}
<Box sx={{flex: '0 0 content'}}>
<BottomPanel {...{errors}}/>
</Box>
</Stack>
</>;
}
export default App;