From ec49c47b3997e47c6fc6d020791efd1f8024b396 Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Wed, 15 Oct 2025 13:53:49 +0200 Subject: [PATCH] implement copy paste --- src/App/App.tsx | 9 + src/App/RTHistory.tsx | 10 +- src/App/TopPanel.tsx | 2 +- src/App/shortcut_handler.ts | 22 ++ src/VisualEditor/VisualEditor.css | 4 +- src/VisualEditor/VisualEditor.tsx | 291 ++++++++++++++++------ src/statecharts/abstract_syntax.ts | 4 +- src/statecharts/actionlang_interpreter.ts | 1 - src/statecharts/interpreter.ts | 84 +++---- src/statecharts/label_ast.ts | 15 +- src/statecharts/label_parser.js | 170 ++++++++++--- src/statecharts/parser.ts | 79 +++--- src/statecharts/runtime_types.ts | 107 ++++++-- src/statecharts/transition_label.grammar | 16 +- 14 files changed, 580 insertions(+), 234 deletions(-) create mode 100644 src/App/shortcut_handler.ts diff --git a/src/App/App.tsx b/src/App/App.tsx index 82f94dc..41204f7 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -14,6 +14,7 @@ import { TopPanel } from "./TopPanel"; import { RTHistory } from "./RTHistory"; import { AST } from "./AST"; import { TraceableError } from "../statecharts/parser"; +import { getKeyHandler } from "./shortcut_handler"; export function App() { const [mode, setMode] = useState("and"); @@ -83,6 +84,14 @@ export function App() { }, [time, rtIdx]); + useEffect(() => { + const onKeyDown = getKeyHandler(setMode); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, []); + return {/* Top bar */} {[...props.environment.entries()] - .filter(([variable]) => !variable.startsWith('_')) - .map(([variable,value]) => - `${variable}: ${value}` - ).join(', ')}; + return
{ + [...props.environment.entries()] + .filter(([variable]) => !variable.startsWith('_')) + .map(([variable,value]) => `${variable}: ${value}`).join(', ') + }
; } function ShowMode(props: {mode: Mode, statechart: Statechart}) { diff --git a/src/App/TopPanel.tsx b/src/App/TopPanel.tsx index 2acd3ed..c6f66c4 100644 --- a/src/App/TopPanel.tsx +++ b/src/App/TopPanel.tsx @@ -116,7 +116,7 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast, mode  
- +   diff --git a/src/App/shortcut_handler.ts b/src/App/shortcut_handler.ts new file mode 100644 index 0000000..730c6a1 --- /dev/null +++ b/src/App/shortcut_handler.ts @@ -0,0 +1,22 @@ +import { Dispatch, SetStateAction } from "react"; +import { InsertMode } from "../VisualEditor/VisualEditor"; + +export function getKeyHandler(setMode: Dispatch>) { + return function onKeyDown(e: KeyboardEvent) { + if (e.key === "a") { + setMode("and"); + } + if (e.key === "o") { + setMode("or"); + } + if (e.key === "p") { + setMode("pseudo"); + } + if (e.key === "t") { + setMode("transition"); + } + if (e.key === "x") { + setMode("text"); + } + } +} diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index fa10a2f..7af5532 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -81,7 +81,7 @@ text.highlight { .lineHelper:hover { stroke: blue; stroke-opacity: 0.2; - cursor: grab; + /* cursor: grab; */ } .pathHelper { @@ -102,7 +102,7 @@ text.highlight { .circleHelper:hover { fill: blue; fill-opacity: 0.2; - cursor: grab; + /* cursor: grab; */ } .rountangle.or { diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index 6d99536..3f52bb8 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 { ArrowPart, RountanglePart, VisualEditorState, emptyState, findNearestArrow, findNearestRountangleSide, findRountangle } from "../statecharts/concrete_syntax"; +import { Arrow, ArrowPart, Rountangle, RountanglePart, Text, VisualEditorState, emptyState, findNearestArrow, findNearestRountangleSide, 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"; @@ -63,6 +63,8 @@ type VisualEditorProps = { export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditorProps) { const [historyState, setHistoryState] = useState({current: emptyState, history: [], future: []}); + const [clipboard, setClipboard] = useState>(new Set()); + const state = historyState.current; const setState = (s: SetStateAction) => { setHistoryState(historyState => { @@ -164,7 +166,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor const onMouseDown = (e: MouseEvent) => { const currentPointer = getCurrentPointer(e); - if (e.button === 1) { + if (e.button === 2) { checkPoint(); // ignore selection, middle mouse button always inserts setState(state => { @@ -230,25 +232,26 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor break; } } - if (!allPartsInSelection) { - setSelection([{uid, parts}] as Selection); + // if (!allPartsInSelection) { + // setSelection([{uid, parts}] as Selection); + // } + + if (allPartsInSelection) { + // start dragging + setDragging({ + lastMousePos: currentPointer, + }); + return; } - - // 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}) => { @@ -304,7 +307,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor } }; - const onMouseUp = () => { + const onMouseUp = (e) => { if (dragging) { setDragging(null); // do not persist sizes smaller than 40x40 @@ -320,45 +323,68 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor } if (selectingState) { // we were making a selection - 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 (selectingState.size.x === 0 && selectingState.size.y === 0) { + const uid = e.target?.dataset.uid; + const parts: string[] = e.target?.dataset.parts?.split(' ') || []; if (uid) { - const parts: Set = uidToParts.get(uid) || new Set(); - for (const part of shape.dataset.parts?.split(' ') || []) { - parts.add(part); + 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; + } } - uidToParts.set(uid, parts); } } - setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({ - kind: "rountangle", - uid, - parts: [...parts], - }))); - setSelectingState(null); // no longer making a selection + 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], + }))); + } } + setSelectingState(null); // no longer making a selection }; + function deleteShapes(selection: Selection) { + setState(state => ({ + ...state, + 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)), + })); + setSelection([]); + } + const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Delete") { // delete selection if (selection.length > 0) { checkPoint(); - setState(state => ({ - ...state, - 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)), - })); - setSelection([]); + deleteShapes(selection); } } if (e.key === "o") { @@ -394,17 +420,61 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor if (e.key === "h") { setShowHelp(showHelp => !showHelp); } - // if (e.key === "s") { - // setMode("state"); - // } - // if (e.key === "t") { - // setMode("transition"); - // } - // if (e.key === "x") { - // setMode("text"); - // } - 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(); @@ -441,7 +511,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; - }, [selectingState, dragging]); + }, [selectingState, dragging, clipboard]); // detect what is 'connected' const arrow2SideMap = new Map(); @@ -525,6 +595,86 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor } } + function onPaste(e: ClipboardEvent) { + const data = e.clipboardData?.getData("text/plain"); + if (data) { + let parsed; + try { + parsed = JSON.parse(data); + } + catch (e) { + return; + } + // const offset = {x: 40, y: 40}; + const offset = {x: 0, y: 0}; + let nextID = state.nextID; + try { + const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({ + ...r, + uid: (nextID++).toString(), + topLeft: addV2D(r.topLeft, offset), + } as Rountangle)); + const copiedArrows: Arrow[] = parsed.arrows.map((a: Arrow) => ({ + ...a, + uid: (nextID++).toString(), + start: addV2D(a.start, offset), + end: addV2D(a.end, offset), + } as Arrow)); + const copiedTexts: Text[] = parsed.texts.map((t: Text) => ({ + ...t, + uid: (nextID++).toString(), + topLeft: addV2D(t.topLeft, offset), + } as Text)); + setState(state => ({ + ...state, + rountangles: [...state.rountangles, ...copiedRountangles], + arrows: [...state.arrows, ...copiedArrows], + texts: [...state.texts, ...copiedTexts], + nextID: nextID, + })); + // @ts-ignore + const newSelection: Selection = [ + ...copiedRountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})), + ...copiedArrows.map(a => ({uid: a.uid, parts: ["start", "end"]})), + ...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})), + ]; + setSelection(newSelection); + // copyInternal(newSelection, e); // doesn't work + e.preventDefault(); + } + catch (e) { + } + } + } + + function copyInternal(selection: Selection, e: ClipboardEvent) { + const uidsToCopy = new Set(selection.map(shape => shape.uid)); + const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid)); + const arrowsToCopy = state.arrows.filter(a => uidsToCopy.has(a.uid)); + const textsToCopy = state.texts.filter(t => uidsToCopy.has(t.uid)); + e.clipboardData?.setData("text/plain", JSON.stringify({ + rountangles: rountanglesToCopy, + arrows: arrowsToCopy, + texts: textsToCopy, + })); + } + + function onCopy(e: ClipboardEvent) { + if (selection.length > 0) { + e.preventDefault(); + copyInternal(selection, e); + } + } + + function onCut(e: ClipboardEvent) { + if (selection.length > 0) { + copyInternal(selection, e); + deleteShapes(selection); + e.preventDefault(); + } + + } + const active = rt?.mode || new Set(); const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); @@ -534,6 +684,9 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor onMouseDown={onMouseDown} onContextMenu={e => e.preventDefault()} ref={refSVG} + onCopy={onCopy} + onPaste={onPaste} + onCut={onCut} > {textNode};})} {selectingState && } - - {/* {showHelp ? <> - - Left mouse button: Select/Drag. - - - Right mouse button: Select only. - - - Middle mouse button: Insert [S]tates / [T]ransitions / Te[X]t (current mode: {mode}) - - [Del] Delete selection. - - - [O] Turn selected states into OR-states. - - - [A] Turn selected states into AND-states. - - - [H] Show/hide this help. - - : [H] To show help.} */} - ; } diff --git a/src/statecharts/abstract_syntax.ts b/src/statecharts/abstract_syntax.ts index d814a72..f854027 100644 --- a/src/statecharts/abstract_syntax.ts +++ b/src/statecharts/abstract_syntax.ts @@ -1,4 +1,4 @@ -import { Action, EventTrigger, TransitionLabel } from "./label_ast"; +import { Action, EventTrigger, ParsedText, TransitionLabel } from "./label_ast"; export type AbstractState = { uid: string; @@ -28,7 +28,7 @@ export type Transition = { uid: string; src: ConcreteState; tgt: ConcreteState; - label: TransitionLabel[]; + label: ParsedText[]; } export type Statechart = { diff --git a/src/statecharts/actionlang_interpreter.ts b/src/statecharts/actionlang_interpreter.ts index 13d3f97..d8247d9 100644 --- a/src/statecharts/actionlang_interpreter.ts +++ b/src/statecharts/actionlang_interpreter.ts @@ -3,7 +3,6 @@ import { Expression } from "./label_ast"; import { Environment } from "./runtime_types"; - const UNARY_OPERATOR_MAP: Map any> = new Map([ ["!", x => !x], ["-", x => -x as any], diff --git a/src/statecharts/interpreter.ts b/src/statecharts/interpreter.ts index e9e81ce..a024cea 100644 --- a/src/statecharts/interpreter.ts +++ b/src/statecharts/interpreter.ts @@ -1,11 +1,11 @@ import { evalExpr } from "./actionlang_interpreter"; import { computeArena, ConcreteState, getDescendants, isOverlapping, OrState, Statechart, stateDescription, Transition } from "./abstract_syntax"; -import { Action, EventTrigger } from "./label_ast"; -import { Environment, RaisedEvents, Mode, RT_Statechart, initialRaised, BigStepOutput, Timers, RT_Event } from "./runtime_types"; +import { Action, AfterTrigger, EventTrigger } from "./label_ast"; +import { Environment, RaisedEvents, Mode, RT_Statechart, initialRaised, BigStepOutput, Timers, RT_Event, TimerElapseEvent } from "./runtime_types"; export function initialize(ast: Statechart): BigStepOutput { let {enteredStates, environment, ...raised} = enterDefault(0, ast.root, { - environment: new Map(), + environment: new Environment(), ...initialRaised, }); return handleInternalEvents(0, ast, {mode: enteredStates, environment, ...raised}); @@ -23,14 +23,19 @@ export function entryActions(simtime: number, state: ConcreteState, actionScope: } // schedule timers // we store timers in the environment (dirty!) - const environment = new Map(actionScope.environment); - const timers: Timers = [...(environment.get("_timers") || [])]; - for (const timeOffset of state.timers) { - const futureSimTime = simtime + timeOffset; // point in simtime when after-trigger becomes enabled - timers.push([futureSimTime, {kind: "timer", state: state.uid, timeDurMs: timeOffset}]); - } - timers.sort((a,b) => a[0] - b[0]); // smallest futureSimTime comes first - environment.set("_timers", timers); + let environment = actionScope.environment.transform("_timers", oldTimers => { + const newTimers = [ + ...oldTimers, + ...state.timers.map(timeOffset => { + const futureSimTime = simtime + timeOffset; + return [futureSimTime, {kind: "timer", state: state.uid, timeDurMs: timeOffset}] as [number, TimerElapseEvent]; + }), + ]; + newTimers.sort((a,b) => a[0] - b[0]); + return newTimers; + }, []); + // new nested scope + environment = environment.pushScope(); return {...actionScope, environment}; } @@ -38,11 +43,12 @@ export function exitActions(simtime: number, state: ConcreteState, actionScope: for (const action of state.exitActions) { (actionScope = execAction(action, actionScope)); } + let environment = actionScope.environment.popScope(); // cancel timers - const environment = new Map(actionScope.environment); - const timers: Timers = environment.get("_timers") || []; - const filtered = timers.filter(([_, {state: s}]) => s !== state.uid); - environment.set("_timers", filtered); + environment = environment.transform("_timers", oldTimers => { + // remove all timers of 'state': + return oldTimers.filter(([_, {state: s}]) => s !== state.uid); + }, []); return {...actionScope, environment}; } @@ -124,15 +130,15 @@ export function enterPath(simtime: number, path: ConcreteState[], rt: ActionScop export function exitCurrent(simtime: number, state: ConcreteState, rt: EnteredScope): ActionScope { let {enteredStates, ...actionScope} = rt; - // exit all active children... - for (const child of state.children) { - if (enteredStates.has(child.uid)) { + if (enteredStates.has(state.uid)) { + // exit all active children... + for (const child of state.children) { actionScope = exitCurrent(simtime, child, {enteredStates, ...actionScope}); } - } - // execute exit actions - actionScope = exitActions(simtime, state, actionScope); + // execute exit actions + actionScope = exitActions(simtime, state, actionScope); + } return actionScope; } @@ -140,7 +146,7 @@ export function exitCurrent(simtime: number, state: ConcreteState, rt: EnteredSc export function exitPath(simtime: number, path: ConcreteState[], rt: EnteredScope): ActionScope { let {enteredStates, ...actionScope} = rt; - const toExit = enteredStates.difference(new Set(path)); + const toExit = enteredStates.difference(new Set(path.map(s=>s.uid))); const [state, ...rest] = path; @@ -152,18 +158,16 @@ export function exitPath(simtime: number, path: ConcreteState[], rt: EnteredScop // execute exit actions actionScope = exitActions(simtime, state, actionScope); - return actionScope; } export function execAction(action: Action, rt: ActionScope): ActionScope { if (action.kind === "assignment") { const rhs = evalExpr(action.rhs, rt.environment); - const newEnvironment = new Map(rt.environment); - newEnvironment.set(action.lhs, rhs); + const environment = rt.environment.set(action.lhs, rhs); return { ...rt, - environment: newEnvironment, + environment, }; } else if (action.kind === "raise") { @@ -224,7 +228,6 @@ export function handleEvent(simtime: number, event: RT_Event, statechart: Statec console.warn('nondeterminism!!!!'); } const t = enabled[0]; - console.log('enabled:', transitionDescription(t)); const {arena, srcPath, tgtPath} = computeArena(t); let overlapping = false; for (const alreadyFired of arenasFired) { @@ -233,30 +236,25 @@ export function handleEvent(simtime: number, event: RT_Event, statechart: Statec } } if (!overlapping) { - console.log('^ firing'); let oldValue; if (event.kind === "input" && event.param !== undefined) { // input events may have a parameter - // *temporarily* add event to environment (dirty!) - oldValue = environment.get(event.param); - environment = new Map([ - ...environment, - [(t.label[0].trigger as EventTrigger).paramName as string, event.param], - ]); + // add event parameter to environment in new scope + environment = environment.pushScope(); + environment = environment.newVar( + (t.label[0].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) { - // restore original value of variable that had same name as input parameter - environment = new Map([ - ...environment, - [(t.label[0].trigger as EventTrigger).paramName as string, oldValue], - ]); - console.log('restored environment:', environment); + environment = environment.popScope(); + // console.log('restored environment:', environment); } arenasFired.add(arena); } else { - console.log('skip (overlapping arenas)'); + // console.log('skip (overlapping arenas)'); } } else { @@ -292,9 +290,10 @@ function transitionDescription(t: Transition) { export function fireTransition(simtime: number, t: Transition, arena: OrState, srcPath: ConcreteState[], tgtPath: ConcreteState[], {mode, environment, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { - // console.log('fire ', transitionDescription(t), {arena, srcPath, tgtPath}); + console.log('fire ', transitionDescription(t), {arena, srcPath, tgtPath}); // exit src + console.log('exit src...'); ({environment, ...raised} = exitPath(simtime, srcPath.slice(1), {environment, enteredStates: mode, ...raised})); const toExit = getDescendants(arena); toExit.delete(arena.uid); // do not exit the arena itself @@ -306,6 +305,7 @@ export function fireTransition(simtime: number, t: Transition, arena: OrState, s } // enter tgt + console.log('enter tgt...'); let enteredStates; ({enteredStates, environment, ...raised} = enterPath(simtime, tgtPath.slice(1), {environment, ...raised})); const enteredMode = exitedMode.union(enteredStates); diff --git a/src/statecharts/label_ast.ts b/src/statecharts/label_ast.ts index 7c2f43c..d3b8a54 100644 --- a/src/statecharts/label_ast.ts +++ b/src/statecharts/label_ast.ts @@ -1,4 +1,4 @@ -export type ParsedText = TransitionLabel | Comment; +export type ParsedText = TransitionLabel | Comment | ParserError; export type TransitionLabel = { kind: "transitionLabel"; @@ -14,6 +14,11 @@ export type Comment = { text: string; } +export type ParserError = { + kind: "parserError"; + uid: string; // uid of the text node +} + export type Trigger = EventTrigger | AfterTrigger | EntryTrigger | ExitTrigger; export type EventTrigger = { @@ -73,4 +78,10 @@ export type VarRef = { export type Literal = { kind: "literal"; value: any; -} \ No newline at end of file +} + +export type FunctionCall = { + kind: "call", + fn: VarRef, + param: Expression, +} diff --git a/src/statecharts/label_parser.js b/src/statecharts/label_parser.js index 03b31d7..79d6b95 100644 --- a/src/statecharts/label_parser.js +++ b/src/statecharts/label_parser.js @@ -182,16 +182,18 @@ function peg$parse(input, options) { const peg$c15 = ">="; const peg$c16 = "true"; const peg$c17 = "false"; - const peg$c18 = "^"; - const peg$c19 = "//"; - const peg$c20 = "\n"; + const peg$c18 = "\""; + const peg$c19 = "^"; + const peg$c20 = "//"; + const peg$c21 = "\n"; const peg$r0 = /^[0-9A-Z_a-z]/; const peg$r1 = /^[0-9]/; const peg$r2 = /^[<>]/; const peg$r3 = /^[+\-]/; const peg$r4 = /^[*\/]/; - const peg$r5 = /^[ \t\n\r]/; + const peg$r5 = /^[^"]/; + const peg$r6 = /^[ \t\n\r]/; const peg$e0 = peg$literalExpectation("[", false); const peg$e1 = peg$literalExpectation("]", false); @@ -216,11 +218,13 @@ function peg$parse(input, options) { const peg$e20 = peg$classExpectation(["*", "/"], false, false, false); const peg$e21 = peg$literalExpectation("true", false); const peg$e22 = peg$literalExpectation("false", false); - const peg$e23 = peg$literalExpectation("^", false); - const peg$e24 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false, false); - const peg$e25 = peg$literalExpectation("//", false); - const peg$e26 = peg$anyExpectation(); - const peg$e27 = peg$literalExpectation("\n", false); + const peg$e23 = peg$literalExpectation("\"", false); + const peg$e24 = peg$classExpectation(["\""], true, false, false); + const peg$e25 = peg$literalExpectation("^", false); + const peg$e26 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false, false); + const peg$e27 = peg$literalExpectation("//", false); + const peg$e28 = peg$anyExpectation(); + const peg$e29 = peg$literalExpectation("\n", false); function peg$f0(trigger, guard, actions) { return { @@ -296,20 +300,30 @@ function peg$parse(input, options) { function peg$f14(expr) { return expr; } - function peg$f15(value) { + function peg$f15(fn, param) { + return { + kind: "call", + fn, + param, + }; + } + function peg$f16(value) { return {kind: "literal", value} } - function peg$f16(variable) { + function peg$f17(variable) { return {kind: "ref", variable} } - function peg$f17() { + function peg$f18() { return text() === "true"; } - function peg$f18(event, param) { + function peg$f19(str) { + return str.join(''); + } + function peg$f20(event, param) { return {kind: "raise", event, param: param ? param[1] : undefined}; } - function peg$f19() { return null; } - function peg$f20(text) { + function peg$f21() { return null; } + function peg$f22(text) { return { kind: "comment", text: text.join(''), @@ -1152,11 +1166,14 @@ function peg$parse(input, options) { function peg$parseatom() { let s0; - s0 = peg$parsenested(); + s0 = peg$parsefnCall(); if (s0 === peg$FAILED) { - s0 = peg$parseliteral(); + s0 = peg$parsenested(); if (s0 === peg$FAILED) { - s0 = peg$parseref(); + s0 = peg$parseliteral(); + if (s0 === peg$FAILED) { + s0 = peg$parseref(); + } } } @@ -1205,6 +1222,28 @@ function peg$parse(input, options) { return s0; } + function peg$parsefnCall() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = peg$parseref(); + if (s1 !== peg$FAILED) { + s2 = peg$parsenested(); + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f15(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + function peg$parseliteral() { let s0, s1; @@ -1212,10 +1251,13 @@ function peg$parse(input, options) { s1 = peg$parsenumber(); if (s1 === peg$FAILED) { s1 = peg$parseboolean(); + if (s1 === peg$FAILED) { + s1 = peg$parsestring(); + } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f15(s1); + s1 = peg$f16(s1); } s0 = s1; @@ -1229,7 +1271,7 @@ function peg$parse(input, options) { s1 = peg$parseidentifier(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(s1); + s1 = peg$f17(s1); } s0 = s1; @@ -1258,23 +1300,75 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f17(); + s1 = peg$f18(); } s0 = s1; return s0; } + function peg$parsestring() { + let s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c18; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r5.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r5.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + } + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c18; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f19(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + function peg$parseraise() { let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 94) { - s1 = peg$c18; + s1 = peg$c19; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e25); } } if (s1 !== peg$FAILED) { s2 = peg$parse_(); @@ -1317,7 +1411,7 @@ function peg$parse(input, options) { s4 = null; } peg$savedPos = s0; - s0 = peg$f18(s3, s4); + s0 = peg$f20(s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1339,11 +1433,11 @@ function peg$parse(input, options) { s2 = peg$parsecomment(); if (s2 === peg$FAILED) { s2 = input.charAt(peg$currPos); - if (peg$r5.test(s2)) { + if (peg$r6.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } } } while (s2 !== peg$FAILED) { @@ -1351,16 +1445,16 @@ function peg$parse(input, options) { s2 = peg$parsecomment(); if (s2 === peg$FAILED) { s2 = input.charAt(peg$currPos); - if (peg$r5.test(s2)) { + if (peg$r6.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } } } } peg$savedPos = s0; - s1 = peg$f19(); + s1 = peg$f21(); s0 = s1; peg$silentFails--; @@ -1371,12 +1465,12 @@ function peg$parse(input, options) { let s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c19) { - s1 = peg$c19; + if (input.substr(peg$currPos, 2) === peg$c20) { + s1 = peg$c20; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } if (s1 !== peg$FAILED) { s2 = peg$parse_(); @@ -1387,7 +1481,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -1396,17 +1490,17 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } } s4 = peg$parse_(); if (s4 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 10) { - s5 = peg$c20; + s5 = peg$c21; peg$currPos++; } else { s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e29); } } if (s5 === peg$FAILED) { s5 = peg$currPos; @@ -1416,7 +1510,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } peg$silentFails--; if (s6 === peg$FAILED) { @@ -1428,7 +1522,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f20(s3); + s0 = peg$f22(s3); } else { peg$currPos = s0; s0 = peg$FAILED; diff --git a/src/statecharts/parser.ts b/src/statecharts/parser.ts index dd64420..a133831 100644 --- a/src/statecharts/parser.ts +++ b/src/statecharts/parser.ts @@ -173,6 +173,7 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl let parsed: ParsedText; try { parsed = parseLabel(text.text); // may throw + parsed.uid = text.uid; } catch (e) { if (e instanceof SyntaxError) { errors.push({ @@ -180,21 +181,21 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl message: e.message, data: e, }); - continue; + parsed = { + kind: "parserError", + uid: text.uid, + } } else { throw e; } } - parsed.uid = text.uid; - if (parsed.kind === "transitionLabel") { - const belongsToArrow = findNearestArrow(text.topLeft, state.arrows); - if (belongsToArrow) { - const belongsToTransition = uid2Transition.get(belongsToArrow.uid); - if (belongsToTransition) { - // parse as transition label - belongsToTransition.label.push(parsed); - + const belongsToArrow = findNearestArrow(text.topLeft, state.arrows); + if (belongsToArrow) { + const belongsToTransition = uid2Transition.get(belongsToArrow.uid); + if (belongsToTransition) { + belongsToTransition.label.push(parsed); + if (parsed.kind === "transitionLabel") { // collect events // triggers if (parsed.trigger.kind === "event") { @@ -210,6 +211,10 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl belongsToTransition.src.timers.push(parsed.trigger.durationMs); belongsToTransition.src.timers.sort(); } + else if (["entry", "exit"].includes(parsed.trigger.kind)) { + errors.push({shapeUid: text.uid, message: "entry/exit trigger not allowed on transitions"}); + } + // // raise-actions // for (const action of parsed.actions) { // if (action.kind === "raise") { @@ -224,40 +229,40 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl // } // collect variables - variables = variables - .union(findVariables(parsed.guard)); + variables = variables.union(findVariables(parsed.guard)); for (const action of parsed.actions) { variables = variables.union(findVariablesAction(action)); } } - continue; } } - // 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; - 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 - if (parsed.trigger.kind === "entry") { - belongsToState.entryActions.push(...parsed.actions); - } - else if(parsed.trigger.kind === "exit") { - belongsToState.exitActions.push(...parsed.actions); - } - else { - errors.push({ - shapeUid: text.uid, - message: "states can only have entry/exit triggers", - data: {start: {offset: 0}, end: {offset: text.text.length}}, - }); - } + else { + // 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; + 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 + if (parsed.trigger.kind === "entry") { + belongsToState.entryActions.push(...parsed.actions); + } + else if(parsed.trigger.kind === "exit") { + belongsToState.exitActions.push(...parsed.actions); + } + else { + errors.push({ + shapeUid: text.uid, + message: "states can only have entry/exit triggers", + data: {start: {offset: 0}, end: {offset: text.text.length}}, + }); + } - } - else if (parsed.kind === "comment") { - // just append comments to their respective states - belongsToState.comments.push([text.uid, parsed.text]); + } + else if (parsed.kind === "comment") { + // just append comments to their respective states + belongsToState.comments.push([text.uid, parsed.text]); + } } } diff --git a/src/statecharts/runtime_types.ts b/src/statecharts/runtime_types.ts index 4d99b25..f589282 100644 --- a/src/statecharts/runtime_types.ts +++ b/src/statecharts/runtime_types.ts @@ -17,29 +17,94 @@ export type TimerElapseEvent = { export type Mode = Set; // set of active states -export type Environment = ReadonlyMap; // variable name -> value +// export type Environment = ReadonlyMap; // variable name -> value -// export class Environment { -// env: Map[]; -// constructor(env = [new Map()]) { -// this.env = env; -// } +export class Environment { + scopes: ReadonlyMap[]; // array of nested scopes - scope at the back of the array is used first -// with(key: string, value: any): Environment { -// for (let i=0; i[]) { + this.scopes = env; + } + + pushScope(): Environment { + return new Environment([...this.scopes, new Map()]); + } + + popScope(): Environment { + return new Environment(this.scopes.slice(0, -1)); + } + + // force creation of a new variable in the current scope, even if a variable with the same name already exists in a surrounding scope + newVar(key: string, value: any): Environment { + return new Environment( + this.scopes.with( + this.scopes.length-1, + new Map([ + ...this.scopes[this.scopes.length-1], + [key, value], + ]), + )); + } + + // update variable in the innermost scope where it exists, or create it in the current scope if it doesn't exist yet + set(key: string, value: any): Environment { + for (let i=this.scopes.length-1; i>=0; i--) { + const map = this.scopes[i]; + if (map.has(key)) { + return new Environment(this.scopes.with(i, new Map([ + ...map.entries(), + [key, value], + ]))); + } + } + console.log(this.scopes); + return new Environment(this.scopes.with(-1, new Map([ + ...this.scopes[this.scopes.length-1].entries(), + [key, value], + ]))); + } + + // lookup variable, starting in the currrent (= innermost) scope, then looking into surrounding scopes until found. + get(key: string): any { + for (let i=this.scopes.length-1; i>=0; i--) { + const map = this.scopes[i]; + const found = map.get(key); + if (found !== undefined) { + return found; + } + } + } + + transform(key: string, upd: (old:T) => T, defaultVal: T): Environment { + const old = this.get(key) || defaultVal; + return this.set(key, upd(old)); + } + + *entries() { + const visited = new Set(); + for (let i=this.scopes.length-1; i>=0; i--) { + const map = this.scopes[i]; + for (const [key, value] of map.entries()) { + if (!visited.has(key)) { + yield [key, value]; + visited.add(key); + } + } + } + } +} + +// console.log('env...'); +// let env = new Environment(); +// env = env.set("a", 1); +// env = env.set("b", 2); +// env = env.pushScope(); +// console.log(env.get("a")); // 1 +// env = env.newVar("a", 99); +// console.log(env.get("a")); // 99 +// env = env.popScope(); +// console.log(env.get("a")); // 1 +// console.log('end env...'); export type RT_Statechart = { mode: Mode; diff --git a/src/statecharts/transition_label.grammar b/src/statecharts/transition_label.grammar index a2ecbc3..9492371 100644 --- a/src/statecharts/transition_label.grammar +++ b/src/statecharts/transition_label.grammar @@ -93,13 +93,21 @@ product = atom:atom rest:((_ ("*" / "/") _) product)? { }; } -atom = nested / literal / ref +atom = fnCall / nested / literal / ref nested = "(" _ expr:expr _ ")" { return expr; } -literal = value:(number / boolean) { +fnCall = fn:ref param:nested { + return { + kind: "call", + fn, + param, + }; +} + +literal = value:(number / boolean / string) { return {kind: "literal", value} } @@ -111,6 +119,10 @@ boolean = ("true" / "false") { return text() === "true"; } +string = '"' str:([^"]*) '"' { + return str.join(''); +} + raise = "^" _ event:identifier param:("(" expr ")")? { return {kind: "raise", event, param: param ? param[1] : undefined}; }