From db1479bfc40e43524d3f1e374eded38e6d0d7f9b Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Wed, 15 Oct 2025 14:09:45 +0200 Subject: [PATCH 1/2] usability improvements --- src/VisualEditor/VisualEditor.css | 25 ++++++--------- src/VisualEditor/VisualEditor.tsx | 53 +++++++++++++++++-------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index 7af5532..87e286b 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -3,6 +3,10 @@ background-color: #eee; } +.svgCanvas.dragging { + cursor: grabbing !important; +} + .svgCanvas.active { background-color: rgb(255, 140, 0, 0.2); } @@ -49,12 +53,6 @@ text.highlight { /* cursor: grab; */ } -.rountangle.dragging { - /* fill: lightgrey; */ - /* stroke-width: 4px; */ - cursor: grabbing; -} - .rountangle.selected { fill: rgba(0, 0, 255, 0.2); /* stroke: blue; @@ -70,7 +68,7 @@ text.highlight { stroke-width: 3px; } -.selected:hover { +.selected:hover:not(:active) { cursor: grab; } @@ -78,10 +76,10 @@ text.highlight { stroke: rgba(0, 0, 0, 0); stroke-width: 16px; } -.lineHelper:hover { +.lineHelper:hover:not(:active) { stroke: blue; stroke-opacity: 0.2; - /* cursor: grab; */ + cursor: grab; } .pathHelper { @@ -89,7 +87,7 @@ text.highlight { stroke: rgba(0, 0, 0, 0); stroke-width: 16px; } -.pathHelper:hover { +.pathHelper:hover:not(:active) { stroke: blue; stroke-opacity: 0.2; cursor: grab; @@ -99,10 +97,10 @@ text.highlight { .circleHelper { fill: rgba(0, 0, 0, 0); } -.circleHelper:hover { +.circleHelper:hover:not(:active) { fill: blue; fill-opacity: 0.2; - /* cursor: grab; */ + cursor: grab; } .rountangle.or { @@ -134,9 +132,6 @@ text.highlight { cursor: grab; } - - - line.selected, circle.selected { fill: rgba(0, 0, 255, 0.2); /* stroke-dasharray: 7 6; */ diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index 3f52bb8..9d7a788 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -147,14 +147,20 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor const compressedStateString = compressedStateBuffer.toBase64(); window.location.hash = "#"+compressedStateString; - const [statechart, errors] = parseStatechart(state); - // console.log('statechart: ', statechart, 'errors:', errors); - setErrors(errors); - setAST(statechart); - }, 100); + // const [statechart, errors] = parseStatechart(state); + // setErrors(errors); + // setAST(statechart); + }, 200); return () => clearTimeout(timeout); }, [state]); + useEffect(() => { + const [statechart, errors] = parseStatechart(state); + setErrors(errors); + setAST(statechart); + }, [state]) + + function getCurrentPointer(e: {pageX: number, pageY: number}) { const bbox = refSVG.current!.getBoundingClientRect(); return { @@ -232,26 +238,25 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor break; } } - // if (!allPartsInSelection) { - // setSelection([{uid, parts}] as Selection); - // } - - if (allPartsInSelection) { - // start dragging - setDragging({ - lastMousePos: currentPointer, - }); - return; + if (!allPartsInSelection) { + setSelection([{uid, parts}] as Selection); } + + // start dragging + setDragging({ + lastMousePos: currentPointer, + }); + return; } - // otherwise, just start making a selection - setDragging(null); - setSelectingState({ - topLeft: currentPointer, - size: {x: 0, y: 0}, - }); - setSelection([]); } + + // otherwise, just start making a selection + setDragging(null); + setSelectingState({ + topLeft: currentPointer, + size: {x: 0, y: 0}, + }); + setSelection([]); }; const onMouseMove = (e: {pageX: number, pageY: number}) => { @@ -680,7 +685,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); return e.preventDefault()} ref={refSVG} @@ -701,7 +706,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor - {(rootErrors.length>0) && {rootErrors.join(' ')}} + {(rootErrors.length>0) && {rootErrors.join(' ')}} {state.rountangles.map(rountangle => Date: Thu, 16 Oct 2025 17:10:37 +0200 Subject: [PATCH 2/2] pseudo-states appear to be working + variables only exist within the scope where they are created --- src/App/App.css | 31 +-- src/App/App.tsx | 14 +- src/App/BottomPanel.css | 3 + src/App/BottomPanel.tsx | 10 + src/App/{AST.tsx => ShowAST.tsx} | 8 +- src/App/TopPanel.tsx | 66 +++-- src/VisualEditor/ArrowSVG.tsx | 9 +- src/VisualEditor/DiamondSVG.tsx | 37 +++ src/VisualEditor/HistorySVG.tsx | 3 + src/VisualEditor/RectHelpers.tsx | 80 ++++++ src/VisualEditor/RountangleSVG.tsx | 112 +------- src/VisualEditor/TextSVG.tsx | 41 +++ src/VisualEditor/VisualEditor.css | 4 +- src/VisualEditor/VisualEditor.tsx | 307 +++++++++------------- src/statecharts/abstract_syntax.ts | 84 +++++- src/statecharts/actionlang_interpreter.ts | 5 + src/statecharts/concrete_syntax.ts | 13 +- src/statecharts/interpreter.ts | 123 +++++---- src/statecharts/label_ast.ts | 8 +- src/statecharts/label_parser.js | 230 ++++++++-------- src/statecharts/parser.ts | 115 +++++--- src/statecharts/transition_label.grammar | 8 +- 22 files changed, 742 insertions(+), 569 deletions(-) create mode 100644 src/App/BottomPanel.css create mode 100644 src/App/BottomPanel.tsx rename src/App/{AST.tsx => ShowAST.tsx} (82%) create mode 100644 src/VisualEditor/DiamondSVG.tsx create mode 100644 src/VisualEditor/HistorySVG.tsx create mode 100644 src/VisualEditor/RectHelpers.tsx create mode 100644 src/VisualEditor/TextSVG.tsx diff --git a/src/App/App.css b/src/App/App.css index edd85c6..1b05d57 100644 --- a/src/App/App.css +++ b/src/App/App.css @@ -1,28 +1,3 @@ -/* .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: 20; /* margin-left: 30; */ @@ -68,4 +43,10 @@ summary { } .toolbar > input { height: 20px; +} + +button.active { + border: solid blue 2px; + background-color: rgba(0,0,255,0.2); + color: black; } \ No newline at end of file diff --git a/src/App/App.tsx b/src/App/App.tsx index 41204f7..301ca65 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -12,9 +12,10 @@ import "./App.css"; import { Box, Stack } from "@mui/material"; import { TopPanel } from "./TopPanel"; import { RTHistory } from "./RTHistory"; -import { AST } from "./AST"; +import { ShowAST } from "./ShowAST"; import { TraceableError } from "../statecharts/parser"; import { getKeyHandler } from "./shortcut_handler"; +import { BottomPanel } from "./BottomPanel"; export function App() { const [mode, setMode] = useState("and"); @@ -106,7 +107,7 @@ export function App() { {...{ast, time, setTime, onInit, onClear, onRaise, mode, setMode}} /> - + {/* main */} @@ -117,14 +118,17 @@ export function App() { borderLeft: 1, borderColor: "divider", flex: '0 0 content', - paddingRight: 1, - paddingLeft: 1, + // paddingRight: 1, + // paddingLeft: 1, }}> - +
+ + + ; } diff --git a/src/App/BottomPanel.css b/src/App/BottomPanel.css new file mode 100644 index 0000000..ceb412e --- /dev/null +++ b/src/App/BottomPanel.css @@ -0,0 +1,3 @@ +.errorStatus { + color: rgb(230,0,0); +} \ No newline at end of file diff --git a/src/App/BottomPanel.tsx b/src/App/BottomPanel.tsx new file mode 100644 index 0000000..f7cde46 --- /dev/null +++ b/src/App/BottomPanel.tsx @@ -0,0 +1,10 @@ +import { TraceableError } from "../statecharts/parser"; + +import "./BottomPanel.css"; + +export function BottomPanel(props: {errors: TraceableError[]}) { + return
+
{ + props.errors.length>0 && <>{props.errors.length} errors {props.errors.map(({message})=>message).join(',')}}
+
; +} \ No newline at end of file diff --git a/src/App/AST.tsx b/src/App/ShowAST.tsx similarity index 82% rename from src/App/AST.tsx rename to src/App/ShowAST.tsx index 2de6e2b..fe21809 100644 --- a/src/App/AST.tsx +++ b/src/App/ShowAST.tsx @@ -1,4 +1,4 @@ -import { ConcreteState, stateDescription, Transition } from "../statecharts/abstract_syntax"; +import { ConcreteState, PseudoState, stateDescription, Transition } from "../statecharts/abstract_syntax"; import { Action, Expression } from "../statecharts/label_ast"; import { RT_Statechart } from "../statecharts/runtime_types"; @@ -32,7 +32,7 @@ export function ShowAction(props: {action: Action}) { } } -export function AST(props: {root: ConcreteState, transitions: Map, rt: RT_Statechart | undefined}) { +export function ShowAST(props: {root: ConcreteState | PseudoState, transitions: Map, rt: RT_Statechart | undefined}) { const description = stateDescription(props.root); const outgoing = props.transitions.get(props.root.uid) || []; @@ -49,9 +49,9 @@ export function AST(props: {root: ConcreteState, transitions: Map exit / ) } - {props.root.children.length>0 && + {props.root.kind !== "pseudo" && props.root.children.length>0 && props.root.children.map(child => - + ) } {outgoing.length>0 && diff --git a/src/App/TopPanel.tsx b/src/App/TopPanel.tsx index c6f66c4..0dc8fab 100644 --- a/src/App/TopPanel.tsx +++ b/src/App/TopPanel.tsx @@ -10,10 +10,11 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import BoltIcon from '@mui/icons-material/Bolt'; import SkipNextIcon from '@mui/icons-material/SkipNext'; import TrendingFlatIcon from '@mui/icons-material/TrendingFlat'; +import AccessAlarmIcon from '@mui/icons-material/AccessAlarm'; +import StopIcon from '@mui/icons-material/Stop'; import { formatTime } from "./util"; import { InsertMode } from "../VisualEditor/VisualEditor"; -import { DiamondShape } from "../VisualEditor/RountangleSVG"; export type TopPanelProps = { rt?: BigStep, @@ -33,16 +34,21 @@ function RountangleIcon(props: {kind: string}) { x={1} y={1} width={18} height={18} className={`rountangle ${props.kind}`} - style={props.kind === "or" ? {strokeDasharray: '3 2'}: {}} + style={{...(props.kind === "or" ? {strokeDasharray: '3 2'}: {}), strokeWidth: 1.2}} /> ; } function PseudoStateIcon(props: {}) { - return - - - + const w=20, h=20; + return + ; } @@ -101,28 +107,29 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast, mode return <>
- {([ - ["and", "AND-states", ], - ["or", "OR-states", ], - ["pseudo", "pseudo-states", ], - ["transition", "transitions", ], - ["text", "text", <>T], - ] as [InsertMode, string, ReactElement][]).map(([m, hint, buttonTxt]) => - )} -
-   -
- - + {([ + ["and", "AND-states", ], + ["or", "OR-states", ], + ["pseudo", "pseudo-states", ], + ["transition", "transitions", ], + ["text", "text", <> T ], + ] as [InsertMode, string, ReactElement][]).map(([m, hint, buttonTxt]) => + )}   - - + + + +   + + + {/* onChangePaused(newValue==="paused", performance.now())} size="small"> @@ -155,12 +162,15 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast, mode return {kind: "realtime", scale: time.scale, since: {simtime: nextTimedTransition[0], wallclktime: now}}; } }); - }}> + }}> + +   {ast.inputEvents && <> {ast.inputEvents.map(({event, paramName}) => - <> {paramName && <>})} + {paramName && <>} )} } diff --git a/src/VisualEditor/ArrowSVG.tsx b/src/VisualEditor/ArrowSVG.tsx index 44496c1..3f4cd6b 100644 --- a/src/VisualEditor/ArrowSVG.tsx +++ b/src/VisualEditor/ArrowSVG.tsx @@ -22,11 +22,16 @@ export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: stri data-uid={uid} data-parts="start end" /> - {props.errors.length > 0 && {props.errors.join(' ')}} + {props.errors.length > 0 && {props.errors.join(' ')}} ; +} + +export function DiamondSVG(props: { diamond: Diamond; selected: string[]; highlight: RountanglePart[]; errors: string[]; active: boolean; }) { + const minSize = rountangleMinSize(props.diamond.size); + const extraAttrs = { + className: '' + + (props.selected.length === 4 ? " selected" : "") + + (props.errors.length > 0 ? " error" : "") + + (props.active ? " active" : ""), + "data-uid": props.diamond.uid, + "data-parts": "left top right bottom", + }; + return + + + + ; +} diff --git a/src/VisualEditor/HistorySVG.tsx b/src/VisualEditor/HistorySVG.tsx new file mode 100644 index 0000000..4c5587e --- /dev/null +++ b/src/VisualEditor/HistorySVG.tsx @@ -0,0 +1,3 @@ +export function ShallowHistorySVG() { + +} \ No newline at end of file diff --git a/src/VisualEditor/RectHelpers.tsx b/src/VisualEditor/RectHelpers.tsx new file mode 100644 index 0000000..846dab7 --- /dev/null +++ b/src/VisualEditor/RectHelpers.tsx @@ -0,0 +1,80 @@ +import { RountanglePart } from "../statecharts/concrete_syntax"; +import { Vec2D } from "./geometry"; +import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "./parameters"; + +export function RectHelper(props: { uid: string, size: Vec2D, selected: string[], highlight: RountanglePart[] }) { + return <> + + + + + + + + + + {props.uid} + ; +} \ No newline at end of file diff --git a/src/VisualEditor/RountangleSVG.tsx b/src/VisualEditor/RountangleSVG.tsx index f19657d..f45ad2c 100644 --- a/src/VisualEditor/RountangleSVG.tsx +++ b/src/VisualEditor/RountangleSVG.tsx @@ -1,20 +1,8 @@ import { Rountangle, RountanglePart } from "../statecharts/concrete_syntax"; -import { Rect2D } from "./geometry"; -import { ROUNTANGLE_RADIUS, CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "./parameters"; +import { ROUNTANGLE_RADIUS } from "./parameters"; +import { RectHelper } from "./RectHelpers"; import { rountangleMinSize } from "./VisualEditor"; -export function DiamondShape(props: {geometry: Rect2D, extraAttrs: object}) { - const {geometry} = props; - return ; -} export function RountangleSVG(props: { rountangle: Rountangle; selected: string[]; highlight: RountanglePart[]; errors: string[]; active: boolean; }) { const { topLeft, size, uid } = props.rountangle; @@ -31,94 +19,20 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[ "data-parts": "left top right bottom", }; return - {props.rountangle.kind === "pseudo" ? - - : - } - + {(props.errors.length > 0) && {props.errors.join(' ')}} - - - - - - - - - - - {uid} + ; } diff --git a/src/VisualEditor/TextSVG.tsx b/src/VisualEditor/TextSVG.tsx new file mode 100644 index 0000000..d022eaa --- /dev/null +++ b/src/VisualEditor/TextSVG.tsx @@ -0,0 +1,41 @@ +import { TraceableError } from "..//statecharts/parser"; +import {Text} from "../statecharts/concrete_syntax"; + +export function TextSVG(props: {text: Text, error: TraceableError|undefined, selected: boolean, highlight: boolean, onEdit: (newText: string) => void}) { + const commonProps = { + "data-uid": props.text.uid, + "data-parts": "text", + textAnchor: "middle" as "middle", + className: + (props.selected ? "selected":"") + +(props.highlight ? " highlight":""), + } + + let textNode; + if (props.error?.data?.location) { + const {start,end} = props.error.data.location; + textNode = <> + {props.text.text.slice(0, start.offset)} + + {props.text.text.slice(start.offset, end.offset)} + {start.offset === end.offset && <>_} + + {props.text.text.slice(end.offset)} + + {props.error.message}; + } + else { + textNode = {props.text.text}; + } + + return { + const newText = prompt("", props.text.text); + if (newText) { + props.onEdit(newText); + } + }} + >{textNode}; +} \ No newline at end of file diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index 87e286b..27f6cf8 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -143,9 +143,9 @@ text.selected, text.selected:hover { fill: blue; font-weight: 600; } -text:hover { +text:hover:not(:active) { fill: blue; - /* cursor: grab; */ + cursor: grab; } .highlight { diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index 9d7a788..8ec51d0 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -2,7 +2,7 @@ import * as lz4 from "@nick/lz4"; import { Dispatch, SetStateAction, useEffect, useRef, useState, MouseEvent } from "react"; import { Statechart } from "../statecharts/abstract_syntax"; -import { Arrow, ArrowPart, Rountangle, RountanglePart, Text, VisualEditorState, emptyState, findNearestArrow, findNearestRountangleSide, findRountangle } from "../statecharts/concrete_syntax"; +import { Arrow, ArrowPart, Diamond, Rountangle, RountanglePart, Text, VisualEditorState, emptyState, findNearestArrow, findNearestSide, findRountangle } from "../statecharts/concrete_syntax"; import { parseStatechart, TraceableError } from "../statecharts/parser"; import { BigStep } from "../statecharts/runtime_types"; import { ArcDirection, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry"; @@ -12,6 +12,8 @@ import { getBBoxInSvgCoords } from "./svg_helper"; import "./VisualEditor.css"; import { ArrowSVG } from "./ArrowSVG"; import { RountangleSVG } from "./RountangleSVG"; +import { TextSVG } from "./TextSVG"; +import { DiamondSVG } from "./DiamondSVG"; type DraggingState = { @@ -115,7 +117,6 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor } const [dragging, setDragging] = useState(null); - const [showHelp, setShowHelp] = useState(false); // uid's of selected rountangles const [selection, setSelection] = useState([]); @@ -177,7 +178,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor // ignore selection, middle mouse button always inserts setState(state => { const newID = state.nextID.toString(); - if (mode === "and" || mode === "or" || mode === "pseudo") { + if (mode === "and" || mode === "or") { // insert rountangle setSelection([{uid: newID, parts: ["bottom", "right"]}]); return { @@ -191,6 +192,18 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor nextID: state.nextID+1, }; } + else if (mode === "pseudo") { + setSelection([{uid: newID, parts: ["bottom", "right"]}]); + return { + ...state, + diamonds: [...state.diamonds, { + uid: newID, + topLeft: currentPointer, + size: MIN_ROUNTANGLE_SIZE, + }], + nextID: state.nextID+1, + }; + } else if (mode === "transition") { setSelection([{uid: newID, parts: ["end"]}]); return { @@ -225,7 +238,9 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor if (e.button === 0) { // left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape. + // @ts-ignore const uid = e.target?.dataset.uid; + // @ts-ignore const parts: string[] = e.target?.dataset.parts?.split(' ') || []; if (uid) { checkPoint(); @@ -271,8 +286,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor return r; } return { - uid: r.uid, - kind: r.kind, + ...r, ...transformRect(r, parts, pointerDelta), }; }) @@ -283,7 +297,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor return a; } return { - uid: a.uid, + ...a, ...transformLine(a, parts, pointerDelta), } }), @@ -293,11 +307,20 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor return t; } return { - uid: t.uid, - text: t.text, + ...t, topLeft: addV2D(t.topLeft, pointerDelta), } }), + diamonds: state.diamonds.map(d => { + const parts = selection.find(selected => selected.uid === d.uid)?.parts || []; + if (parts.length === 0) { + return d; + } + return { + ...d, + ...transformRect(d, parts, pointerDelta), + } + }) })); setDragging({lastMousePos: currentPointer}); } @@ -312,7 +335,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor } }; - const onMouseUp = (e) => { + const onMouseUp = (e: {pageX: number, pageY: number}) => { if (dragging) { setDragging(null); // do not persist sizes smaller than 40x40 @@ -323,53 +346,37 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor ...r, size: rountangleMinSize(r.size), })), + diamonds: state.diamonds.map(d => ({ + ...d, + size: rountangleMinSize(d.size), + })) }; }); } if (selectingState) { // we were making a selection - if (selectingState.size.x === 0 && selectingState.size.y === 0) { - const uid = e.target?.dataset.uid; - const parts: string[] = e.target?.dataset.parts?.split(' ') || []; + const normalizedSS = normalizeRect(selectingState); + const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[]; + const shapesInSelection = shapes.filter(el => { + const bbox = getBBoxInSvgCoords(el, refSVG.current!); + return isEntirelyWithin(bbox, normalizedSS); + }).filter(el => !el.classList.contains("corner")); + + const uidToParts = new Map(); + for (const shape of shapesInSelection) { + const uid = shape.dataset.uid; if (uid) { - checkPoint(); - // @ts-ignore - setSelection(() => ([{uid, parts}])); - - // if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on - let allPartsInSelection = true; - for (const part of parts) { - if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) { - allPartsInSelection = false; - break; - } + const parts: Set = uidToParts.get(uid) || new Set(); + for (const part of shape.dataset.parts?.split(' ') || []) { + parts.add(part); } + uidToParts.set(uid, parts); } } - else { - const normalizedSS = normalizeRect(selectingState); - const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[]; - const shapesInSelection = shapes.filter(el => { - const bbox = getBBoxInSvgCoords(el, refSVG.current!); - return isEntirelyWithin(bbox, normalizedSS); - }).filter(el => !el.classList.contains("corner")); - - const uidToParts = new Map(); - for (const shape of shapesInSelection) { - const uid = shape.dataset.uid; - if (uid) { - const parts: Set = uidToParts.get(uid) || new Set(); - for (const part of shape.dataset.parts?.split(' ') || []) { - parts.add(part); - } - uidToParts.set(uid, parts); - } - } - setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({ - uid, - parts: [...parts], - }))); - } + setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({ + uid, + parts: [...parts], + }))); } setSelectingState(null); // no longer making a selection }; @@ -380,6 +387,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor rountangles: state.rountangles.filter(r => !selection.some(rs => rs.uid === r.uid)), arrows: state.arrows.filter(a => !selection.some(as => as.uid === a.uid)), texts: state.texts.filter(t => !selection.some(ts => ts.uid === t.uid)), + diamonds: state.diamonds.filter(d => !selection.some(ds => ds.uid === d.uid)), })); setSelection([]); } @@ -412,74 +420,17 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor return selection; }); } - if (e.key === "p") { - // selected states become pseudo-states - setSelection(selection => { - setState(state => ({ - ...state, - rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r), - })); - return selection; - }); - } - if (e.key === "h") { - setShowHelp(showHelp => !showHelp); - } + // if (e.key === "p") { + // // selected states become pseudo-states + // setSelection(selection => { + // setState(state => ({ + // ...state, + // rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r), + // })); + // return selection; + // }); + // } if (e.ctrlKey) { - // if (e.key === "c") { - // if (selection.length > 0) { - // e.preventDefault(); - // setClipboard(new Set(selection.map(shape => shape.uid))); - // console.log('set clipboard', new Set(selection.map(shape => shape.uid))); - // } - // } - // if (e.key === "v") { - // console.log('paste shortcut..', clipboard); - // if (clipboard.size > 0) { - // console.log('pasting...a'); - // e.preventDefault(); - // checkPoint(); - // const offset = {x: 40, y: 40}; - // const rountanglesToCopy = state.rountangles.filter(r => clipboard.has(r.uid)); - // const arrowsToCopy = state.arrows.filter(a => clipboard.has(a.uid)); - // const textsToCopy = state.texts.filter(t => clipboard.has(t.uid)); - // let nextUid = state.nextID; - // const rountanglesCopied: Rountangle[] = rountanglesToCopy.map(r => ({ - // ...r, - // uid: (nextUid++).toString(), - // topLeft: addV2D(r.topLeft, offset), - // })); - // const arrowsCopied: Arrow[] = arrowsToCopy.map(a => ({ - // ...a, - // uid: (nextUid++).toString(), - // start: addV2D(a.start, offset), - // end: addV2D(a.end, offset), - // })); - // const textsCopied: Text[] = textsToCopy.map(t => ({ - // ...t, - // uid: (nextUid++).toString(), - // topLeft: addV2D(t.topLeft, offset), - // })); - // setState(state => ({ - // ...state, - // rountangles: [...state.rountangles, ...rountanglesCopied], - // arrows: [...state.arrows, ...arrowsCopied], - // texts: [...state.texts, ...textsCopied], - // nextID: nextUid, - // })); - // setClipboard(new Set([ - // ...rountanglesCopied.map(r => r.uid), - // ...arrowsCopied.map(a => a.uid), - // ...textsCopied.map(t => t.uid), - // ])); - // // @ts-ignore - // setSelection([ - // ...rountanglesCopied.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})), - // ...arrowsCopied.map(a => ({uid: a.uid, parts: ["start", "end"]})), - // ...textsCopied.map(t => ({uid: t.uid, parts: ["text"]})), - // ]); - // } - // } if (e.key === "z") { e.preventDefault(); undo(); @@ -498,11 +449,6 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor ...state.texts.map(t => ({uid: t.uid, parts: ["text"]})), ]); } - - if (e.key === "c") { - // e.preventDefault(); - // setClipboard() - } } }; @@ -525,9 +471,12 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor const arrow2TextMap = new Map(); const text2RountangleMap = new Map(); const rountangle2TextMap = new Map(); + + // arrow <-> (rountangle | diamond) for (const arrow of state.arrows) { - const startSide = findNearestRountangleSide(arrow, "start", state.rountangles); - const endSide = findNearestRountangleSide(arrow, "end", state.rountangles); + const sides = [...state.rountangles, ...state.diamonds]; + const startSide = findNearestSide(arrow, "start", sides); + const endSide = findNearestSide(arrow, "end", sides); if (startSide || endSide) { arrow2SideMap.set(arrow.uid, [startSide, endSide]); } @@ -542,6 +491,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor side2ArrowMap.set(endSide.uid + '/' + endSide.part, arrowConns); } } + // text <-> arrow for (const text of state.texts) { const nearestArrow = findNearestArrow(text.topLeft, state.arrows); if (nearestArrow) { @@ -552,7 +502,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor arrow2TextMap.set(nearestArrow.uid, textsOfArrow); } else { - // no arrow, then the text belongs to the rountangle it is in + // text <-> rountangle const rountangle = findRountangle(text.topLeft, state.rountangles); if (rountangle) { text2RountangleMap.set(text.uid, rountangle.uid); @@ -677,9 +627,35 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor deleteShapes(selection); e.preventDefault(); } - } + function onEditText(text: Text, newText: string) { + if (newText === "") { + // delete text node + setState(state => ({ + ...state, + texts: state.texts.filter(t => t.uid !== text.uid), + })); + } + else { + setState(state => ({ + ...state, + texts: state.texts.map(t => { + if (t.uid === text.uid) { + return { + ...text, + text: newText, + } + } + else { + return t; + } + }), + })); + } + } + + const active = rt?.mode || new Set(); const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); @@ -689,8 +665,12 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor onMouseDown={onMouseDown} onContextMenu={e => e.preventDefault()} ref={refSVG} + + // @ts-ignore onCopy={onCopy} + // @ts-ignore onPaste={onPaste} + // @ts-ignore onCut={onCut} > @@ -699,9 +679,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor viewBox="0 0 10 10" refX="5" refY="5" - markerWidth="6" - markerHeight="6" - orient="auto-start-reverse"> + markerWidth="12" + markerHeight="12" + orient="auto-start-reverse" + markerUnits="userSpaceOnUse"> @@ -719,6 +700,18 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor active={active.has(rountangle.uid)} />)} + {state.diamonds.map(diamond => <> + r.uid === diamond.uid)?.parts || []} + highlight={[...(sidesToHighlight[diamond.uid] || []), ...(rountanglesToHighlight[diamond.uid]?["left","right","top","bottom"]:[]) as RountanglePart[]]} + errors={errors + .filter(({shapeUid}) => shapeUid === diamond.uid) + .map(({message}) => message)} + active={active.has(diamond.uid)}/> + )} + {state.arrows.map(arrow => { const sides = arrow2SideMap.get(arrow.uid); let arc = "no" as ArcDirection; @@ -739,60 +732,14 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor )} {state.texts.map(txt => { - const err = errors.find(({shapeUid}) => txt.uid === shapeUid); - const commonProps = { - "data-uid": txt.uid, - "data-parts": "text", - textAnchor: "middle" as "middle", - className: - (selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"") - +(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":""), - } - let textNode; - if (err?.data?.location) { - const {start,end} = err.data.location; - textNode = <> - {txt.text.slice(0, start.offset)} - - {txt.text.slice(start.offset, end.offset)} - {start.offset === end.offset && <>_} - - {txt.text.slice(end.offset)} - - {err.message}; - } - else { - textNode = {txt.text}; - } - return { - const newText = prompt("", txt.text); - if (newText) { - setState(state => ({ - ...state, - texts: state.texts.map(t => { - if (t.uid === txt.uid) { - return { - ...txt, - text: newText, - } - } - else { - return t; - } - }), - })); - } - else if (newText === "") { - setState(state => ({ - ...state, - texts: state.texts.filter(t => t.uid !== txt.uid), - })); - } - }} - >{textNode};})} + return txt.uid === shapeUid)} + text={txt} + selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)} + highlight={textsToHighlight.hasOwnProperty(txt.uid)} + onEdit={newText => onEditText(txt, newText)} + /> + })} {selectingState && } ; diff --git a/src/statecharts/abstract_syntax.ts b/src/statecharts/abstract_syntax.ts index f854027..805ce27 100644 --- a/src/statecharts/abstract_syntax.ts +++ b/src/statecharts/abstract_syntax.ts @@ -1,33 +1,43 @@ -import { Action, EventTrigger, ParsedText, TransitionLabel } from "./label_ast"; +import { Action, EventTrigger, ParsedText } 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; - timers: number[]; // list of timeouts (e.g., the state having an outgoing transition with trigger "after 4s" would appear as the number 4000 in this list) } +export type StableState = { + kind: "and" | "or"; + children: ConcreteState[]; + timers: number[]; // list of timeouts (e.g., the state having an outgoing transition with trigger "after 4s" would appear as the number 4000 in this list) +} & AbstractState; + export type AndState = { kind: "and"; -} & AbstractState; +} & StableState; export type OrState = { kind: "or"; // array of tuples: (uid of Arrow indicating initial state, initial state) // in a valid AST, there must be one initial state, but we allow the user to draw crazy shit initial: [string, ConcreteState][]; -} & AbstractState; +} & StableState; + +export type PseudoState = { + kind: "pseudo"; + uid: string; + comments: [string, string][]; +}; export type ConcreteState = AndState | OrState; export type Transition = { - uid: string; - src: ConcreteState; - tgt: ConcreteState; + uid: string; // uid of arrow in concrete syntax + src: ConcreteState | PseudoState; + tgt: ConcreteState | PseudoState; label: ParsedText[]; } @@ -41,7 +51,7 @@ export type Statechart = { internalEvents: EventTrigger[]; outputEvents: Set; - uid2State: Map; + uid2State: Map; } const emptyRoot: OrState = { @@ -87,6 +97,57 @@ export function isOverlapping(a: ConcreteState, b: ConcreteState): boolean { } } + +export function computeLCA(a: ConcreteState, b: ConcreteState): ConcreteState { + if (a === b) { + return a; + } + if (a.depth > b.depth) { + return computeLCA(a.parent!, b); + } + return computeLCA(a, b.parent!); +} + +export function computeLCA2(states: ConcreteState[]): ConcreteState { + if (states.length === 0) { + throw new Error("cannot compute LCA of empty set of states"); + } + if (states.length === 1) { + return states[0]; + } + // 2 states or more + return states.reduce((acc, cur) => computeLCA(acc, cur)); +} + +export function getPossibleTargets(t: Transition, ts: Map): ConcreteState[] { + if (t.tgt.kind !== "pseudo") { + return [t.tgt]; + } + const pseudoOutgoing = ts.get(t.tgt.uid) || []; + return pseudoOutgoing.flatMap(t => getPossibleTargets(t, ts)); +} + +export function computeArena2(t: Transition, ts: Map): OrState { + const tgts = getPossibleTargets(t, ts); + let lca = computeLCA2([t.src as ConcreteState, ...tgts]); + while (lca.kind !== "or") { + lca = lca.parent!; + } + return lca as OrState; +} + +// Assuming ancestor is already entered, what states to enter in order to enter descendants? +// E.g. +// root > A > B > C > D +// computePath({ancestor: A, descendant: A}) = [] +// computePath({ancestor: A, descendant: C}) = [B, C] +export function computePath({ancestor, descendant}: {ancestor: ConcreteState, descendant: ConcreteState}): ConcreteState[] { + if (ancestor === descendant) { + return []; + } + return [...computePath({ancestor, descendant: descendant.parent!}), 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}): { @@ -98,7 +159,7 @@ export function computeArena({src, tgt}: {src: ConcreteState, tgt: ConcreteState const path = isAncestorOf({descendant: src, ancestor: tgt}); if (path) { if (tgt.kind === "or") { - return {arena: tgt, srcPath: path, tgtPath: [tgt]}; + return {arena: tgt as OrState, srcPath: path, tgtPath: [tgt]}; } } // keep looking @@ -126,8 +187,7 @@ export function getDescendants(state: ConcreteState): Set { // the 'description' of a state is a human-readable string that (hopefully) identifies the state. // if the state contains a comment, we take the 'first' (= visually topmost) comment // otherwise we fall back to the state's UID. -export function stateDescription(state: ConcreteState) { +export function stateDescription(state: ConcreteState | PseudoState) { const description = state.comments.length > 0 ? state.comments[0][1] : state.uid; return description; } - diff --git a/src/statecharts/actionlang_interpreter.ts b/src/statecharts/actionlang_interpreter.ts index d8247d9..7b8e8ad 100644 --- a/src/statecharts/actionlang_interpreter.ts +++ b/src/statecharts/actionlang_interpreter.ts @@ -42,5 +42,10 @@ export function evalExpr(expr: Expression, environment: Environment): any { const rhs = evalExpr(expr.rhs, environment); return BINARY_OPERATOR_MAP.get(expr.operator)!(lhs, rhs); } + else if (expr.kind === "call") { + const fn = evalExpr(expr.fn, environment); + const param = evalExpr(expr.param, environment); + return fn(param); + } throw new Error("should never reach here"); } diff --git a/src/statecharts/concrete_syntax.ts b/src/statecharts/concrete_syntax.ts index 62f7bc3..9f59385 100644 --- a/src/statecharts/concrete_syntax.ts +++ b/src/statecharts/concrete_syntax.ts @@ -4,7 +4,11 @@ import { sides } from "../VisualEditor/VisualEditor"; export type Rountangle = { uid: string; - kind: "and" | "or" | "pseudo"; + kind: "and" | "or"; +} & Rect2D; + +export type Diamond = { + uid: string; } & Rect2D; export type Text = { @@ -21,6 +25,7 @@ export type VisualEditorState = { rountangles: Rountangle[]; texts: Text[]; arrows: Arrow[]; + diamonds: Diamond[]; nextID: number; }; @@ -28,8 +33,8 @@ export type VisualEditorState = { export type RountanglePart = "left" | "top" | "right" | "bottom"; export type ArrowPart = "start" | "end"; -export const emptyState = { - rountangles: [], texts: [], arrows: [], nextID: 0, +export const emptyState: VisualEditorState = { + rountangles: [], texts: [], arrows: [], diamonds: [], nextID: 0, }; export const onOffStateMachine = { @@ -45,7 +50,7 @@ export const onOffStateMachine = { }; // used to find which rountangle an arrow connects to (src/tgt) -export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "end", candidates: Rountangle[]): {uid: string, part: RountanglePart} | undefined { +export function findNearestSide(arrow: Line2D, arrowPart: "start" | "end", candidates: (Rountangle|Diamond)[]): {uid: string, part: RountanglePart} | undefined { let best = Infinity; let bestSide: undefined | {uid: string, part: RountanglePart}; for (const rountangle of candidates) { diff --git a/src/statecharts/interpreter.ts b/src/statecharts/interpreter.ts index a024cea..556b0ef 100644 --- a/src/statecharts/interpreter.ts +++ b/src/statecharts/interpreter.ts @@ -1,11 +1,11 @@ +import { computeArena2, computePath, ConcreteState, getDescendants, isOverlapping, OrState, StableState, Statechart, stateDescription, Transition } from "./abstract_syntax"; import { evalExpr } from "./actionlang_interpreter"; -import { computeArena, ConcreteState, getDescendants, isOverlapping, OrState, Statechart, stateDescription, Transition } from "./abstract_syntax"; -import { Action, AfterTrigger, EventTrigger } from "./label_ast"; -import { Environment, RaisedEvents, Mode, RT_Statechart, initialRaised, BigStepOutput, Timers, RT_Event, TimerElapseEvent } from "./runtime_types"; +import { Action, EventTrigger, TransitionLabel } from "./label_ast"; +import { BigStepOutput, Environment, initialRaised, Mode, RaisedEvents, RT_Event, RT_Statechart, TimerElapseEvent, Timers } from "./runtime_types"; export function initialize(ast: Statechart): BigStepOutput { let {enteredStates, environment, ...raised} = enterDefault(0, ast.root, { - environment: new Environment(), + environment: new Environment([new Map([["_timers", []]])]), ...initialRaised, }); return handleInternalEvents(0, ast, {mode: enteredStates, environment, ...raised}); @@ -18,12 +18,15 @@ type ActionScope = { type EnteredScope = { enteredStates: Mode } & ActionScope; export function entryActions(simtime: number, state: ConcreteState, actionScope: ActionScope): ActionScope { + // console.log('enter', stateDescription(state), '...'); + let {environment, ...rest} = actionScope; + environment = environment.pushScope(); for (const action of state.entryActions) { - (actionScope = execAction(action, actionScope)); + ({environment, ...rest} = execAction(action, {environment, ...rest})); } // schedule timers // we store timers in the environment (dirty!) - let environment = actionScope.environment.transform("_timers", oldTimers => { + environment = environment.transform("_timers", oldTimers => { const newTimers = [ ...oldTimers, ...state.timers.map(timeOffset => { @@ -35,20 +38,21 @@ export function entryActions(simtime: number, state: ConcreteState, actionScope: return newTimers; }, []); // new nested scope - environment = environment.pushScope(); - return {...actionScope, environment}; + return {environment, ...rest}; } export function exitActions(simtime: number, state: ConcreteState, actionScope: ActionScope): ActionScope { + // console.log('exit', stateDescription(state), '...'); for (const action of state.exitActions) { (actionScope = execAction(action, actionScope)); } - let environment = actionScope.environment.popScope(); + let environment = actionScope.environment; // cancel timers environment = environment.transform("_timers", oldTimers => { // remove all timers of 'state': return oldTimers.filter(([_, {state: s}]) => s !== state.uid); }, []); + environment = environment.popScope(); return {...actionScope, environment}; } @@ -193,42 +197,42 @@ export function execAction(action: Action, rt: ActionScope): ActionScope { throw new Error("should never reach here"); } -export function handleEvent(simtime: number, event: RT_Event, statechart: Statechart, activeParent: ConcreteState, {environment, mode, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { +export function handleEvent(simtime: number, event: RT_Event, statechart: Statechart, activeParent: StableState, {environment, mode, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { const arenasFired = new Set(); for (const state of activeParent.children) { if (mode.has(state.uid)) { const outgoing = statechart.transitions.get(state.uid) || []; + const labels = outgoing.flatMap(t => + t.label + .filter(l => l.kind === "transitionLabel") + .map(l => [t,l] as [Transition, TransitionLabel])); let triggered; if (event.kind === "input") { // get transitions triggered by event - triggered = outgoing.filter(transition => { - const trigger = transition.label[0].trigger; - if (trigger.kind === "event") { - return trigger.event === event.name; - } - return false; - }); + triggered = labels.filter(([_t,l]) => + l.trigger.kind === "event" && l.trigger.event === event.name); } else { // get transitions triggered by timeout - triggered = outgoing.filter(transition => { - const trigger = transition.label[0].trigger; - if (trigger.kind === "after") { - return trigger.durationMs === event.timeDurMs; - } - return false; - }); + triggered = labels.filter(([_t,l]) => + l.trigger.kind === "after" && l.trigger.durationMs === event.timeDurMs); } // eval guard - const enabled = triggered.filter(transition => - evalExpr(transition.label[0].guard, environment) - ); + const guardEnvironment = environment.set("inState", (stateLabel: string) => { + for (const [uid, state] of statechart.uid2State.entries()) { + if (stateDescription(state) === stateLabel) { + return (mode.has(uid)); + } + } + }); + const enabled = triggered.filter(([t,l]) => + evalExpr(l.guard, guardEnvironment)); if (enabled.length > 0) { if (enabled.length > 1) { console.warn('nondeterminism!!!!'); } - const t = enabled[0]; - const {arena, srcPath, tgtPath} = computeArena(t); + const [t,l] = enabled[0]; // just pick one transition + const arena = computeArena2(t, statechart.transitions); let overlapping = false; for (const alreadyFired of arenasFired) { if (isOverlapping(arena, alreadyFired)) { @@ -236,20 +240,18 @@ export function handleEvent(simtime: number, event: RT_Event, statechart: Statec } } if (!overlapping) { - let oldValue; if (event.kind === "input" && event.param !== undefined) { // input events may have a parameter // add event parameter to environment in new scope environment = environment.pushScope(); environment = environment.newVar( - (t.label[0].trigger as EventTrigger).paramName as string, + (l.trigger as EventTrigger).paramName as string, event.param, ); } - ({mode, environment, ...raised} = fireTransition(simtime, t, arena, srcPath, tgtPath, {mode, environment, ...raised})); - if (event.kind === "input" && event.param) { + ({mode, environment, ...raised} = fireTransition2(simtime, t, statechart.transitions, l, arena, {mode, environment, ...raised})); + if (event.kind === "input" && event.param !== undefined) { environment = environment.popScope(); - // console.log('restored environment:', environment); } arenasFired.add(arena); } @@ -288,27 +290,54 @@ function transitionDescription(t: Transition) { return stateDescription(t.src) + ' ➔ ' + stateDescription(t.tgt); } -export function fireTransition(simtime: number, t: Transition, arena: OrState, srcPath: ConcreteState[], tgtPath: ConcreteState[], {mode, environment, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { +export function fireTransition2(simtime: number, t: Transition, ts: Map, label: TransitionLabel, arena: OrState, {mode, environment, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { + console.log('fire', transitionDescription(t)); - console.log('fire ', transitionDescription(t), {arena, srcPath, tgtPath}); + const srcPath = computePath({ancestor: arena, descendant: t.src as ConcreteState}).reverse(); - // exit src - console.log('exit src...'); - ({environment, ...raised} = exitPath(simtime, srcPath.slice(1), {environment, enteredStates: mode, ...raised})); + // exit src and other states up to arena + ({environment, ...raised} = exitPath(simtime, srcPath, {environment, enteredStates: mode, ...raised})); const toExit = getDescendants(arena); toExit.delete(arena.uid); // do not exit the arena itself - const exitedMode = mode.difference(toExit); + const exitedMode = mode.difference(toExit); // active states after exiting the states we need to exit + // console.log({exitedMode}); + + return fireSecondHalfOfTransition(simtime, t, ts, label, arena, {mode: exitedMode, environment, ...raised}); +} + +// assuming we've already exited the source state of the transition, now enter the target state +// IF however, the target is a pseudo-state, DON'T enter it (pseudo-states are NOT states), instead fire the first pseudo-outgoing transition. +export function fireSecondHalfOfTransition(simtime: number, t: Transition, ts: Map, label: TransitionLabel, arena: OrState, {mode, environment, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { // exec transition actions - for (const action of t.label[0].actions) { + for (const action of label.actions) { ({environment, ...raised} = execAction(action, {environment, ...raised})); } - // enter tgt - console.log('enter tgt...'); - let enteredStates; - ({enteredStates, environment, ...raised} = enterPath(simtime, tgtPath.slice(1), {environment, ...raised})); - const enteredMode = exitedMode.union(enteredStates); + if (t.tgt.kind === "pseudo") { + const outgoing = ts.get(t.tgt.uid) || []; + for (const nextT of outgoing) { + for (const nextLabel of nextT.label) { + if (nextLabel.kind === "transitionLabel") { + if (evalExpr(nextLabel.guard, environment)) { + console.log('fire', transitionDescription(nextT)); + // found ourselves an enabled transition + return fireSecondHalfOfTransition(simtime, nextT, ts, nextLabel, arena, {mode, environment, ...raised}); + } + } + } + } + throw new Error("stuck in pseudo-state!!") + } + else { + const tgtPath = computePath({ancestor: arena, descendant: t.tgt}); + // enter tgt + let enteredStates; + ({enteredStates, environment, ...raised} = enterPath(simtime, tgtPath, {environment, ...raised})); + const enteredMode = mode.union(enteredStates); - return {mode: enteredMode, environment, ...raised}; + // console.log({enteredMode}); + + return {mode: enteredMode, environment, ...raised}; + } } diff --git a/src/statecharts/label_ast.ts b/src/statecharts/label_ast.ts index d3b8a54..257792e 100644 --- a/src/statecharts/label_ast.ts +++ b/src/statecharts/label_ast.ts @@ -19,7 +19,11 @@ export type ParserError = { uid: string; // uid of the text node } -export type Trigger = EventTrigger | AfterTrigger | EntryTrigger | ExitTrigger; +export type Trigger = TriggerLess | EventTrigger | AfterTrigger | EntryTrigger | ExitTrigger; + +export type TriggerLess = { + kind: "triggerless"; +} export type EventTrigger = { kind: "event"; @@ -55,7 +59,7 @@ export type RaiseEvent = { } -export type Expression = BinaryExpression | UnaryExpression | VarRef | Literal; +export type Expression = BinaryExpression | UnaryExpression | VarRef | Literal | FunctionCall; export type BinaryExpression = { kind: "binaryExpr"; diff --git a/src/statecharts/label_parser.js b/src/statecharts/label_parser.js index 79d6b95..9093146 100644 --- a/src/statecharts/label_parser.js +++ b/src/statecharts/label_parser.js @@ -211,9 +211,9 @@ function peg$parse(input, options) { const peg$e13 = peg$classExpectation([["0", "9"]], false, false, false); const peg$e14 = peg$literalExpectation("==", false); const peg$e15 = peg$literalExpectation("!=", false); - const peg$e16 = peg$classExpectation(["<", ">"], false, false, false); - const peg$e17 = peg$literalExpectation("<=", false); - const peg$e18 = peg$literalExpectation(">=", false); + const peg$e16 = peg$literalExpectation("<=", false); + const peg$e17 = peg$literalExpectation(">=", false); + const peg$e18 = peg$classExpectation(["<", ">"], false, false, false); const peg$e19 = peg$classExpectation(["+", "-"], false, false, false); const peg$e20 = peg$classExpectation(["*", "/"], false, false, false); const peg$e21 = peg$literalExpectation("true", false); @@ -229,7 +229,7 @@ function peg$parse(input, options) { function peg$f0(trigger, guard, actions) { return { kind: "transitionLabel", - trigger, + trigger: trigger ? trigger : {kind: "triggerless"}, guard: guard ? guard[2] : {kind: "literal", value: true}, actions: actions ? actions[2] : [], }; @@ -502,9 +502,9 @@ function peg$parse(input, options) { function peg$parsestart() { let s0; - s0 = peg$parsetlabel(); + s0 = peg$parsecomment(); if (s0 === peg$FAILED) { - s0 = peg$parsecomment(); + s0 = peg$parsetlabel(); } return s0; @@ -516,35 +516,33 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); s2 = peg$parsetrigger(); - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 91) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - s7 = peg$parsecompare(); - if (s7 !== peg$FAILED) { - s8 = peg$parse_(); - if (input.charCodeAt(peg$currPos) === 93) { - s9 = peg$c1; - peg$currPos++; - } else { - s9 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - if (s9 !== peg$FAILED) { - s5 = [s5, s6, s7, s8, s9]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 91) { + s5 = peg$c0; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + s7 = peg$parsecompare(); + if (s7 !== peg$FAILED) { + s8 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 93) { + s9 = peg$c1; + peg$currPos++; + } else { + s9 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s9 !== peg$FAILED) { + s5 = [s5, s6, s7, s8, s9]; + s4 = s5; } else { peg$currPos = s4; s4 = peg$FAILED; @@ -553,42 +551,42 @@ function peg$parse(input, options) { peg$currPos = s4; s4 = peg$FAILED; } - if (s4 === peg$FAILED) { - s4 = null; - } - s5 = peg$parse_(); - s6 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 47) { - s7 = peg$c2; - peg$currPos++; - } else { - s7 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e2); } - } - if (s7 !== peg$FAILED) { - s8 = peg$parse_(); - s9 = peg$parseactions(); - if (s9 !== peg$FAILED) { - s7 = [s7, s8, s9]; - s6 = s7; - } else { - peg$currPos = s6; - s6 = peg$FAILED; - } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + if (s4 === peg$FAILED) { + s4 = null; + } + s5 = peg$parse_(); + s6 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 47) { + s7 = peg$c2; + peg$currPos++; + } else { + s7 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s7 !== peg$FAILED) { + s8 = peg$parse_(); + s9 = peg$parseactions(); + if (s9 !== peg$FAILED) { + s7 = [s7, s8, s9]; + s6 = s7; } else { peg$currPos = s6; s6 = peg$FAILED; } - if (s6 === peg$FAILED) { - s6 = null; - } - s7 = peg$parse_(); - peg$savedPos = s0; - s0 = peg$f0(s2, s4, s6); } else { - peg$currPos = s0; - s0 = peg$FAILED; + peg$currPos = s6; + s6 = peg$FAILED; } + if (s6 === peg$FAILED) { + s6 = null; + } + s7 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f0(s2, s4, s6); return s0; } @@ -1002,25 +1000,25 @@ function peg$parse(input, options) { if (peg$silentFails === 0) { peg$fail(peg$e15); } } if (s5 === peg$FAILED) { - s5 = input.charAt(peg$currPos); - if (peg$r2.test(s5)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c14) { + s5 = peg$c14; + peg$currPos += 2; } else { s5 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e16); } } if (s5 === peg$FAILED) { - if (input.substr(peg$currPos, 2) === peg$c14) { - s5 = peg$c14; + if (input.substr(peg$currPos, 2) === peg$c15) { + s5 = peg$c15; peg$currPos += 2; } else { s5 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e17); } } if (s5 === peg$FAILED) { - if (input.substr(peg$currPos, 2) === peg$c15) { - s5 = peg$c15; - peg$currPos += 2; + s5 = input.charAt(peg$currPos); + if (peg$r2.test(s5)) { + peg$currPos++; } else { s5 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e18); } @@ -1474,8 +1472,16 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { s2 = peg$parse_(); - if (s2 !== peg$FAILED) { - s3 = []; + s3 = []; + if (input.length > peg$currPos) { + s4 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + while (s4 !== peg$FAILED) { + s3.push(s4); if (input.length > peg$currPos) { s4 = input.charAt(peg$currPos); peg$currPos++; @@ -1483,54 +1489,36 @@ function peg$parse(input, options) { s4 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e28); } } - while (s4 !== peg$FAILED) { - s3.push(s4); - if (input.length > peg$currPos) { - s4 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } - } - } - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 10) { - s5 = peg$c21; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } - } - if (s5 === peg$FAILED) { - s5 = peg$currPos; - peg$silentFails++; - if (input.length > peg$currPos) { - s6 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } - } - peg$silentFails--; - if (s6 === peg$FAILED) { - s5 = undefined; - } else { - peg$currPos = s5; - s5 = peg$FAILED; - } - } - if (s5 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f22(s3); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } + } + s4 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 10) { + s5 = peg$c21; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + } + if (s5 === peg$FAILED) { + s5 = peg$currPos; + peg$silentFails++; + if (input.length > peg$currPos) { + s6 = input.charAt(peg$currPos); + peg$currPos++; } else { - peg$currPos = s0; - s0 = peg$FAILED; + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } } + peg$silentFails--; + if (s6 === peg$FAILED) { + s5 = undefined; + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } + if (s5 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f22(s3); } else { peg$currPos = s0; s0 = peg$FAILED; diff --git a/src/statecharts/parser.ts b/src/statecharts/parser.ts index a133831..8fe35f0 100644 --- a/src/statecharts/parser.ts +++ b/src/statecharts/parser.ts @@ -1,5 +1,5 @@ -import { ConcreteState, OrState, Statechart, Transition } from "./abstract_syntax"; -import { findNearestArrow, findNearestRountangleSide, findRountangle, Rountangle, VisualEditorState } from "./concrete_syntax"; +import { AbstractState, ConcreteState, OrState, PseudoState, Statechart, Transition } from "./abstract_syntax"; +import { findNearestArrow, findNearestSide, findRountangle, Rountangle, VisualEditorState } from "./concrete_syntax"; import { isEntirelyWithin } from "../VisualEditor/geometry"; import { Action, EventTrigger, Expression, ParsedText } from "./label_ast"; @@ -45,7 +45,7 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl timers: [], } - const uid2State = new Map([["root", root]]); + const uid2State = new Map([["root", root]]); // we will always look for the smallest parent rountangle const parentCandidates: Rountangle[] = [{ @@ -59,37 +59,59 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl // step 1: figure out state hierarchy - // we assume that the rountangles are sorted from big to small: + // IMPORTANT ASSUMPTION: state.rountangles is sorted from big to small surface area: for (const rt of state.rountangles) { - // @ts-ignore - const state: ConcreteState = { + const common = { kind: rt.kind, uid: rt.uid, - children: [], comments: [], entryActions: [], exitActions: [], - timers: [], }; - if (state.kind === "or") { - (state as unknown as OrState).initial = []; + + let state; + if (rt.kind === "or") { + state = { + ...common, + initial: [], + children: [], + timers: [], + }; + } + else if (rt.kind === "and") { + state = { + ...common, + children: [], + timers: [], + }; } - uid2State.set(rt.uid, (state)); // iterate in reverse: for (let i=parentCandidates.length-1; i>=0; i--) { const candidate = parentCandidates[i]; if (candidate.uid === "root" || isEntirelyWithin(rt, candidate)) { - // found our parent :) - const parentState = uid2State.get(candidate.uid)!; + // found our parent + const parentState = uid2State.get(candidate.uid)! as ConcreteState; parentState.children.push(state as unknown as ConcreteState); parentCandidates.push(rt); parentLinks.set(rt.uid, candidate.uid); - state.parent = parentState; - state.depth = parentState.depth+1; + state = { + ...state, + parent: parentState, + depth: parentState.depth + 1, + } break; } } + uid2State.set(rt.uid, state as ConcreteState); + } + + for (const d of state.diamonds) { + uid2State.set(d.uid, { + kind: "pseudo", + uid: d.uid, + comments: [], + }); } // step 2: figure out transitions @@ -98,27 +120,37 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl const uid2Transition = new Map(); for (const arr of state.arrows) { - const srcUID = findNearestRountangleSide(arr, "start", state.rountangles)?.uid; - const tgtUID = findNearestRountangleSide(arr, "end", state.rountangles)?.uid; + const sides = [...state.rountangles, ...state.diamonds]; + const srcUID = findNearestSide(arr, "start", sides)?.uid; + const tgtUID = findNearestSide(arr, "end", sides)?.uid; if (!srcUID) { if (!tgtUID) { - // dangling edge - todo: display error... + // dangling edge errors.push({shapeUid: arr.uid, message: "dangling"}); } else { // target but no source, so we treat is as an 'initial' marking - const initialState = uid2State.get(tgtUID)!; - const ofState = uid2State.get(parentLinks.get(tgtUID)!)!; - if (ofState.kind === "or") { - ofState.initial.push([arr.uid, initialState]); - } - else { - // and states do not have an 'initial' state - todo: display error... + const tgtState = uid2State.get(tgtUID)!; + if (tgtState.kind === "pseudo") { + // maybe allow this in the future? errors.push({ shapeUid: arr.uid, - message: "AND-state cannot have an initial state", + message: "pseudo-state cannot be initial state", }); } + else { + const ofState = uid2State.get(parentLinks.get(tgtUID)!)!; + if (ofState.kind === "or") { + ofState.initial.push([arr.uid, tgtState]); + } + else { + // and states do not have an 'initial' state + errors.push({ + shapeUid: arr.uid, + message: "AND-state cannot have an initial state", + }); + } + } } } else { @@ -194,26 +226,42 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl if (belongsToArrow) { const belongsToTransition = uid2Transition.get(belongsToArrow.uid); if (belongsToTransition) { + const {src} = belongsToTransition; belongsToTransition.label.push(parsed); if (parsed.kind === "transitionLabel") { // collect events // triggers if (parsed.trigger.kind === "event") { - const {event} = parsed.trigger; - if (event.startsWith("_")) { - errors.push(...addEvent(internalEvents, parsed.trigger, parsed.uid)); + if (src.kind === "pseudo") { + errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have event trigger"}); } else { - errors.push(...addEvent(inputEvents, parsed.trigger, parsed.uid)); + const {event} = parsed.trigger; + if (event.startsWith("_")) { + errors.push(...addEvent(internalEvents, parsed.trigger, parsed.uid)); + } + else { + errors.push(...addEvent(inputEvents, parsed.trigger, parsed.uid)); + } } } else if (parsed.trigger.kind === "after") { - belongsToTransition.src.timers.push(parsed.trigger.durationMs); - belongsToTransition.src.timers.sort(); + if (src.kind === "pseudo") { + errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have after-trigger"}); + } + else { + src.timers.push(parsed.trigger.durationMs); + src.timers.sort(); + } } else if (["entry", "exit"].includes(parsed.trigger.kind)) { errors.push({shapeUid: text.uid, message: "entry/exit trigger not allowed on transitions"}); } + else if (parsed.trigger.kind === "triggerless") { + if (src.kind !== "pseudo") { + errors.push({shapeUid: text.uid, message: "triggerless transitions only allowed on pseudo-states"}); + } + } // // raise-actions // for (const action of parsed.actions) { @@ -240,7 +288,7 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl // text does not belong to transition... // so it belongs to a rountangle (a state) const rountangle = findRountangle(text.topLeft, state.rountangles); - const belongsToState = rountangle ? uid2State.get(rountangle.uid)! : root; + const belongsToState = rountangle ? uid2State.get(rountangle.uid)! as ConcreteState : root; if (parsed.kind === "transitionLabel") { // labels belonging to a rountangle (= a state) must by entry/exit actions // if we cannot find a containing state, then it belong to the root @@ -257,7 +305,6 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl data: {start: {offset: 0}, end: {offset: text.text.length}}, }); } - } else if (parsed.kind === "comment") { // just append comments to their respective states diff --git a/src/statecharts/transition_label.grammar b/src/statecharts/transition_label.grammar index 9492371..b57faf6 100644 --- a/src/statecharts/transition_label.grammar +++ b/src/statecharts/transition_label.grammar @@ -1,9 +1,9 @@ -start = tlabel / comment +start = comment / tlabel -tlabel = _ trigger:trigger _ guard:("[" _ guard _ "]")? _ actions:("/" _ actions )? _ { +tlabel = _ trigger:trigger? _ guard:("[" _ guard _ "]")? _ actions:("/" _ actions )? _ { return { kind: "transitionLabel", - trigger, + trigger: trigger ? trigger : {kind: "triggerless"}, guard: guard ? guard[2] : {kind: "literal", value: true}, actions: actions ? actions[2] : [], }; @@ -57,7 +57,7 @@ number = [0-9]+ { expr = compare -compare = sum:sum rest:((_ ("==" / "!=" / "<" / ">" / "<=" / ">=") _) compare)? { +compare = sum:sum rest:((_ ("==" / "!=" / "<=" / ">=" / "<" / ">") _) compare)? { if (rest === null) { return sum; }