diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 8ea95b0..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import "./index.css"; - -import { VisualEditor } from "./VisualEditor/VisualEditor"; - -export function App() { - return ( - - ); -} - -export default App; diff --git a/src/App/App.css b/src/App/App.css new file mode 100644 index 0000000..132cc86 --- /dev/null +++ b/src/App/App.css @@ -0,0 +1,31 @@ +.layoutVertical { + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; +} +.panel { + height: 1.5rem; + background-color: lightgrey; +} +.layout { + display: flex; + width: 100%; + height: calc(100vh - 1.5rem); +} + +.sidebar { + flex: 0 0 content; + padding-right: 4px; +} +.content { + flex: 1 1 auto; + overflow: auto; +} + +details { + padding-left :10; +} +/* details:not(:has(details)) > summary::marker { + color: white; +} */ \ No newline at end of file diff --git a/src/App/App.tsx b/src/App/App.tsx new file mode 100644 index 0000000..1a4198a --- /dev/null +++ b/src/App/App.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; + +import { ConcreteState, emptyStatechart, Statechart, Transition } from "../VisualEditor/ast"; + +import { VisualEditor } from "../VisualEditor/VisualEditor"; +import { RT_Statechart } from "../VisualEditor/runtime_types"; +import { initialize, raiseEvent } from "../VisualEditor/interpreter"; + +import "../index.css"; +import "./App.css"; +import { Action, Expression } from "../VisualEditor/label_ast"; + +export function stateDescription(state: ConcreteState) { + const description = state.comments.length > 0 ? state.comments[0][1] : state.uid; + return description; +} + +export function ShowTransition(props: {transition: Transition}) { + return <>➔ {stateDescription(props.transition.tgt)}; +} + +export function ShowExpr(props: {expr: Expression}) { + if (props.expr.kind === "literal") { + return <>{props.expr.value}; + } + else if (props.expr.kind === "ref") { + return <>{props.expr.variable}; + } + else if (props.expr.kind === "unaryExpr") { + return <>{props.expr.operator}; + } + else if (props.expr.kind === "binaryExpr") { + return <>{props.expr.operator}; + } +} + +export function ShowAction(props: {action: Action}) { + if (props.action.kind === "raise") { + return <>raise {props.action.event}; + } + else if (props.action.kind === "assignment") { + return <>{props.action.lhs} = ;; + } +} + +export function AST(props: {root: ConcreteState, transitions: Map}) { + const description = stateDescription(props.root); + const outgoing = props.transitions.get(props.root.uid) || []; + + return
+ {props.root.kind}: {description} + + {props.root.entryActions.length>0 && +
+ entry actions + {props.root.entryActions.map(action => +
+ )} +
+ } + {props.root.exitActions.length>0 && +
+ exit actions + {props.root.exitActions.map(action => + + )} +
+ } + {props.root.children.length>0 && +
+ children + {props.root.children.map(child => + + )} +
+ } + {outgoing.length>0 && +
+ outgoing + {outgoing.map(transition => <> 
)} +
+ } +
+} + + +export function App() { + const [ast, setAST] = useState(emptyStatechart); + const [errors, setErrors] = useState<[string,string][]>([]); + const [rt, setRT] = useState(null); + + const [paused, setPaused] = useState(true); + const [timescale, setTimescale] = useState(1); + + function restart() { + const rt = initialize(ast); + console.log('runtime: ', rt); + setRT(rt); + } + + function stop() { + setRT(null); + } + + function raise(event: string) { + if (rt && ast.inputEvents.has(event)) { + const nextConfigs = raiseEvent(event, ast, ast.root, rt) + console.log({nextConfigs}); + if (nextConfigs.length > 0) { + if (nextConfigs.length > 1) { + console.warn('non-determinism, blindly selecting first next run-time state!'); + } + setRT(nextConfigs[0]); + } + } + } + + return
+
+ + + +   + + + + +   + + +
+
+
+ +
+ +
+
; +} + +export default App; diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index d399f3a..8ce73d5 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -30,7 +30,7 @@ text.highlight { } .rountangle { - fill: rgba(255, 255, 255, 255); + fill: white; /* fill: none; */ stroke: black; stroke-width: 2px; @@ -58,6 +58,10 @@ text.highlight { .rountangle.error { stroke: rgb(230,0,0); } +.rountangle.active { + fill: rgb(255, 196, 0); + fill-opacity: 0.6; +} .selected:hover { cursor: grab; diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index e71b54f..8185bee 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react"; +import { Dispatch, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react"; import { ArcDirection, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, euclideanDistance, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry"; import "./VisualEditor.css"; @@ -9,7 +9,9 @@ import { parseStatechart } from "./parser"; import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS, MIN_ROUNTANGLE_SIZE, ROUNTANGLE_RADIUS } from "./parameters"; import * as lz4 from "@nick/lz4"; -import { initialize } from "./interpreter"; +import { getActiveStates, initialize } from "./interpreter"; +import { RT_Statechart } from "./runtime_types"; +import { emptyStatechart, Statechart } from "./ast"; type DraggingState = { @@ -48,7 +50,16 @@ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [ ["bottom", getBottomSide], ]; -export function VisualEditor() { +type VisualEditorProps = { + ast: Statechart, + setAST: Dispatch>, + rt: RT_Statechart|null, + setRT: Dispatch>, + errors: [string,string][], + setErrors: Dispatch>, +}; + +export function VisualEditor({ast, setAST, rt, setRT, errors, setErrors}: VisualEditorProps) { const [historyState, setHistoryState] = useState({current: emptyState, history: [], future: []}); const state = historyState.current; @@ -110,8 +121,6 @@ export function VisualEditor() { // not null while the user is making a selection const [selectingState, setSelectingState] = useState(null); - const [errors, setErrors] = useState<[string,string][]>([]); - const refSVG = useRef(null); useEffect(() => { @@ -139,9 +148,7 @@ export function VisualEditor() { const [statechart, errors] = parseStatechart(state); console.log('statechart: ', statechart, 'errors:', errors); setErrors(errors); - - const rt = initialize(statechart); - console.log('runtime:', rt); + setAST(statechart); }, 100); return () => clearTimeout(timeout); }, [state]); @@ -489,6 +496,8 @@ export function VisualEditor() { } } + const active = getActiveStates(rt?.mode || {}); + const rootErrors = errors.filter(([uid]) => uid === "root").map(err=>err[1]); return r.uid === rountangle.uid)?.parts || []} highlight={[...(sidesToHighlight[rountangle.uid] || []), ...(rountanglesToHighlight[rountangle.uid]?["left","right","top","bottom"]:[])]} errors={errors.filter(([uid,msg])=>uid===rountangle.uid).map(err=>err[1])} + active={active.has(rountangle.uid)} />)} {state.arrows.map(arrow => { @@ -631,7 +641,7 @@ function rountangleMinSize(size: Vec2D): Vec2D { }; } -export function RountangleSVG(props: {rountangle: Rountangle, selected: string[], highlight: RountanglePart[], errors: string[]}) { +export function RountangleSVG(props: {rountangle: Rountangle, selected: string[], highlight: RountanglePart[], errors: string[], active: boolean}) { const {topLeft, size, uid} = props.rountangle; // always draw a rountangle with a minimum size // during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it @@ -642,6 +652,7 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[] +(props.selected.length===4?" selected":"") +((props.rountangle.kind==="or")?" or":"") +(props.errors.length>0?" error":"") + +(props.active?" active":"") } rx={ROUNTANGLE_RADIUS} ry={ROUNTANGLE_RADIUS} x={0} diff --git a/src/VisualEditor/ast.ts b/src/VisualEditor/ast.ts index 92e6675..f605f54 100644 --- a/src/VisualEditor/ast.ts +++ b/src/VisualEditor/ast.ts @@ -2,10 +2,12 @@ import { Action, TransitionLabel } from "./label_ast"; export type AbstractState = { uid: string; + parent?: ConcreteState; children: ConcreteState[]; comments: [string, string][]; // array of tuple (text-uid, text-text) entryActions: Action[]; exitActions: Action[]; + depth: number; } export type AndState = { @@ -37,4 +39,61 @@ export type Statechart = { inputEvents: Set; internalEvents: Set; outputEvents: Set; + + uid2State: Map; +} + +export const emptyStatechart: Statechart = { + root: { + uid: "root", + kind: "or", + initial: [], + children:[], + comments: [], + entryActions: [], + exitActions: [], + }, + transitions: new Map(), + variables: new Set(), + inputEvents: new Set(), + internalEvents: new Set(), + outputEvents: new Set(), +}; + +// reflexive, transitive relation +export function isAncestorOf({ancestor, descendant}: {ancestor: ConcreteState, descendant: ConcreteState}): ConcreteState[] | false { + if (ancestor.uid === descendant.uid) { + return [descendant]; + } + if (ancestor.depth >= descendant.depth) { + return false; + } + const pathToParent = isAncestorOf({ancestor, descendant: descendant.parent!}); + return pathToParent && [...pathToParent, descendant]; +} + +// the arena of a transition is the lowest common ancestor state that is an OR-state +// see "Deconstructing the Semantics of Big-Step Modelling Languages" by Shahram Esmaeilsabzali, 2009 +export function computeArena({src, tgt}: {src: ConcreteState, tgt: ConcreteState}): { + arena: ConcreteState, + srcPath: ConcreteState[], + tgtPath: ConcreteState[], +} { + if (src.depth >= tgt.depth) { + const path = isAncestorOf({descendant: src, ancestor: tgt}); + if (path) { + if (tgt.kind === "or") { + return {arena: tgt, srcPath: path, tgtPath: [tgt]}; + } + } + // keep looking + const {arena, srcPath, tgtPath} = computeArena({src, tgt: tgt.parent!}); + return {arena, srcPath, tgtPath: [...tgtPath, tgt]}; + } + else { + // same, but swap src and tgt + const {arena, srcPath, tgtPath} = computeArena({src: tgt, tgt: src}); + return {arena, srcPath: tgtPath, tgtPath: srcPath}; + } + throw new Error("should never reach here"); } diff --git a/src/VisualEditor/interpreter.ts b/src/VisualEditor/interpreter.ts index e23edc3..ba0fb15 100644 --- a/src/VisualEditor/interpreter.ts +++ b/src/VisualEditor/interpreter.ts @@ -1,11 +1,11 @@ -import { ConcreteState, Statechart } from "./ast"; +import { computeArena, ConcreteState, isAncestorOf, Statechart, Transition } from "./ast"; import { Action, Expression } from "./label_ast"; import { Environment, RaisedEvents, Mode, RT_Statechart, initialRaised } from "./runtime_types"; export function initialize(ast: Statechart): RT_Statechart { - const {mode, environment, raised} = enter(ast.root, { + const {mode, environment, ...raised} = enterDefault(ast.root, { environment: new Map(), - raised: initialRaised, + ...initialRaised, }); return { mode, @@ -17,59 +17,102 @@ export function initialize(ast: Statechart): RT_Statechart { type ActionScope = { environment: Environment, - raised: RaisedEvents, -}; +} & RaisedEvents; type EnteredScope = { mode: Mode } & ActionScope; -export function enter(state: ConcreteState, rt: ActionScope): EnteredScope { - let {environment, raised} = rt; +export function enterDefault(state: ConcreteState, rt: ActionScope): EnteredScope { + let actionScope = rt; // execute entry actions for (const action of state.entryActions) { - ({environment, raised} = execAction(action, {environment, raised})); + (actionScope = execAction(action, actionScope)); } // enter children... const mode: {[uid:string]: Mode} = {}; if (state.kind === "and") { - + // enter every child for (const child of state.children) { let childMode; - ({mode: childMode, environment, raised} = enter(child, {environment, raised})); + ({mode: childMode, ...actionScope} = enterDefault(child, actionScope)); mode[child.uid] = childMode; } } else if (state.kind === "or") { - const mode: {[uid:string]: Mode} = {}; // same as AND-state, but we only enter the initial state(s) for (const [_, child] of state.initial) { let childMode; - ({mode: childMode, environment, raised} = enter(child, {environment, raised})); + ({mode: childMode, ...actionScope} = enterDefault(child, actionScope)); mode[child.uid] = childMode; } } - return { mode, environment, raised }; + return { mode, ...actionScope }; } -export function exit(state: ConcreteState, rt: EnteredScope): ActionScope { - let {mode, environment, raised} = rt; +export function enterPath(path: ConcreteState[], rt: ActionScope): EnteredScope { + let actionScope = rt; + + const [state, ...rest] = path; + + // execute entry actions + for (const action of state.entryActions) { + (actionScope = execAction(action, actionScope)); + } + + // enter children... + + const mode: {[uid:string]: Mode} = {}; + if (state.kind === "and") { + // enter every child + for (const child of state.children) { + let childMode; + if (rest.length > 0 && child.uid === rest[0].uid) { + ({mode: childMode, ...actionScope} = enterPath(rest, actionScope)); + } + else { + ({mode: childMode, ...actionScope} = enterDefault(child, actionScope)); + } + mode[child.uid] = childMode; + } + } + else if (state.kind === "or") { + if (rest.length > 0) { + let childMode; + ({mode: childMode, ...actionScope} = enterPath(rest, actionScope)); + mode[rest[0].uid] = childMode; + } + else { + // same as AND-state, but we only enter the initial state(s) + for (const [_, child] of state.initial) { + let childMode; + ({mode: childMode, ...actionScope} = enterDefault(child, actionScope)); + mode[child.uid] = childMode; + } + } + } + + return { mode, ...actionScope }; +} + +export function exitCurrent(state: ConcreteState, rt: EnteredScope): ActionScope { + let {mode, ...actionScope} = rt; // exit all active children... for (const [childUid, childMode] of Object.entries(mode)) { const child = state.children.find(child => child.uid === childUid); if (child) { - ({environment, raised} = exit(child, {mode: childMode, environment, raised})); + (actionScope = exitCurrent(child, {mode: childMode, ...actionScope})); } } // execute exit actions for (const action of state.exitActions) { - ({environment, raised} = execAction(action, {environment, raised})); + (actionScope = execAction(action, actionScope)); } - return {environment, raised}; + return actionScope; } export function execAction(action: Action, rt: ActionScope): ActionScope { @@ -87,19 +130,14 @@ export function execAction(action: Action, rt: ActionScope): ActionScope { // append to internal events return { ...rt, - raised: { - ...rt.raised, - internalEvents: [...rt.raised.internalEvents, action.event]}, + internalEvents: [...rt.internalEvents, action.event], }; } else { // append to output events return { ...rt, - raised: { - ...rt.raised, - outputEvents: [...rt.raised.outputEvents, action.event], - }, + outputEvents: [...rt.outputEvents, action.event], } } } @@ -142,10 +180,81 @@ export function evalExpr(expr: Expression, environment: Environment): any { return UNARY_OPERATOR_MAP.get(expr.operator)!(arg); } else if (expr.kind === "binaryExpr") { - const argA = evalExpr(expr.lhs, environment); - const argB = evalExpr(expr.rhs, environment); - return BINARY_OPERATOR_MAP.get(expr.operator)!(argA,argB); + const lhs = evalExpr(expr.lhs, environment); + const rhs = evalExpr(expr.rhs, environment); + return BINARY_OPERATOR_MAP.get(expr.operator)!(lhs,rhs); } throw new Error("should never reach here"); } +export function getActiveStates(mode: Mode): Set { + return new Set([].concat( + ...Object.entries(mode).map(([childUid, childMode]) => + [childUid, ...getActiveStates(childMode)]) + )); +} + +export function raiseEvent(event: string, statechart: Statechart, sourceState: ConcreteState, rt: RT_Statechart): RT_Statechart[] { + const activeStates = sourceState.children.filter(child => rt.mode.hasOwnProperty(child.uid)) + for (const state of activeStates) { + const outgoing = statechart.transitions.get(state.uid) || []; + const enabled = outgoing.filter(transition => transition.label[0].trigger.kind === "event" && transition.label[0].trigger.event === event); + const enabledGuard = enabled.filter(transition => + evalExpr(transition.label[0].guard, rt.environment) + ); + if (enabledGuard.length > 0) { + const newRts = enabledGuard.map(t => fireTransition(t, statechart, rt)); + return newRts; + } + else { + // no enabled outgoing transitions, try the children: + return raiseEvent(event, statechart, state, rt); + } + } + return []; +} + +function setModeDeep(oldMode: Mode, pathToState: ConcreteState[], newMode: Mode): Mode { + if (pathToState.length === 0) { + return newMode; + } + const [next, ...rest] = pathToState; + return { + ...oldMode, + [next.uid]: setModeDeep(oldMode[next.uid], rest, newMode), + } +} + +function unsetModeDeep(oldMode: Mode, pathToState: ConcreteState[]): Mode { + if (pathToState.length === 0) { + return {}; + } + if (pathToState.length === 1) { + const keyToDelete = pathToState[0].uid; + const newMode = {...oldMode}; // shallow copy + delete newMode[keyToDelete]; + return newMode; + } + const [next, ...rest] = pathToState; + return { + ...oldMode, + [next.uid]: unsetModeDeep(oldMode[next.uid], rest), + } +} + +export function fireTransition(t: Transition, statechart: Statechart, rt: RT_Statechart): RT_Statechart { + const {arena, srcPath, tgtPath} = computeArena(t); + const pathToArena = isAncestorOf({ancestor: statechart.root, descendant: arena}) as ConcreteState[]; + console.log('fire ', t.src.comments[0][1], '->', t.tgt.comments[0][1], {srcPath, tgtPath}); + let {environment, ...raised} = exitCurrent(srcPath[1], rt); + const exitedMode = unsetModeDeep(rt.mode, [...pathToArena.slice(1), ...srcPath.slice(1)]); + for (const action of t.label[0].actions) { + ({environment, ...raised} = execAction(action, {environment, ...raised})); + } + let deepMode; + ({mode: deepMode, environment, ...raised} = enterPath(tgtPath.slice(1), {environment, ...raised})); + // console.log('entered path:', tgtPath.slice(1), {deepMode}); + const enteredMode = setModeDeep(exitedMode, [...pathToArena.slice(1), ...tgtPath.slice(1)], deepMode); + // console.log('pathToArena:', pathToArena, 'newMode:', enteredMode); + return {mode: enteredMode, environment, inputEvents: rt.inputEvents, ...raised}; +} diff --git a/src/VisualEditor/parser.ts b/src/VisualEditor/parser.ts index b19b2ec..a9d08eb 100644 --- a/src/VisualEditor/parser.ts +++ b/src/VisualEditor/parser.ts @@ -18,6 +18,7 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string, comments: [], entryActions: [], exitActions: [], + depth: 0, } const uid2State = new Map([["root", root]]); @@ -54,10 +55,12 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string, const candidate = parentCandidates[i]; if (candidate.uid === "root" || isEntirelyWithin(rt, candidate)) { // found our parent :) - const parentState = uid2State.get(candidate.uid); - parentState!.children.push(state); + const parentState = uid2State.get(candidate.uid)!; + parentState.children.push(state); parentCandidates.push(rt); parentLinks.set(rt.uid, candidate.uid); + state.parent = parentState; + state.depth = parentState.depth+1; break; } } diff --git a/src/frontend.tsx b/src/frontend.tsx index 446e60e..e95c329 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -7,7 +7,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { App } from "./App"; +import { App } from "./App/App"; const elem = document.getElementById("root")!; const app = (