From ab988898c0419e611cdde6142e0b8ed3dc8505f9 Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Thu, 23 Oct 2025 19:16:46 +0200 Subject: [PATCH] performance and usability improvements --- artwork/about-logo.txt | 3 + package.json | 2 +- src/App/AST.css | 17 +- src/App/App.css | 5 + src/App/App.tsx | 303 +++++++++++++++--------- src/App/KeyInfo.tsx | 22 +- src/App/RTHistory.tsx | 66 ++++-- src/App/ShowAST.tsx | 7 +- src/App/TopPanel.tsx | 34 +-- src/App/util.ts | 12 + src/Plant/DigitalWatch/DigitalWatch.css | 14 ++ src/Plant/DigitalWatch/DigitalWatch.tsx | 44 ++-- src/Plant/Dummy/Dummy.tsx | 4 +- src/Plant/Plant.ts | 6 +- src/VisualEditor/VisualEditor.css | 2 +- src/VisualEditor/VisualEditor.tsx | 9 +- src/statecharts/interpreter.ts | 14 +- todo.txt | 23 +- 18 files changed, 381 insertions(+), 206 deletions(-) create mode 100644 artwork/about-logo.txt create mode 100644 src/Plant/DigitalWatch/DigitalWatch.css diff --git a/artwork/about-logo.txt b/artwork/about-logo.txt new file mode 100644 index 0000000..298f893 --- /dev/null +++ b/artwork/about-logo.txt @@ -0,0 +1,3 @@ +Font used in the logo is Twiddlestix: + +https://www.1001fonts.com/twiddlestix-font.html diff --git a/package.json b/package.json index 39e33f0..ac5cb33 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "module": "src/index.tsx", "scripts": { "dev": "bun --hot src/index.tsx", - "build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", + "build": "NODE_ENV=production bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", "start": "NODE_ENV=production bun src/index.tsx" }, "dependencies": { diff --git a/src/App/AST.css b/src/App/AST.css index 3e0b061..1617deb 100644 --- a/src/App/AST.css +++ b/src/App/AST.css @@ -9,12 +9,12 @@ details > summary { } /* these two rules add a bit of padding to an opened
node */ -details:open > summary { +/* details:open > summary:has(+ *) { margin-bottom: 4px; } -details:open { +details:open:has(>summary:has(+ *)) { padding-bottom: 8px; -} +} */ details > summary:hover { background-color: #eee; @@ -46,30 +46,33 @@ details > summary:hover { .outputEvent { border: 1px black solid; border-radius: 6px; - margin-left: 4px; + /* margin-left: 4px; */ padding-left: 2px; padding-right: 2px; background-color: rgb(230, 249, 255); + color: black; display: inline-block; } .internalEvent { border: 1px black solid; border-radius: 6px; - margin-left: 4px; + /* margin-left: 4px; */ padding-left: 2px; padding-right: 2px; background-color: rgb(255, 218, 252); + color: black; display: inline-block; } .inputEvent { border: 1px black solid; border-radius: 6px; - margin-left: 4px; + /* margin-left: 4px; */ padding-left: 2px; padding-right: 2px; background-color: rgb(224, 247, 209); + color: black; display: inline-block; } .inputEvent * { @@ -116,4 +119,4 @@ ul { .shadowBelow { box-shadow: 0 -15px 15px 15px rgba(0, 0, 0, 0.4); z-index: 1; -} \ No newline at end of file +} diff --git a/src/App/App.css b/src/App/App.css index bfc40f9..a073dd9 100644 --- a/src/App/App.css +++ b/src/App/App.css @@ -35,6 +35,11 @@ details:has(+ details) { /* border: solid black 3px; */ border: solid blue 1px; } + +.runtimeState.runtimeError { + background-color: lightpink; + color: darkred; +} /* details:not(:has(details)) > summary::marker { color: white; } */ diff --git a/src/App/App.tsx b/src/App/App.tsx index 48d20c3..5a5b7f9 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -1,8 +1,8 @@ -import { createElement, Dispatch, ReactElement, SetStateAction, useEffect, useRef, useState } from "react"; +import { ReactElement, useEffect, useMemo, useRef, useState } from "react"; -import { emptyStatechart, Statechart } from "../statecharts/abstract_syntax"; -import { handleInputEvent, initialize } from "../statecharts/interpreter"; -import { BigStep, BigStepOutput } from "../statecharts/runtime_types"; +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"; @@ -12,19 +12,19 @@ import "./App.css"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import { TopPanel } from "./TopPanel"; -import { RTHistory } from "./RTHistory"; 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 { DigitalWatch, DigitalWatchPlant } from "@/Plant/DigitalWatch/DigitalWatch"; +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"; -type EditHistory = { +export type EditHistory = { current: VisualEditorState, history: VisualEditorState[], future: VisualEditorState[], @@ -35,13 +35,46 @@ const plants: [string, Plant][] = [ ["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(plant: Plant, 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("and"); const [historyState, setHistoryState] = useState({current: emptyState, history: [], future: []}); const [ast, setAST] = useState(emptyStatechart); const [errors, setErrors] = useState([]); - const [rt, setRT] = useState([]); - const [rtIdx, setRTIdx] = useState(); + const [trace, setTrace] = useState(null); const [time, setTime] = useState({kind: "paused", simtime: 0}); const [modal, setModal] = useState(null); @@ -58,7 +91,7 @@ export function App() { const refRightSideBar = useRef(null); - + // append editor state to undo history function makeCheckPoint() { setHistoryState(historyState => ({ ...historyState, @@ -92,54 +125,124 @@ export function App() { } function onInit() { - const config = initialize(ast); - setRT([{inputEvent: null, simtime: 0, ...config}]); - setRTIdx(0); + const timestampedEvent = {simtime: 0, inputEvent: ""}; + 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() { - setRT([]); - setRTIdx(undefined); + 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 (rt.length>0 && rtIdx!==undefined && ast.inputEvents.some(e => e.event === inputEvent)) { - const simtime = getSimTime(time, Math.round(performance.now())); - const nextConfig = handleInputEvent(simtime, {kind: "input", name: inputEvent, param}, ast, rt[rtIdx]!); - appendNewConfig(inputEvent, simtime, nextConfig); + 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" ? "" : event.name, + }; - function appendNewConfig(inputEvent: string, simtime: number, config: BigStepOutput) { - setRT([...rt.slice(0, rtIdx!+1), {inputEvent, simtime, ...config}]); - setRTIdx(rtIdx!+1); - // console.log('new config:', config); + 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) { + console.log(o); + 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() { - setTime(() => { - if (rtIdx !== undefined) { - if (rtIdx > 0) + if (trace !== null) { + setTime(() => { + if (trace !== null) { return { kind: "paused", - simtime: rt[rtIdx-1].simtime, + simtime: trace.trace[trace.idx-1].simtime, } - } - return { kind: "paused", simtime: 0 }; - }); - setRTIdx(rtIdx => { - if (rtIdx !== undefined) { - if (rtIdx > 0) - return rtIdx - 1; - else - return 0; - } - else return undefined; - }) + } + return { kind: "paused", simtime: 0 }; + }); + setTrace({ + ...trace, + idx: trace.idx-1, + }); + } } function scrollDownSidebar() { @@ -159,36 +262,6 @@ export function App() { } }, []); - useEffect(() => { - let timeout: NodeJS.Timeout | undefined; - if (rtIdx !== undefined) { - const currentRt = rt[rtIdx]!; - const timers = currentRt.environment.get("_timers") || []; - if (timers.length > 0) { - const [nextInterrupt, timeElapsedEvent] = timers[0]; - const raiseTimeEvent = () => { - const nextConfig = handleInputEvent(nextInterrupt, timeElapsedEvent, ast, currentRt); - appendNewConfig('', nextInterrupt, nextConfig); - } - if (time.kind === "realtime") { - const wallclkDelay = getWallClkDelay(time, nextInterrupt, Math.round(performance.now())); - // console.log('scheduling timeout after', wallclkDelay); - timeout = setTimeout(raiseTimeEvent, wallclkDelay); - } - else if (time.kind === "paused") { - if (nextInterrupt <= time.simtime) { - raiseTimeEvent(); - } - } - } - } - - return () => { - if (timeout) clearTimeout(timeout); - } - - }, [time, rtIdx]); - useEffect(() => { const onKeyDown = getKeyHandler(setMode); window.addEventListener("keydown", onKeyDown); @@ -197,27 +270,28 @@ export function App() { }; }, []); - // const highlightActive = (rtIdx !== undefined) && new Set([...rt[rtIdx].mode].filter(uid => { - // const state = ast.uid2State.get(uid); - // return state && state.parent?.kind !== "and"; - // })) || new Set(); - - const highlightActive: Set = (rtIdx === undefined) ? new Set() : rt[rtIdx].mode; - - const highlightTransitions = (rtIdx === undefined) ? [] : rt[rtIdx].firedTransitions; - - - const plantStates = []; - let ps = plant.initial(e => { - onRaise(e.name, e.param); - }); - for (let i=0; i; + 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 <> @@ -250,13 +324,12 @@ export function App() { }} > {/* Below the top bar: Editor */} - + @@ -272,18 +345,22 @@ export function App() { }}> state tree
    - +
input events - + internal events @@ -293,20 +370,6 @@ export function App() { output events -
- - -
- -
-
-
- plant - {rtIdx!==undefined && } + {trace !== null && + plant.render(trace.trace[trace.idx].plantState, event => onRaise(event.name, event.param))} +
setShowExecutionTrace(e.newState === "open")}>execution trace
+
+ + {showExecutionTrace && + + {/* */} + {/* execution trace */} +
+ +
+ {/*
*/} +
} + +
diff --git a/src/App/KeyInfo.tsx b/src/App/KeyInfo.tsx index 9e7fc73..40e8617 100644 --- a/src/App/KeyInfo.tsx +++ b/src/App/KeyInfo.tsx @@ -1,14 +1,16 @@ -import { Stack } from "@mui/material"; +import { Box, Stack } from "@mui/material"; -export function KeyInfoVisible(props: {keyInfo, children}) { - return -
- {props.keyInfo} -
-
- {props.children} -
-
+export function KeyInfoVisible(props: {keyInfo, children, horizontal?: boolean}) { + return
+ {/* */} +
+ {props.keyInfo} +
+
+ {props.children} +
+ {/*
*/} +
} export function KeyInfoHidden(props: {children}) { diff --git a/src/App/RTHistory.tsx b/src/App/RTHistory.tsx index 779efc1..8110df2 100644 --- a/src/App/RTHistory.tsx +++ b/src/App/RTHistory.tsx @@ -1,41 +1,61 @@ import { Dispatch, Ref, SetStateAction } from "react"; import { Statechart, stateDescription } from "../statecharts/abstract_syntax"; -import { BigStep, Environment, Mode, RaisedEvent } from "../statecharts/runtime_types"; +import { BigStep, Environment, Mode, RaisedEvent, RT_Event } from "../statecharts/runtime_types"; import { formatTime } from "./util"; import { TimeMode } from "../statecharts/time"; +import { TraceState } from "./App"; type RTHistoryProps = { - rt: BigStep[], - rtIdx: number | undefined, + trace: TraceState|null, + setTrace: Dispatch>; ast: Statechart, - setRTIdx: Dispatch>, setTime: Dispatch>, - refRightSideBar: Ref, } -export function RTHistory({rt, rtIdx, ast, setRTIdx, setTime, refRightSideBar}: RTHistoryProps) { +export function RTHistory({trace, setTrace, ast, setTime}: RTHistoryProps) { function gotoRt(idx: number, timestamp: number) { - setRTIdx(idx); + setTrace(trace => trace && { + ...trace, + idx, + }); setTime({kind: "paused", simtime: timestamp}); } + if (trace === null) { + return <>; + } return
- {rt.map((r, idx) => <> -
gotoRt(idx, r.simtime)}> -
- {formatTime(r.simtime)} -   -
{r.inputEvent || ""}
-
- - - {r.outputEvents.length>0 && <>^ - {r.outputEvents.map((e:RaisedEvent) => {e.name})} - } - {/*
*/} -
)} + {trace.trace.map((item, i) => { + if (item.kind === "bigstep") { + const newStates = item.mode.difference(trace.trace[i-1]?.mode || new Set()); + return
gotoRt(i, item.simtime)}> +
+ {formatTime(item.simtime)} +   +
{item.inputEvent || ""}
+
+ + + {item.outputEvents.length>0 && <>^ + {item.outputEvents.map((e:RaisedEvent) => {e.name})} + } +
; + } + else { + return
+
+ {formatTime(item.simtime)} +   +
{item.inputEvent}
+
+
+ {item.error.message} +
+
; + } + })}
; } diff --git a/src/App/ShowAST.tsx b/src/App/ShowAST.tsx index 68983ff..059d63f 100644 --- a/src/App/ShowAST.tsx +++ b/src/App/ShowAST.tsx @@ -32,7 +32,7 @@ export function ShowAction(props: {action: Action}) { } } -export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: Map, rt: RT_Statechart | undefined, highlightActive: Set}) { +export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: Map, trace: TraceState | null, highlightActive: Set}) { const description = stateDescription(props.root); // const outgoing = props.transitions.get(props.root.uid) || []; @@ -40,7 +40,7 @@ export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: {props.root.kind !== "pseudo" && props.root.children.length>0 &&
    {props.root.children.map(child => - + )}
} @@ -74,6 +74,7 @@ export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: import BoltIcon from '@mui/icons-material/Bolt'; import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo"; import { useEffect } from "react"; +import { TraceState } from "./App"; export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean, showKeys: boolean}) { const raiseHandlers = inputEvents.map(({event}) => { @@ -110,7 +111,7 @@ export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inp const shortcut = (i+1)%10; const KI = (i <= 10) ? KeyInfo : KeyInfoHidden; return
- {shortcut}}> + {shortcut}} horizontal={true}> + Ctrl+Shift+Z}> - +
@@ -263,12 +269,12 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl C}> - + Space toggles}> - - + +   @@ -290,12 +296,12 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl
  - +
  - + Tab}> diff --git a/src/App/util.ts b/src/App/util.ts index dd3136e..6babea1 100644 --- a/src/App/util.ts +++ b/src/App/util.ts @@ -11,3 +11,15 @@ export function compactTime(timeMs: number) { return `${timeMs} ms`; } +export function memoize(fn: (i: InType) => OutType) { + const cache = new Map(); + return (i: InType) => { + const found = cache.get(i); + if (found) { + return found; + } + const result = fn(i); + cache.set(i, result); + return result; + } +} diff --git a/src/Plant/DigitalWatch/DigitalWatch.css b/src/Plant/DigitalWatch/DigitalWatch.css new file mode 100644 index 0000000..77449b0 --- /dev/null +++ b/src/Plant/DigitalWatch/DigitalWatch.css @@ -0,0 +1,14 @@ +.watchButtonHelper { + fill-opacity: 0; +} + +.watchButtonHelper:hover { + fill: beige; + fill-opacity: 0.5; +} + +.watchButtonHelper:active { + fill: red; + fill-opacity: 1; +} + diff --git a/src/Plant/DigitalWatch/DigitalWatch.tsx b/src/Plant/DigitalWatch/DigitalWatch.tsx index 7817457..eb3cccd 100644 --- a/src/Plant/DigitalWatch/DigitalWatch.tsx +++ b/src/Plant/DigitalWatch/DigitalWatch.tsx @@ -4,6 +4,8 @@ import digitalFont from "./digital-font.ttf"; import { Plant } from "../Plant"; import { RaisedEvent } from "@/statecharts/runtime_types"; +import "./DigitalWatch.css"; + type DigitalWatchState = { light: boolean; h: number; @@ -12,7 +14,8 @@ type DigitalWatchState = { alarm: boolean; } -type DigitalWatchProps = DigitalWatchState & { +type DigitalWatchProps = { + state: DigitalWatchState, callbacks: { onTopLeftPressed: () => void; onTopRightPressed: () => void; @@ -25,7 +28,7 @@ type DigitalWatchProps = DigitalWatchState & { }, } -export function DigitalWatch({light, h, m, s, alarm, callbacks}: DigitalWatchProps) { +export function DigitalWatch({state: {light, h, m, s, alarm}, callbacks}: DigitalWatchProps) { const twoDigits = (n: number) => n < 0 ? " " : ("0"+n.toString()).slice(-2); const hhmmss = `${twoDigits(h)}:${twoDigits(m)}:${twoDigits(s)}`; @@ -44,19 +47,19 @@ export function DigitalWatch({light, h, m, s, alarm, callbacks}: DigitalWatchPro {hhmmss} - callbacks.onTopLeftPressed()} onMouseUp={() => callbacks.onTopLeftReleased()} /> - callbacks.onTopRightPressed()} onMouseUp={() => callbacks.onTopRightReleased()} /> - callbacks.onBottomLeftPressed()} onMouseUp={() => callbacks.onBottomLeftReleased()} /> - callbacks.onBottomRightPressed()} onMouseUp={() => callbacks.onBottomRightReleased()} /> @@ -68,7 +71,7 @@ export function DigitalWatch({light, h, m, s, alarm, callbacks}: DigitalWatchPro ; } -export const DigitalWatchPlant: Plant = { +export const DigitalWatchPlant: Plant = { inputEvents: [ { kind: "event", event: "setH", paramName: 'h' }, { kind: "event", event: "setM", paramName: 'm' }, @@ -86,24 +89,14 @@ export const DigitalWatchPlant: Plant = { { kind: "event", event: "bottomRightReleased" }, { kind: "event", event: "bottomLeftReleased" }, ], - initial: (raise: (event: RaisedEvent) => void) => ({ + initial: { light: false, alarm: false, h: 12, m: 0, s: 0, - callbacks: { - onTopLeftPressed: () => raise({ name: "topLeftPressed" }), - onTopRightPressed: () => raise({ name: "topRightPressed" }), - onBottomRightPressed: () => raise({ name: "bottomRightPressed" }), - onBottomLeftPressed: () => raise({ name: "bottomLeftPressed" }), - onTopLeftReleased: () => raise({ name: "topLeftReleased" }), - onTopRightReleased: () => raise({ name: "topRightReleased" }), - onBottomRightReleased: () => raise({ name: "bottomRightReleased" }), - onBottomLeftReleased: () => raise({ name: "bottomLeftReleased" }), - }, - }), - reducer: (inputEvent: RaisedEvent, state: DigitalWatchProps) => { + }, + reduce: (inputEvent: RaisedEvent, state: DigitalWatchState) => { if (inputEvent.name === "setH") { return { ...state, h: inputEvent.param }; } @@ -127,5 +120,14 @@ export const DigitalWatchPlant: Plant = { } return state; // unknown event - ignore it }, - render: DigitalWatch, + render: (state, raiseEvent) => raiseEvent({name: "topLeftPressed"}), + onTopRightPressed: () => raiseEvent({name: "topRightPressed"}), + onBottomRightPressed: () => raiseEvent({name: "bottomRightPressed"}), + onBottomLeftPressed: () => raiseEvent({name: "bottomLeftPressed"}), + onTopLeftReleased: () => raiseEvent({name: "topLeftReleased"}), + onTopRightReleased: () => raiseEvent({name: "topRightReleased"}), + onBottomRightReleased: () => raiseEvent({name: "bottomRightReleased"}), + onBottomLeftReleased: () => raiseEvent({name: "bottomLeftReleased"}), + }}/>, } diff --git a/src/Plant/Dummy/Dummy.tsx b/src/Plant/Dummy/Dummy.tsx index 5be4403..8274253 100644 --- a/src/Plant/Dummy/Dummy.tsx +++ b/src/Plant/Dummy/Dummy.tsx @@ -5,6 +5,6 @@ export const DummyPlant: Plant<{}> = { inputEvents: [], outputEvents: [], initial: () => ({}), - reducer: (_inputEvent: RaisedEvent, _state: {}) => ({}), - render: (_state: {}) => <>, + reduce: (_inputEvent: RaisedEvent, _state: {}) => ({}), + render: (_state: {}, _raise: (event: RaisedEvent) => void) => <>, } diff --git a/src/Plant/Plant.ts b/src/Plant/Plant.ts index b20aa16..69aef28 100644 --- a/src/Plant/Plant.ts +++ b/src/Plant/Plant.ts @@ -6,7 +6,7 @@ export type Plant = { inputEvents: EventTrigger[]; outputEvents: EventTrigger[]; - initial: (raise: (event: RaisedEvent) => void) => StateType; - reducer: (inputEvent: RaisedEvent, state: StateType) => StateType; - render: (state: StateType) => ReactElement; + initial: StateType; + reduce: (inputEvent: RaisedEvent, state: StateType) => StateType; + render: (state: StateType, raise: (event: RaisedEvent) => void) => ReactElement; } diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index f36bd16..19801a4 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -160,7 +160,7 @@ text.helper:hover { stroke: var(--error-color); } .arrow.fired { - stroke: rgb(160, 0, 168); + stroke: rgb(160 0 168); stroke-width: 3px; animation: blinkTransition 1s; } diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index d76fec9..1a9d1a3 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -15,6 +15,7 @@ import { HistorySVG } from "./HistorySVG"; import { detectConnections } from "../statecharts/detect_connections"; import "./VisualEditor.css"; +import { TraceState } from "@/App/App"; export type VisualEditorState = { rountangles: Rountangle[]; @@ -65,7 +66,7 @@ type VisualEditorProps = { setState: Dispatch<(v:VisualEditorState) => VisualEditorState>, ast: Statechart, setAST: Dispatch>, - rt: BigStep|undefined, + trace: TraceState | null, errors: TraceableError[], setErrors: Dispatch>, mode: InsertMode, @@ -76,7 +77,7 @@ type VisualEditorProps = { zoom: number; }; -export function VisualEditor({state, setState, ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) { +export function VisualEditor({state, setState, ast, setAST, trace, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) { const [dragging, setDragging] = useState(false); @@ -132,7 +133,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError }) }); }) - }, [rt]); + }, [trace && trace.idx]); useEffect(() => { const timeout = setTimeout(() => { @@ -667,7 +668,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError } } - const active = rt?.mode || new Set(); + const active = trace && trace.trace[trace.idx].mode || new Set(); const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); diff --git a/src/statecharts/interpreter.ts b/src/statecharts/interpreter.ts index a412518..684d81e 100644 --- a/src/statecharts/interpreter.ts +++ b/src/statecharts/interpreter.ts @@ -24,6 +24,16 @@ type ActionScope = { type EnteredScope = { enteredStates: Mode } & ActionScope; +export class RuntimeError extends Error { + highlight: string[]; + constructor(message: string, highlight: string[]) { + super(message); + this.highlight = highlight; + } +} + +export class NonDeterminismError extends RuntimeError {} + export function execAction(action: Action, rt: ActionScope): ActionScope { if (action.kind === "assignment") { const rhs = evalExpr(action.rhs, rt.environment); @@ -116,7 +126,7 @@ export function enterDefault(simtime: number, state: ConcreteState, rt: ActionSc // same as AND-state, but we only enter the initial state(s) if (state.initial.length > 0) { if (state.initial.length > 1) { - console.warn(state.uid + ': multiple initial states, only entering one of them'); + throw new NonDeterminismError(`Non-determinism: state '${stateDescription(state)} has multiple (${state.initial.length}) initial states.`, [...state.initial.map(i => i[0]), state.uid]); } const [arrowUid, toEnter] = state.initial[0]; firedTransitions = [...firedTransitions, arrowUid]; @@ -237,7 +247,7 @@ export function handleEvent(simtime: number, event: RT_Event, statechart: Statec evalExpr(l.guard, guardEnvironment)); if (enabled.length > 0) { if (enabled.length > 1) { - console.warn('nondeterminism!!!!'); + throw new NonDeterminismError(`Non-determinism: state '${stateDescription(state)}' has multiple (${enabled.length}) enabled outgoing transitions: ${enabled.map(([t]) => transitionDescription(t)).join(', ')}`, [...enabled.map(([t]) => t.uid), state.uid]); } const [t,l] = enabled[0]; // just pick one transition const arena = computeArena2(t, statechart.transitions); diff --git a/todo.txt b/todo.txt index b6e28bc..d506807 100644 --- a/todo.txt +++ b/todo.txt @@ -27,19 +27,23 @@ TODO -- must have: - - event parameters on output / internal events +- digital watch: + highlight when watch button pressed/released + +- maybe support: - explicit order of: - - outgoing transitions - - regions in AND-state + - outgoing transitions? - usability stuff: - - show internal events + - ability to hide statechart and only show the plant? - hovering over event in side panel should highlight all occurrences of the event in the SC - hovering over error in bottom panel should highlight that rror in the SC - highlight selected shapes while making a selection - comments sometimes snap to transitions even if they belong to a state + - highlight fired transitions + - highlight about-to-fire transitions + - when there is a runtime error, e.g., - variable not found - stuck in pseudo-state @@ -50,3 +54,12 @@ TODO - experimental features: - multiverse execution history - local scopes + + + +for the assignment: + *ALL* features + add history (look at original Harel paper) + +Publish StateBuddy paper(s): + compare CS approach to other tools, not only YAKINDU