Compare commits

...

30 commits

Author SHA1 Message Date
7994cd6eb0 cleanup the code a bit... 2025-11-14 19:05:02 +01:00
1bd801ce5d less code duplication 2025-11-14 17:19:33 +01:00
970b9d850e getting rid of some code duplication 2025-11-14 16:52:09 +01:00
0266675f29 update todo 2025-11-14 11:59:53 +01:00
d805fa95e4 bind zoom in shortcut also to Ctrl+= (like browsers do)" 2025-11-14 11:01:15 +01:00
8fa430846b edit css, make microwave background transparent 2025-11-14 09:27:19 +01:00
632cf9b542 progress bar shows how long until next timer elapse 2025-11-13 23:56:52 +01:00
bdc2a66b1c remove debug output 2025-11-13 22:49:16 +01:00
74361eb162 accidentally committed debug endpoint for property checking 2025-11-13 22:47:10 +01:00
dc948629a7 fix history color in dark mode + flip colors of and/or 2025-11-13 22:27:13 +01:00
c825830a14 better looking checkboxes 2025-11-13 22:09:49 +01:00
e29559e46d improve dark mode colors + fix dark logo in chrome 2025-11-13 22:01:02 +01:00
e3b88b7d89 blue is better for bottom panel in dark mode 2025-11-13 21:06:19 +01:00
ee899cea2d fix selection 2025-11-13 21:01:50 +01:00
af934c6767 improve colors 2025-11-13 19:55:52 +01:00
02cbbe601a disable forcing light/dark mode (doesn't work properly) 2025-11-13 19:50:33 +01:00
6efc27453e new featuregit status dark mode 2025-11-13 19:25:43 +01:00
9646d716c6 editor: shift or ctrl key to append selection 2025-11-13 13:05:22 +01:00
848a13e875 update assignment: alternate and/or states as in the theory 2025-11-13 10:59:34 +01:00
1f72542234 cleanup code a bit more 2025-11-12 14:34:46 +01:00
07b51dd2f2 move file 2025-11-08 10:50:41 +01:00
5674416623 assignment: get rid of the water levels 2025-11-08 10:49:51 +01:00
a013fca768 update assignment (MTL property was wrong! i blame chatgpt) 2025-11-08 10:41:41 +01:00
e0863c9443 merge recovered app state with default app state (makes a difference if the recovered app state is only partially defined) 2025-11-08 10:36:42 +01:00
9922f8588d clean up code a bit (split of SideBar component and simulator callbacks from App) + fix bug in property checker 2025-11-08 10:32:28 +01:00
2dd35ab079 assignment: fix property (it was wrong) + always include traces for 'true' and 'false' 2025-11-07 15:09:21 +01:00
fcf9448441 assignment: fix example 2025-11-07 14:35:53 +01:00
2d6b8a764b update assignment.html (the bug mentioned has been fixed) 2025-11-07 14:32:43 +01:00
5713c3a59f edit assignment.html 2025-11-07 14:30:37 +01:00
3f6b2ba950 fix interpreter bug 2025-11-07 14:09:04 +01:00
71 changed files with 3685 additions and 1937 deletions

3
.gitignore vendored
View file

@ -32,3 +32,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
# When building the app, we include the git rev in the status bar. We do this by calling git and writing the rev to a file, which is then included by the app.
src/git-rev.txt

File diff suppressed because one or more lines are too long

View file

@ -7,13 +7,12 @@
"module": "src/index.tsx",
"scripts": {
"dev": "bun --hot src/index.tsx",
"build": "NODE_ENV=production bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"build": "git rev-parse HEAD > src/git-rev.txt && NODE_ENV=production bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx"
},
"dependencies": {
"@fontsource/roboto": "^5.2.8",
"@mui/icons-material": "^7.3.4",
// "argus-wasm": "git+https://deemz.org/git/joeri/argus-wasm.git#a4491b3433d48aa1f941bd5ad37b36f819d3b2ac",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},

View file

@ -1,125 +0,0 @@
details.active {
border: rgb(192, 125, 0);
background-color:rgb(255, 251, 244);
filter: drop-shadow( 0px 0px 3px rgba(192, 125, 0, 0.856));
}
details > summary {
padding: 2px;
}
/* these two rules add a bit of padding to an opened <details> node */
/* details:open > summary:has(+ *) {
margin-bottom: 4px;
}
details:open:has(>summary:has(+ *)) {
padding-bottom: 8px;
} */
details > summary:hover {
background-color: #eee;
cursor: default;
}
.errorStatus details > summary:hover {
background-color: rgb(102, 0, 0);
}
.stateTree > * {
padding-left: 10px;
/* border: 1px black solid; */
background-color: white;
/* margin-bottom: 4px; */
/* padding-right: 2px; */
/* padding-top: 2px; */
/* padding-bottom: 2px; */
/* color: black; */
/* width: fit-content; */
/* border-radius: 10px; */
}
/* if <details> has no children (besides the obvious <summary> child), then hide the marker */
/* details:not(:has(:not(summary))) > summary::marker {
content: " ";
} */
.outputEvent {
border: 1px black solid;
border-radius: 6px;
/* margin-left: 4px; */
padding-left: 2px;
padding-right: 2px;
background-color: rgb(230, 249, 255);
color: black;
display: inline-block;
}
.internalEvent {
border: 1px black solid;
border-radius: 6px;
/* margin-left: 4px; */
padding-left: 2px;
padding-right: 2px;
background-color: rgb(255, 218, 252);
color: black;
display: inline-block;
}
.inputEvent {
border: 1px black solid;
border-radius: 6px;
/* margin-left: 4px; */
padding-left: 2px;
padding-right: 2px;
background-color: rgb(224, 247, 209);
color: black;
display: inline-block;
}
.inputEvent:disabled {
color: darkgrey;
}
.inputEvent * {
vertical-align: middle;
}
button.inputEvent:hover:not(:disabled) {
background-color: rgb(195, 224, 176);
}
button.inputEvent:active:not(:disabled) {
background-color: rgb(176, 204, 158);
}
.activeState {
border: rgb(192, 125, 0);
background-color:rgb(255, 251, 244);
filter: drop-shadow( 0px 0px 3px rgba(192, 125, 0, 0.856));
border-radius: 6px;
margin-left: 4px;
padding-left: 2px;
padding-right: 2px;
display: inline-block;
}
/* hr {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #ccc;
margin: 0;
margin-top: -1px;
margin-bottom: -5px;
padding: 0;
z-index: -1;
} */
ul {
list-style-type: circle;
margin-block-start: 0;
margin-block-end: 0;
padding-inline-start: 24px;
/* list-style-position: ; */
}
.shadowBelow {
box-shadow: 0 -5px 5px 5px rgba(0, 0, 0, 0.4);
z-index: 1;
}

View file

@ -1,61 +1,44 @@
/* details {
padding-left: 20;
}
summary {
margin-left: -20;
} */
details:has(+ details) {
border-bottom: 1px lightgrey solid;
border-bottom: 1px var(--separator-color) solid;
}
.runtimeState {
padding: 4px;
/* padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
padding-bottom: 2px; */
}
.runtimeState:has(+.runtimeState) {
border-bottom: 1px lightgrey solid;
border-bottom: 1px var(--separator-color) solid;
}
.runtimeState:has(+.runtimeState.active) {
border-bottom: 0;
}
.runtimeState:hover {
/* background-color: rgba(255, 140, 0, 0.2); */
background-color: rgba(0,0,255,0.2);
background-color: var(--light-accent-color);
cursor: pointer;
}
.runtimeState.active {
background-color: rgba(0,0,255,0.2);
border: solid blue 1px;
background-color: var(--light-accent-color);
border: solid var(--accent-border-color) 1px;
}
.runtimeState.plantStep:not(.active) {
background-color: #f7f7f7;
background-color: var(--inactive-bg-color);
}
.runtimeState.plantStep * {
color: grey;
color: var(--inactive-fg-color);
}
.runtimeState.runtimeError {
background-color: lightpink;
color: darkred;
background-color: var(--error-bg-color);
color: var(--error-color); /* used to be darkred, but this one's a bit lighter */
}
.runtimeState.runtimeError.active {
border-color: darkred;
border-color: var(--error-color);/* used to be darkred, but this one's a bit lighter */
}
/* details:not(:has(details)) > summary::marker {
color: white;
} */
.readonlyTextBox {
width: 56;
background-color:"#eee";
text-align: "right";
}
@ -64,7 +47,7 @@ details:has(+ details) {
}
.toolbar input {
height: 20px;
height: 22px;
}
.toolbar div {
vertical-align: bottom;
@ -77,23 +60,6 @@ details:has(+ details) {
display: inline-block;
}
button {
background-color: #fcfcfc;
border: 1px lightgrey solid;
}
button:not(:disabled):hover {
background-color: rgba(0, 0, 255, 0.2);
}
button.active {
border: solid blue 1px;
background-color: rgba(0,0,255,0.2);
/* margin-right: 1px; */
/* margin-left: 0; */
color: black;
}
.modalOuter {
position: absolute;
width: 100%;
@ -102,7 +68,7 @@ button.active {
justify-content: center;
align-items: center;
text-align: center;
background-color: rgba(200,200,200,0.7);
background-color: var(--modal-backdrop-color);
z-index: 1000;
}
@ -110,7 +76,7 @@ button.active {
position: relative;
text-align: center;
display: inline-block;
background-color: white;
background-color: var(--background-color);
max-height: 100vh;
overflow: auto;
}
@ -128,7 +94,7 @@ div.stackHorizontal {
div.status {
display: inline-block;
vertical-align: middle;
background-color: grey;
background-color: var(--status-inactive-color);
border-radius: 50%;
height: 12px;
width: 12px;
@ -141,6 +107,6 @@ div.status.violated {
}
div.status.satisfied {
background-color: forestgreen;
background-color: var(--status-ok-color);
}

View file

@ -1,36 +1,23 @@
import "../index.css";
import "./App.css";
import { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import AddIcon from '@mui/icons-material/Add';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import CachedOutlinedIcon from '@mui/icons-material/CachedOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { Statechart } from "@/statecharts/abstract_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { Conns, coupledExecution, statechartExecution } from "@/statecharts/timed_reactive";
import { RuntimeError } from "../statecharts/interpreter";
import { parseStatechart } from "../statecharts/parser";
import { BigStep, RaisedEvent } from "../statecharts/runtime_types";
import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
import { BottomPanel } from "./BottomPanel";
import { PersistentDetails, PersistentDetailsLocalStorage } from "./PersistentDetails";
import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
import { dummyPlant } from "./Plant/Dummy/Dummy";
import { microwavePlant } from "./Plant/Microwave/Microwave";
import { Plant } from "./Plant/Plant";
import { trafficLightPlant } from "./Plant/TrafficLight/TrafficLight";
import { RTHistory } from "./RTHistory";
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
import { BottomPanel } from "./BottomPanel/BottomPanel";
import { defaultSideBarState, SideBar, SideBarState } from "./SideBar/SideBar";
import { InsertMode } from "./TopPanel/InsertModes";
import { TopPanel } from "./TopPanel/TopPanel";
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
import { checkProperty, PropertyCheckResult } from "./check_property";
import { useEditor } from "./useEditor";
import { useUrlHashState } from "./useUrlHashState";
import { makeAllSetters } from "./makePartialSetter";
import { useEditor } from "./hooks/useEditor";
import { useSimulator } from "./hooks/useSimulator";
import { useUrlHashState } from "../hooks/useUrlHashState";
import { plants } from "./plants";
import { emptyState } from "@/statecharts/concrete_syntax";
import { ModalOverlay } from "./Overlays/ModalOverlay";
import { FindReplace } from "./BottomPanel/FindReplace";
export type EditHistory = {
current: VisualEditorState,
@ -38,110 +25,87 @@ export type EditHistory = {
future: VisualEditorState[],
}
type UniversalPlantState = {[property: string]: boolean|number};
export type AppState = {
showKeys: boolean,
zoom: number,
insertMode: InsertMode,
showFindReplace: boolean,
findText: string,
replaceText: string,
} & SideBarState;
const plants: [string, Plant<any, UniversalPlantState>][] = [
["dummy", dummyPlant],
["microwave", microwavePlant as unknown as Plant<any, UniversalPlantState>],
["digital watch", digitalWatchPlant as unknown as Plant<any, UniversalPlantState>],
["traffic light", trafficLightPlant as unknown as Plant<any, UniversalPlantState>],
]
export type TraceItemError = {
cause: BigStepCause, // event name, <init> or <timer>
simtime: number,
error: RuntimeError,
const defaultAppState: AppState = {
showKeys: true,
zoom: 1,
insertMode: 'and',
showFindReplace: false,
findText: "",
replaceText: "",
...defaultSideBarState,
}
type CoupledState = {
sc: BigStep,
plant: BigStep,
// plantCleanState: {[prop: string]: boolean|number},
};
export type BigStepCause = {
kind: "init",
simtime: 0,
} | {
kind: "input",
simtime: number,
eventName: string,
param?: any,
} | {
kind: "timer",
simtime: number,
};
export type TraceItem =
{ kind: "error" } & TraceItemError
| { kind: "bigstep", simtime: number, cause: BigStepCause, state: CoupledState, outputEvents: RaisedEvent[] };
export type TraceState = {
trace: [TraceItem, ...TraceItem[]], // non-empty
idx: number,
};
export type LightMode = "light" | "auto" | "dark";
export function App() {
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
const [trace, setTrace] = useState<TraceState|null>(null);
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [modal, setModal] = useState<ReactElement|null>(null);
const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
const {commitState, replaceState, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
const editorState = editHistory && editHistory.current;
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
}, [setEditHistory]);
const {
autoConnect,
setAutoConnect,
autoScroll,
setAutoScroll,
plantConns,
setPlantConns,
showKeys,
setShowKeys,
zoom,
setZoom,
insertMode,
setInsertMode,
plantName,
setPlantName,
showConnections,
setShowConnections,
showProperties,
setShowProperties,
showExecutionTrace,
setShowExecutionTrace,
showPlantTrace,
setShowPlantTrace,
properties,
setProperties,
savedTraces,
setSavedTraces,
activeProperty,
setActiveProperty,
} = useUrlHashState(editorState, setEditHistory);
const plant = plants.find(([pn, p]) => pn === plantName)![1];
const refRightSideBar = useRef<HTMLDivElement>(null);
// parse concrete syntax always:
const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]);
const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]);
const ast = parsed && parsed[0];
const syntaxErrors = parsed && parsed[1] || [];
const currentTraceItem = trace && trace.trace[trace.idx];
const allErrors = [
...syntaxErrors,
...(currentTraceItem && currentTraceItem.kind === "error") ? [{
message: currentTraceItem.error.message,
shapeUid: currentTraceItem.error.highlight[0],
}] : [],
];
const [appState, setAppState] = useState<AppState>(defaultAppState);
const persist = useUrlHashState<VisualEditorState | AppState & {editorState: VisualEditorState}>(
recoveredState => {
if (recoveredState === null) {
setEditHistory(() => ({current: emptyState, history: [], future: []}));
}
// we support two formats
// @ts-ignore
else if (recoveredState.nextID) {
// old format
setEditHistory(() => ({current: recoveredState as VisualEditorState, history: [], future: []}));
}
else {
// new format
// @ts-ignore
if (recoveredState.editorState !== undefined) {
const {editorState, ...appState} = recoveredState as AppState & {editorState: VisualEditorState};
setEditHistory(() => ({current: editorState, history: [], future: []}));
setAppState(defaultAppState => Object.assign({}, defaultAppState, appState));
}
}
},
);
useEffect(() => {
const timeout = setTimeout(() => {
if (editorState !== null) {
console.log('persisting state to url');
persist({editorState, ...appState});
}
}, 100);
return () => clearTimeout(timeout);
}, [editorState, appState]);
const {
autoScroll,
plantConns,
plantName,
} = appState;
const plant = plants.find(([pn, p]) => pn === plantName)![1];
const refRightSideBar = useRef<HTMLDivElement>(null);
const scrollDownSidebar = useCallback(() => {
if (autoScroll && refRightSideBar.current) {
const el = refRightSideBar.current;
@ -152,492 +116,91 @@ export function App() {
}
}, [refRightSideBar.current, autoScroll]);
// coupled execution
const cE = useMemo(() => ast && coupledExecution({
sc: statechartExecution(ast),
plant: plant.execution,
}, {
...plantConns,
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
}), [ast]);
const simulator = useSimulator(ast, plant, plantConns, scrollDownSidebar);
const onInit = useCallback(() => {
if (cE === null) return;
const metadata = {simtime: 0, cause: {kind: "init" as const, simtime: 0 as const}};
try {
const [outputEvents, state] = cE.initial(); // may throw if initialing the statechart results in a RuntimeError
setTrace({
trace: [{kind: "bigstep", ...metadata, state, outputEvents}],
idx: 0,
});
}
catch (error) {
if (error instanceof RuntimeError) {
setTrace({
trace: [{kind: "error", ...metadata, error}],
idx: 0,
});
}
else {
throw error; // probably a bug in the interpreter
}
}
setTime(time => {
if (time.kind === "paused") {
return {...time, simtime: 0};
}
else {
return {...time, since: {simtime: 0, wallclktime: performance.now()}};
}
});
scrollDownSidebar();
}, [cE, scrollDownSidebar]);
// console.log('render app', {ast, plant, appState});
// useDetectChange(ast, 'ast');
// useDetectChange(plant, 'plant');
// useDetectChange(scrollDownSidebar, 'scrollDownSidebar');
// useDetectChange(appState, 'appState');
// useDetectChange(simulator.time, 'simulator.time');
// useDetectChange(simulator.trace, 'simulator.trace');
const onClear = useCallback(() => {
setTrace(null);
setTime({kind: "paused", simtime: 0});
}, [setTrace, setTime]);
// raise input event, producing a new runtime configuration (or a runtime error)
const onRaise = (inputEvent: string, param: any) => {
if (cE === null) return;
if (currentTraceItem !== null /*&& ast.inputEvents.some(e => e.event === inputEvent)*/) {
if (currentTraceItem.kind === "bigstep") {
const simtime = getSimTime(time, Math.round(performance.now()));
appendNewConfig(simtime, {kind: "input", simtime, eventName: inputEvent, param}, () => {
return cE.extTransition(simtime, currentTraceItem.state, {kind: "input", name: inputEvent, param});
});
}
}
};
// timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
useEffect(() => {
let timeout: NodeJS.Timeout | undefined;
if (currentTraceItem !== null && cE !== null) {
if (currentTraceItem.kind === "bigstep") {
const nextTimeout = cE?.timeAdvance(currentTraceItem.state);
const raiseTimeEvent = () => {
appendNewConfig(nextTimeout, {kind: "timer", simtime: nextTimeout}, () => {
return cE.intTransition(currentTraceItem.state);
});
}
if (time.kind === "realtime") {
const wallclkDelay = getWallClkDelay(time, nextTimeout, Math.round(performance.now()));
if (wallclkDelay !== Infinity) {
timeout = setTimeout(raiseTimeEvent, wallclkDelay);
}
}
else if (time.kind === "paused") {
if (nextTimeout <= time.simtime) {
raiseTimeEvent();
}
}
}
}
return () => {
if (timeout) clearTimeout(timeout);
}
}, [time, currentTraceItem]); // <-- todo: is this really efficient?
function appendNewConfig(simtime: number, cause: BigStepCause, computeNewState: () => [RaisedEvent[], CoupledState]) {
let newItem: TraceItem;
const metadata = {simtime, cause}
try {
const [outputEvents, state] = computeNewState(); // may throw RuntimeError
newItem = {kind: "bigstep", ...metadata, state, outputEvents};
}
catch (error) {
if (error instanceof RuntimeError) {
newItem = {kind: "error", ...metadata, error};
// also pause the simulation, for dramatic effect:
setTime({kind: "paused", simtime});
}
else {
throw error;
}
}
// @ts-ignore
setTrace(trace => ({
trace: [
...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
newItem,
],
// idx: 0,
idx: trace!.idx+1,
}));
scrollDownSidebar();
}
const onBack = useCallback(() => {
if (trace !== null) {
setTime(() => {
if (trace !== null) {
return {
kind: "paused",
simtime: trace.trace[trace.idx-1].simtime,
}
}
return { kind: "paused", simtime: 0 };
});
setTrace({
...trace,
idx: trace.idx-1,
});
}
}, [trace]);
const setters = makeAllSetters(setAppState, Object.keys(appState) as (keyof AppState)[]);
const syntaxErrors = parsed && parsed[1] || [];
const currentTraceItem = simulator.trace && simulator.trace.trace[simulator.trace.idx];
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
const allErrors = [
...syntaxErrors,
...(currentTraceItem && currentTraceItem.kind === "error") ? [{
message: currentTraceItem.error.message,
shapeUid: currentTraceItem.error.highlight[0],
}] : [],
];
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || [];
const speed = time.kind === "paused" ? 0 : time.scale;
const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1];
useEffect(() => {
ast && autoConnect && autoDetectConns(ast, plant, setPlantConns);
}, [ast, plant, autoConnect]);
return <div style={{
height:'100%',
// doesn't work:
// colorScheme: lightMode !== "auto" ? lightMode : undefined,
}}>
<ModalOverlay modal={modal} setModal={setModal}>
{/* top-to-bottom: everything -> bottom panel */}
<div className="stackVertical" style={{height:'100%'}}>
const [propertyResults, setPropertyResults] = useState<PropertyCheckResult[] | null>(null);
{/* left-to-right: main -> sidebar */}
<div className="stackHorizontal" style={{flexGrow:1, overflow: "auto"}}>
{/* top-to-bottom: top bar, editor */}
<div className="stackVertical" style={{flexGrow:1, overflow: "auto"}}>
{/* Top bar */}
<div
className="shadowBelow"
style={{flex: '0 0 content'}}
>
{editHistory && <TopPanel
{...{onUndo, onRedo, onRotate, setModal, editHistory, ...simulator, ...setters, ...appState, setEditorState}}
/>}
</div>
{/* Editor */}
<div style={{flexGrow: 1, overflow: "auto"}}>
{editorState && conns && syntaxErrors &&
<VisualEditor {...{state: editorState, commitState, replaceState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, ...appState}}/>}
</div>
const onSaveTrace = () => {
if (trace) {
setSavedTraces(savedTraces => [
...savedTraces,
["untitled", trace.trace.map((item) => item.cause)] as [string, BigStepCause[]],
]);
}
}
const onReplayTrace = (causes: BigStepCause[]) => {
if (cE) {
function run_until(simtime: number) {
while (true) {
const nextTimeout = cE!.timeAdvance(lastState);
if (nextTimeout > simtime) {
break;
}
const [outputEvents, coupledState] = cE!.intTransition(lastState);
lastState = coupledState;
lastSimtime = nextTimeout;
newTrace.push({kind: "bigstep", simtime: nextTimeout, state: coupledState, outputEvents, cause: {kind: "timer", simtime: nextTimeout}});
}
}
const [outputEvents, coupledState] = cE.initial();
const newTrace = [{kind: "bigstep", simtime: 0, state: coupledState, outputEvents, cause: {kind: "init"} as BigStepCause} as TraceItem] as [TraceItem, ...TraceItem[]];
let lastState = coupledState;
let lastSimtime = 0;
for (const cause of causes) {
if (cause.kind === "input") {
run_until(cause.simtime); // <-- just make sure we haven't missed any timers elapsing
// @ts-ignore
const [outputEvents, coupledState] = cE.extTransition(cause.simtime, newTrace.at(-1)!.state, {kind: "input", name: cause.eventName, param: cause.param});
lastState = coupledState;
lastSimtime = cause.simtime;
newTrace.push({kind: "bigstep", simtime: cause.simtime, state: coupledState, outputEvents, cause});
}
else if (cause.kind === "timer") {
run_until(cause.simtime);
}
}
setTrace({trace: newTrace, idx: newTrace.length-1});
setTime({kind: "paused", simtime: lastSimtime});
}
}
// if some properties change, re-evaluate them:
useEffect(() => {
let timeout: NodeJS.Timeout;
if (trace) {
setPropertyResults(null);
timeout = setTimeout(() => {
Promise.all(properties.map((property, i) => {
return checkProperty(plant, property, trace.trace);
}))
.then(results => {
setPropertyResults(results);
})
})
}
return () => clearTimeout(timeout);
}, [properties, trace, plant]);
return <>
{/* Modal dialog */}
{modal && <div
className="modalOuter"
onMouseDown={() => setModal(null)}>
<div className="modalInner">
<span onMouseDown={e => e.stopPropagation()}>
{modal}
</span>
</div>
</div>}
{/* top-to-bottom: everything -> bottom panel */}
<div className="stackVertical" style={{height:'100%'}}>
{/* left-to-right: main -> sidebar */}
<div className="stackHorizontal" style={{flexGrow:1, overflow: "auto"}}>
{/* top-to-bottom: top bar, editor */}
<div className="stackVertical" style={{flexGrow:1, overflow: "auto"}}>
{/* Top bar */}
<div
className="shadowBelow"
style={{flex: '0 0 content'}}
>
{editHistory && <TopPanel
{...{trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}}
/>}
</div>
{/* Editor */}
<div style={{flexGrow: 1, overflow: "auto"}}>
{editorState && conns && syntaxErrors &&
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, syntaxErrors: allErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>}
</div>
</div>
{/* Right: sidebar */}
<div style={{
flex: '0 0 content',
borderLeft: '1px solid lightgrey',
overflowY: "auto",
overflowX: "auto",
maxWidth: 'min(400px, 50vw)',
}}>
<div className="stackVertical" style={{height:'100%'}}>
<div
className={showExecutionTrace ? "shadowBelow" : ""}
style={{flex: '0 0 content', backgroundColor: ''}}
>
{/* State tree */}
<PersistentDetailsLocalStorage localStorageKey="showStateTree" initiallyOpen={true}>
<summary>state tree</summary>
<ul>
{ast && <ShowAST {...{...ast, trace, highlightActive}}/>}
</ul>
</PersistentDetailsLocalStorage>
{/* Input events */}
<PersistentDetailsLocalStorage localStorageKey="showInputEvents" initiallyOpen={true}>
<summary>input events</summary>
{ast && <ShowInputEvents
inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("debug."+e,p)}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={showKeys}/>}
</PersistentDetailsLocalStorage>
{/* Internal events */}
<PersistentDetailsLocalStorage localStorageKey="showInternalEvents" initiallyOpen={true}>
<summary>internal events</summary>
{ast && <ShowInternalEvents internalEvents={ast.internalEvents}/>}
</PersistentDetailsLocalStorage>
{/* Output events */}
<PersistentDetailsLocalStorage localStorageKey="showOutputEvents" initiallyOpen={true}>
<summary>output events</summary>
{ast && <ShowOutputEvents outputEvents={ast.outputEvents}/>}
</PersistentDetailsLocalStorage>
{/* Plant */}
<PersistentDetailsLocalStorage localStorageKey="showPlant" initiallyOpen={true}>
<summary>plant</summary>
<select
disabled={trace!==null}
value={plantName}
onChange={e => setPlantName(() => e.target.value)}>
{plants.map(([plantName, p]) =>
<option>{plantName}</option>
)}
</select>
<br/>
{/* Render plant */}
{<plant.render state={plant.cleanupState(plantState)} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
/>}
</PersistentDetailsLocalStorage>
{/* Connections */}
<PersistentDetails state={showConnections} setState={setShowConnections}>
<summary>connections</summary>
<button title="auto-connect (name-based)" className={autoConnect?"active":""}
onClick={() => setAutoConnect(c => !c)}>
<AutoAwesomeIcon fontSize="small"/>
</button>
{ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
</PersistentDetails>
{/* Properties */}
<details open={showProperties} onToggle={e => setShowProperties(e.newState === "open")}>
<summary>properties</summary>
{plant && <div>
available signals:
&nbsp;
{plant.signals.join(', ')}
</div>}
{properties.map((property, i) => {
const result = propertyResults && propertyResults[i];
let violated = null, propertyError = null;
if (result) {
violated = result[0] && result[0].length > 0 && !result[0][0].satisfied;
propertyError = result[1];
}
return <div style={{width:'100%'}} key={i} className="toolbar">
<div className={"status" + (violated === null ? "" : (violated ? " violated" : " satisfied"))}></div>
<button title="see in trace (below)" className={activeProperty === i ? "active" : ""} onClick={() => setActiveProperty(i)}>
<VisibilityIcon fontSize="small"/>
</button>
<input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
<button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
{propertyError && <div style={{color: 'var(--error-color)'}}>{propertyError}</div>}
</div>;
})}
<div className="toolbar">
<button title="add property" onClick={() => setProperties(properties => [...properties, ""])}>
<AddIcon fontSize="small"/> add property
</button>
</div>
</details>
{/* Traces */}
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary>
{appState.showFindReplace &&
<div>
{savedTraces.map((savedTrace, i) =>
<div key={i} className="toolbar">
<button title="replay trace (may give a different result if you changed your model since recording the trace because only input and timer events are recorded)" onClick={() => onReplayTrace(savedTrace[1])}>
<CachedOutlinedIcon fontSize="small"/>
</button>
&nbsp;
<span style={{display:'inline-block', width: 26, fontSize: 9}}>{(Math.floor(savedTrace[1].at(-1)!.simtime/1000))}s</span>
<span style={{display:'inline-block', width: 22, fontSize: 9}}>({savedTrace[1].length})</span>
&nbsp;
<input title="name of the trace (only for humans - names don't have to be unique or anything)" type="text" value={savedTrace[0]} style={{width: 'calc(100% - 124px)'}} onChange={e => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1, [e.target.value, savedTraces[i][1]]))}/>
<button title="forget trace" onClick={() => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
</div>
)}
<FindReplace setCS={setEditorState} hide={() => setters.setShowFindReplace(false)}/>
</div>
<div className="toolbar">
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/>
<label title="plant steps are steps where only the state of the plant changed" htmlFor="checkbox-show-plant-items">show plant steps</label>
<input id="checkbox-autoscroll" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)}/>
<label title="automatically scroll down event trace when new events occur" htmlFor="checkbox-autoscroll">auto-scroll</label>
&emsp;
<button title="save current trace" disabled={trace === null} onClick={() => onSaveTrace()}>
<SaveOutlinedIcon fontSize="small"/> save trace
</button>
</div>
</details>
}
</div>
{/* We cheat a bit, and render the execution trace depending on whether the <details> above is 'open' or not, rather than putting it as a child of the <details>. We do this because only then can we get the execution trace to scroll without the rest scrolling as well. */}
{showExecutionTrace &&
<div style={{
flexGrow:1,
overflow:'auto',
minHeight: '50vh',
// minHeight: '75%', // <-- allows us to always scroll down the sidebar far enough such that the execution history is enough in view
}}>
<div ref={refRightSideBar}>
{ast && <RTHistory {...{ast, trace, setTrace, setTime, showPlantTrace,
propertyTrace: propertyResults && propertyResults[activeProperty] && propertyResults[activeProperty][0] || []}}/>}
</div>
</div>}
<div style={{flex: '0 0 content'}}>
{/* Right: sidebar */}
<div style={{
flex: '0 0 content',
borderLeft: '1px solid var(--separator-color)',
overflowY: "auto",
overflowX: "auto",
maxWidth: 'min(400px, 50vw)',
}}>
<div className="stackVertical" style={{height:'100%'}}>
<SideBar {...{...appState, refRightSideBar, ast, plantState, ...simulator, ...setters}} />
</div>
</div>
</div>
{/* Bottom panel */}
<div style={{flex: '0 0 content'}}>
{syntaxErrors && <BottomPanel {...{errors: syntaxErrors, ...appState, setEditorState, ...setters}}/>}
</div>
</div>
</div>
{/* Bottom panel */}
<div style={{flex: '0 0 content'}}>
{syntaxErrors && <BottomPanel {...{errors: syntaxErrors}}/>}
</div>
</div>
</>;
}
function autoDetectConns(ast: Statechart, plant: Plant<any, any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
for (const {event: a} of plant.uiEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['plant', b]}));
break;
}
}
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['sc', b]}));
}
}
}
for (const a of ast.outputEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['sc.'+a]: ['plant', b]}));
}
}
}
for (const {event: a} of plant.outputEvents) {
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.'+a]: ['sc', b]}));
}
}
}
}
function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns, setPlantConns: Dispatch<SetStateAction<Conns>>) {
const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</>
const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>;
return <>
{/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-sc-${e}`} style={{width:'50%'}}>sc.{e}&nbsp;&nbsp;</label>
<select id={`select-dst-sc-${e}`}
style={{width:'50%'}}
value={plantConns['sc.'+e]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`sc.${e}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{plantInputs}
</select>
</div>)}
{/* Plant output events can go to Statechart */}
{[...plant.outputEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-${e.event}`} style={{width:'50%'}}>plant.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.'+e.event]?.join('.')}
// @ts-ignore
onChange={(domEvent => setPlantConns(conns => ({...conns, [`plant.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))})))}>
<option key="none" value=""></option>
{scInputs}
</select>
</div>)]}
{/* Plant UI events typically go to the Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%', color: 'grey'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.ui.'+e.event]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
</>;
</ModalOverlay>
</div>;
}
export default App;

View file

@ -1,10 +0,0 @@
.errorStatus {
/* background-color: rgb(230,0,0); */
background-color: var(--error-color);
color: white;
}
.bottom {
border-top: 1px lightgrey solid;
background-color: rgb(255, 249, 235);
}

View file

@ -1,39 +0,0 @@
import { useEffect, useState } from "react";
import { TraceableError } from "../statecharts/parser";
import "./BottomPanel.css";
import logo from "../../artwork/logo-playful.svg";
import { PersistentDetailsLocalStorage } from "./PersistentDetails";
export function BottomPanel(props: {errors: TraceableError[]}) {
const [greeting, setGreeting] = useState(
<div style={{textAlign:'center'}}>
<span style={{fontSize: 18, fontStyle: 'italic'}}>
Welcome to <img src={logo} style={{maxWidth:'100%'}}/>
</span>
</div>);
useEffect(() => {
setTimeout(() => {
setGreeting(<></>);
}, 2000);
}, []);
return <div className="toolbar bottom">
{greeting}
{props.errors.length > 0 &&
<div className="errorStatus">
<PersistentDetailsLocalStorage initiallyOpen={false} localStorageKey="errorsExpanded">
<summary>{props.errors.length} errors</summary>
<div style={{maxHeight: '25vh', overflow: 'auto'}}>
{props.errors.map(({message, shapeUid})=>
<div>
{shapeUid}: {message}
</div>)}
</div>
</PersistentDetailsLocalStorage>
</div>
}
</div>;
}

View file

@ -0,0 +1,15 @@
.errorStatus {
/* background-color: rgb(230,0,0); */
background-color: var(--error-color);
color: var(--background-color);
}
.greeter {
/* border-top: 1px var(--separator-color) solid; */
background-color: var(--greeter-bg-color);
}
.bottom {
border-top: 1px var(--separator-color) solid;
background-color: var(--bottom-panel-bg-color);
}

View file

@ -0,0 +1,48 @@
import { Dispatch, useEffect, useState } from "react";
import { TraceableError } from "../../statecharts/parser";
import "./BottomPanel.css";
import { PersistentDetailsLocalStorage } from "../Components/PersistentDetails";
import { Logo } from "@/App/Logo/Logo";
import { AppState } from "../App";
import { FindReplace } from "./FindReplace";
import { VisualEditorState } from "../VisualEditor/VisualEditor";
import { Setters } from "../makePartialSetter";
import gitRev from "@/git-rev.txt";
export function BottomPanel(props: {errors: TraceableError[], setEditorState: Dispatch<(state: VisualEditorState) => VisualEditorState>} & AppState & Setters<AppState>) {
const [greeting, setGreeting] = useState(
<div className="greeter" style={{textAlign:'center'}}>
<span style={{fontSize: 18, fontStyle: 'italic'}}>
Welcome to <Logo/>
</span>
</div>);
useEffect(() => {
setTimeout(() => {
setGreeting(<></>);
}, 2000);
}, []);
return <div className="toolbar bottom">
{/* {props.showFindReplace &&
<div>
<FindReplace setCS={props.setEditorState} hide={() => props.setShowFindReplace(false)}/>
</div>
} */}
<div className={"statusBar" + props.errors.length ? " error" : ""}>
<PersistentDetailsLocalStorage initiallyOpen={false} localStorageKey="errorsExpanded">
<summary>{props.errors.length} errors</summary>
<div style={{maxHeight: '25vh', overflow: 'auto'}}>
{props.errors.map(({message, shapeUid})=>
<div>
{shapeUid}: {message}
</div>)}
</div>
</PersistentDetailsLocalStorage>
</div>
{greeting}
</div>;
}

View file

@ -0,0 +1,48 @@
import { Dispatch, useCallback, useEffect } from "react";
import { VisualEditorState } from "../VisualEditor/VisualEditor";
import { usePersistentState } from "@/hooks/usePersistentState";
import CloseIcon from '@mui/icons-material/Close';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import { useShortcuts } from "@/hooks/useShortcuts";
type FindReplaceProps = {
setCS: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
// setModal: (modal: null) => void;
hide: () => void,
};
export function FindReplace({setCS, hide}: FindReplaceProps) {
const [findTxt, setFindText] = usePersistentState("findTxt", "");
const [replaceTxt, setReplaceTxt] = usePersistentState("replaceTxt", "");
const onReplace = useCallback(() => {
setCS(cs => {
return {
...cs,
texts: cs.texts.map(txt => ({
...txt,
text: txt.text.replaceAll(findTxt, replaceTxt)
})),
};
});
}, [findTxt, replaceTxt]);
useShortcuts([
{keys: ["Enter"], action: onReplace},
])
const onSwap = useCallback(() => {
setReplaceTxt(findTxt);
setFindText(replaceTxt);
}, [findTxt, replaceTxt]);
return <div className="toolbar toolbarGroup" style={{display: 'flex'}}>
<input placeholder="find" value={findTxt} onChange={e => setFindText(e.target.value)} style={{width:300}}/>
<button tabIndex={-1} onClick={onSwap}><SwapHorizIcon fontSize="small"/></button>
<input tabIndex={0} placeholder="replace" value={replaceTxt} onChange={(e => setReplaceTxt(e.target.value))} style={{width:300}}/>
&nbsp;
<button onClick={onReplace}>replace all</button>
<button onClick={hide} style={{marginLeft: 'auto'}}><CloseIcon fontSize="small"/></button>
</div>;
}

View file

@ -1,4 +1,4 @@
import { usePersistentState } from "@/App/persistent_state"
import { usePersistentState } from "@/hooks/usePersistentState"
import { DetailsHTMLAttributes, Dispatch, PropsWithChildren, SetStateAction } from "react";
type Props = {

View file

@ -0,0 +1,5 @@
import { ButtonHTMLAttributes, PropsWithChildren } from "react";
export function TwoStateButton({active, children, className, ...rest}: PropsWithChildren<{active: boolean} & ButtonHTMLAttributes<HTMLButtonElement>>) {
return <button className={(className||"") + (active?" active":"")} {...rest}>{children}</button>
}

1578
src/App/Logo/Logo.tsx Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,9 @@
import { Logo } from "@/App/Logo/Logo";
import { Dispatch, ReactElement, SetStateAction } from "react";
import logo from "../../../artwork/logo-playful.svg";
export function About(props: {setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
return <div style={{maxWidth: '500px', padding: 4}}>
<p><img src={logo} style={{maxWidth:'100%'}}/></p>
<p><Logo/></p>
<p>StateBuddy is an <a target="_blank" href="https://deemz.org/git/research/statebuddy">open source</a> tool for <a target="_blank" href="https://dl.acm.org/doi/10.1016/0167-6423(87)90035-9">Statechart</a> editing, simulation, (omniscient) debugging and testing.</p>

View file

@ -1,24 +1,20 @@
import { Dispatch, ReactElement, SetStateAction, useState, KeyboardEvent, useEffect, useRef } from "react";
import { Dispatch, ReactElement, SetStateAction, useState, useCallback } from "react";
import { cachedParseLabel } from "@/statecharts/parser";
import { useShortcuts } from "@/hooks/useShortcuts";
export function TextDialog(props: {setModal: Dispatch<SetStateAction<ReactElement|null>>, text: string, done: (newText: string|undefined) => void}) {
const [text, setText] = useState(props.text);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter") {
if (!e.shiftKey) {
e.preventDefault();
useShortcuts([
{keys: ["Enter"], action: useCallback(() => {
props.done(text);
props.setModal(null);
}
}
if (e.key === "Escape") {
props.setModal(null);
e.stopPropagation();
}
e.stopPropagation();
}
}, [text, props.done, props.setModal])},
{keys: ["Escape"], action: useCallback(() => {
props.setModal(null);
}, [props.setModal])},
], false);
let parseError = "";
try {
@ -28,13 +24,15 @@ export function TextDialog(props: {setModal: Dispatch<SetStateAction<ReactElemen
parseError = e.message;
}
return <div onKeyDown={onKeyDown} style={{padding: 4}}>
Text label:<br/>
return <div style={{padding: 4}}>
{/* Text label:<br/> */}
<textarea autoFocus style={{fontFamily: 'Roboto', width: 400, height: 60}} onChange={e=>setText(e.target.value)} value={text} onFocus={e => e.target.select()}/>
<br/>
<span style={{color: 'var(--error-color)'}}>{parseError}</span><br/>
<p><kbd>Enter</kbd> to confirm. <kbd>Esc</kbd> to cancel.
</p>
(Tip: <kbd>Shift</kbd>+<kbd>Enter</kbd> to insert newline.)
{/* <p> */}
<kbd>Enter</kbd> to confirm. <kbd>Esc</kbd> to cancel.
{/* </p> */}
{/* <br/> */}
{/* (Tip: <kbd>Shift</kbd>+<kbd>Enter</kbd> to insert newline.) */}
</div>;
}

View file

@ -0,0 +1,17 @@
import { Dispatch, PropsWithChildren, ReactElement, SetStateAction } from "react";
export function ModalOverlay(props: PropsWithChildren<{modal: ReactElement|null, setModal: Dispatch<SetStateAction<ReactElement|null>>}>) {
return <>
{props.modal && <div
className="modalOuter"
onMouseDown={() => props.setModal(null)}>
<div className="modalInner">
<span onMouseDown={e => e.stopPropagation()}>
{props.modal}
</span>
</div>
</div>}
{props.children}
</>;
}

View file

@ -0,0 +1,14 @@
// import { Dispatch, PropsWithChildren, ReactElement, SetStateAction } from "react";
// import { OverlayWindow } from "../App";
// export function WindowOverlay(props: PropsWithChildren<{overlayWindows: OverlayWindow[]}>) {
// return <>
// {props.modal && <div
// className="modalOuter"
// onMouseDown={() => props.setModal(null)}>
// </div>}
// {props.children}
// </>;
// }

View file

@ -1,5 +1,5 @@
import { useAudioContext } from "@/App/useAudioContext";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { useAudioContext } from "@/hooks/useAudioContext";
import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import { RT_Statechart } from "@/statecharts/runtime_types";

View file

@ -12,13 +12,13 @@ import { RT_Statechart } from "@/statecharts/runtime_types";
import { memo, useEffect } from "react";
import "./Microwave.css";
import { useAudioContext } from "../../useAudioContext";
import { useAudioContext } from "../../../hooks/useAudioContext";
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import microwaveConcreteSyntax from "./model.json";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { objectsEqual } from "@/util/util";
export const [microwaveAbstractSyntax, microwaveErrors] = parseStatechart(microwaveConcreteSyntax as ConcreteSyntax, detectConnections(microwaveConcreteSyntax as ConcreteSyntax));
@ -92,7 +92,7 @@ export const Microwave = memo(function Microwave({state: {bellRinging, magnetron
src: url(${fontDigital});
}
`}</style>
<svg width='400px' height='auto' viewBox="0 0 520 348">
<svg width='380px' height='auto' viewBox="0 0 520 348">
{/* @ts-ignore */}
<image xlinkHref={imgs[doorOpen][magnetronRunning]} width={520} height={348}/>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -3,7 +3,6 @@ import { Statechart } from "@/statecharts/abstract_syntax";
import { EventTrigger } from "@/statecharts/label_ast";
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
import { setsEqual } from "@/util/util";
export type PlantRenderProps<StateType> = {
state: StateType,

View file

@ -9,11 +9,11 @@ import { preload } from "react-dom";
import trafficLightConcreteSyntax from "./model.json";
import { parseStatechart } from "@/statecharts/parser";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { RT_Statechart } from "@/statecharts/runtime_types";
import { useAudioContext } from "@/App/useAudioContext";
import { useAudioContext } from "@/hooks/useAudioContext";
import { memo, useEffect } from "react";
import { objectsEqual } from "@/util/util";

View file

@ -1,10 +1,10 @@
import { Dispatch, memo, SetStateAction, useCallback } from "react";
import { Statechart, stateDescription } from "../statecharts/abstract_syntax";
import { Mode, RaisedEvent, RT_Event } from "../statecharts/runtime_types";
import { formatTime } from "../util/util";
import { TimeMode, timeTravel } from "../statecharts/time";
import { BigStepCause, TraceItem, TraceState } from "./App";
import { Statechart, stateDescription } from "../../statecharts/abstract_syntax";
import { Mode, RaisedEvent, RT_Event } from "../../statecharts/runtime_types";
import { formatTime } from "../../util/util";
import { TimeMode, timeTravel } from "../../statecharts/time";
import { Environment } from "@/statecharts/environment";
import { BigStepCause, TraceItem, TraceState } from "../hooks/useSimulator";
type RTHistoryProps = {
trace: TraceState|null,

View file

@ -1,7 +1,10 @@
import { ConcreteState, UnstableState, stateDescription, Transition } from "../statecharts/abstract_syntax";
import { Action, EventTrigger, Expression } from "../statecharts/label_ast";
import "./AST.css";
import BoltIcon from '@mui/icons-material/Bolt';
import { memo, useEffect } from "react";
import { usePersistentState } from "../../hooks/usePersistentState";
import { ConcreteState, stateDescription, Transition, UnstableState } from "../../statecharts/abstract_syntax";
import { Action, EventTrigger, Expression } from "../../statecharts/label_ast";
import { KeyInfoHidden, KeyInfoVisible } from "../TopPanel/KeyInfo";
import { useShortcuts } from '@/hooks/useShortcuts';
export function ShowTransition(props: {transition: Transition}) {
return <> {stateDescription(props.transition.tgt)}</>;
@ -46,12 +49,8 @@ export const ShowAST = memo(function ShowASTx(props: {root: ConcreteState | Unst
</li>;
});
import BoltIcon from '@mui/icons-material/Bolt';
import { KeyInfoHidden, KeyInfoVisible } from "./TopPanel/KeyInfo";
import { memo, useEffect } from "react";
import { usePersistentState } from "./persistent_state";
export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean, showKeys: boolean}) {
export function ShowInputEvents({inputEvents, onRaise, disabled}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean}) {
const raiseHandlers = inputEvents.map(({event}) => {
return () => {
// @ts-ignore
@ -69,23 +68,16 @@ export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inp
onRaise(event, paramParsed);
};
});
const onKeyDown = (e: KeyboardEvent) => {
// don't capture keyboard events when focused on an input element:
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
const n = (parseInt(e.key)+9) % 10;
if (raiseHandlers[n] !== undefined) {
raiseHandlers[n]();
e.stopPropagation();
e.preventDefault();
}
}
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [raiseHandlers]);
// const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
const shortcutSpec = raiseHandlers.map((handler, i) => {
const n = (i+1)%10;
return {
keys: [n.toString()],
action: handler,
};
});
useShortcuts(shortcutSpec);
const KeyInfo = KeyInfoVisible; // always show keyboard shortcuts on input events, we can't expect the user to remember them
const [inputParams, setInputParams] = usePersistentState<{[eventName:string]: string}>("inputParams", {});

View file

@ -0,0 +1,86 @@
details > summary {
padding: 2px;
}
details > summary:hover {
background-color: var(--summary-hover-bg-color);
cursor: default;
}
.errorStatus details > summary:hover {
background-color: var(--error-hover-bg-color);
}
.stateTree > * {
padding-left: 10px;
background-color: white;
}
.outputEvent {
cursor: default;
border: 1px var(--separator-color) solid;
border-radius: 6px;
padding-left: 2px;
padding-right: 2px;
background-color: var(--output-event-bg-color);
color: var(--text-color);
display: inline-block;
}
.internalEvent {
cursor: default;
border: 1px var(--separator-color) solid;
border-radius: 6px;
padding-left: 2px;
padding-right: 2px;
background-color: var(--internal-event-bg-color);
color: var(--text-color);
display: inline-block;
}
.inputEvent {
border: 1px var(--separator-color) solid;
border-radius: 6px;
padding-left: 2px;
padding-right: 2px;
background-color: var(--input-event-bg-color);
color: var(--text-color);
display: inline-block;
}
.inputEvent:disabled {
color: darkgrey;
}
.inputEvent * {
vertical-align: middle;
}
button.inputEvent:hover:not(:disabled) {
background-color: var(--input-event-hover-bg-color);
}
button.inputEvent:active:not(:disabled) {
background-color: var(--input-event-active-bg-color);
}
.activeState {
border: var(--active-state-border-color) 1px solid;
background-color: var(--active-state-bg-color);
border-radius: 6px;
margin-left: 4px;
padding-left: 2px;
padding-right: 2px;
display: inline-block;
color: var(--text-color);
}
ul {
list-style-type: circle;
margin-block-start: 0;
margin-block-end: 0;
padding-inline-start: 24px;
/* list-style-position: ; */
}
.shadowBelow {
box-shadow: 0 -5px 5px 5px rgba(0, 0, 0, 0.4);
z-index: 1;
}

332
src/App/SideBar/SideBar.tsx Normal file
View file

@ -0,0 +1,332 @@
import AddIcon from '@mui/icons-material/Add';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import CachedOutlinedIcon from '@mui/icons-material/CachedOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { Conns } from '@/statecharts/timed_reactive';
import { Dispatch, Ref, SetStateAction, useEffect, useRef, useState } from 'react';
import { Statechart } from '@/statecharts/abstract_syntax';
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from './ShowAST';
import { Plant } from '../Plant/Plant';
import { checkProperty, PropertyCheckResult } from './check_property';
import { Setters } from '../makePartialSetter';
import { RTHistory } from './RTHistory';
import { BigStepCause, TraceState } from '../hooks/useSimulator';
import { plants, UniversalPlantState } from '../plants';
import { TimeMode } from '@/statecharts/time';
import { PersistentDetails } from '../Components/PersistentDetails';
import "./SideBar.css";
type SavedTraces = [string, BigStepCause[]][];
export type SideBarState = {
showStateTree: boolean,
showInputEvents: boolean,
showInternalEvents: boolean,
showOutputEvents: boolean,
showPlant: boolean,
showConnections: boolean,
showProperties: boolean,
showExecutionTrace: boolean,
plantName: string,
plantConns: Conns,
autoConnect: boolean,
properties: string[],
activeProperty: number,
savedTraces: SavedTraces,
autoScroll: boolean,
showPlantTrace: boolean,
};
export const defaultSideBarState = {
showStateTree: false,
showInputEvents: true,
showInternalEvents: true,
showOutputEvents: true,
showPlant: true,
showConnections: false,
showProperties: false,
showExecutionTrace: true,
plantName: 'dummy',
plantConns: {},
autoConnect: true,
properties: [],
activeProperty: 0,
savedTraces: [],
autoScroll: false,
showPlantTrace: false,
};
type SideBarProps = SideBarState & {
refRightSideBar: Ref<HTMLDivElement>,
ast: Statechart | null,
plant: Plant<any, UniversalPlantState>,
// setSavedTraces: Dispatch<SetStateAction<SavedTraces>>,
trace: TraceState|null,
setTrace: Dispatch<SetStateAction<TraceState|null>>,
plantState: UniversalPlantState,
onRaise: (inputEvent: string, param: any) => void,
onReplayTrace: (causes: BigStepCause[]) => void,
setTime: Dispatch<SetStateAction<TimeMode>>,
time: TimeMode,
} & Setters<SideBarState>;
export function SideBar({showExecutionTrace, showConnections, plantName, showPlantTrace, showProperties, activeProperty, autoConnect, autoScroll, plantConns, properties, savedTraces, refRightSideBar, ast, plant, setSavedTraces, trace, setTrace, setProperties, setShowPlantTrace, setActiveProperty, setPlantConns, setPlantName, setAutoConnect, setShowProperties, setAutoScroll, time, plantState, onReplayTrace, onRaise, setTime, setShowConnections, setShowExecutionTrace, showPlant, setShowPlant, showOutputEvents, setShowOutputEvents, setShowInternalEvents, showInternalEvents, setShowInputEvents, setShowStateTree, showInputEvents, showStateTree}: SideBarProps) {
const [propertyResults, setPropertyResults] = useState<PropertyCheckResult[] | null>(null);
const speed = time.kind === "paused" ? 0 : time.scale;
const onSaveTrace = () => {
if (trace) {
setSavedTraces(savedTraces => [
...savedTraces,
["untitled", trace.trace.map((item) => item.cause)] as [string, BigStepCause[]],
]);
}
}
// if some properties change, re-evaluate them:
useEffect(() => {
let timeout: NodeJS.Timeout;
if (trace) {
setPropertyResults(null);
timeout = setTimeout(() => {
Promise.all(properties.map((property, i) => {
return checkProperty(plant, property, trace.trace);
}))
.then(results => {
setPropertyResults(results);
})
})
}
return () => clearTimeout(timeout);
}, [properties, trace, plant]);
// whenever the ast, the plant or 'autoconnect' option changes, detect connections:
useEffect(() => {
if (ast && autoConnect) {
autoDetectConns(ast, plant, setPlantConns);
}
}, [ast, plant, autoConnect]);
return <>
<div
className={showExecutionTrace ? "shadowBelow" : ""}
style={{flex: '0 0 content', backgroundColor: ''}}
>
{/* State tree */}
<PersistentDetails state={showStateTree} setState={setShowStateTree}>
<summary>state tree</summary>
<ul>
{ast && <ShowAST {...{...ast, trace, highlightActive: new Set()}}/>}
</ul>
</PersistentDetails>
{/* Input events */}
<PersistentDetails state={showInputEvents} setState={setShowInputEvents}>
<summary>input events</summary>
{ast && <ShowInputEvents
inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("debug."+e,p)}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
/>}
</PersistentDetails>
{/* Internal events */}
<PersistentDetails state={showInternalEvents} setState={setShowInternalEvents}>
<summary>internal events</summary>
{ast && <ShowInternalEvents internalEvents={ast.internalEvents}/>}
</PersistentDetails>
{/* Output events */}
<PersistentDetails state={showOutputEvents} setState={setShowOutputEvents}>
<summary>output events</summary>
{ast && <ShowOutputEvents outputEvents={ast.outputEvents}/>}
</PersistentDetails>
{/* Plant */}
<PersistentDetails state={showPlant} setState={setShowPlant}>
<summary>plant</summary>
<select
disabled={trace!==null}
value={plantName}
onChange={e => setPlantName(() => e.target.value)}>
{plants.map(([plantName, p]) =>
<option>{plantName}</option>
)}
</select>
<br/>
{/* Render plant */}
{<plant.render state={plant.cleanupState(plantState)} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
/>}
</PersistentDetails>
{/* Connections */}
<PersistentDetails state={showConnections} setState={setShowConnections}>
<summary>connections</summary>
<button title="auto-connect (name-based)" className={autoConnect?"active":""}
onClick={() => setAutoConnect(c => !c)}>
<AutoAwesomeIcon fontSize="small"/>
</button>
{ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
</PersistentDetails>
{/* Properties */}
<details open={showProperties} onToggle={e => setShowProperties(e.newState === "open")}>
<summary>properties</summary>
{plant && <div>
available signals:
&nbsp;
{plant.signals.join(', ')}
</div>}
{properties.map((property, i) => {
const result = propertyResults && propertyResults[i];
let violated = null, propertyError = null;
if (result) {
violated = result[0] && result[0].length > 0 && !result[0][0].satisfied;
propertyError = result[1];
}
return <div style={{width:'100%'}} key={i} className="toolbar">
<div className={"status" + (violated === null ? "" : (violated ? " violated" : " satisfied"))}></div>
<button title="see in trace (below)" className={activeProperty === i ? "active" : ""} onClick={() => setActiveProperty(i)}>
<VisibilityIcon fontSize="small"/>
</button>
<input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
<button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
{propertyError && <div style={{color: 'var(--error-color)'}}>{propertyError}</div>}
</div>;
})}
<div className="toolbar">
<button title="add property" onClick={() => setProperties(properties => [...properties, ""])}>
<AddIcon fontSize="small"/> add property
</button>
</div>
</details>
{/* Traces */}
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary>
<div>
{savedTraces.map((savedTrace, i) =>
<div key={i} className="toolbar">
<button title="replay trace (may give a different result if you changed your model since recording the trace because only input and timer events are recorded)" onClick={() => onReplayTrace(savedTrace[1])}>
<CachedOutlinedIcon fontSize="small"/>
</button>
&nbsp;
<span style={{display:'inline-block', width: 26, fontSize: 9}}>{(Math.floor(savedTrace[1].at(-1)!.simtime/1000))}s</span>
<span style={{display:'inline-block', width: 22, fontSize: 9}}>({savedTrace[1].length})</span>
&nbsp;
<input title="name of the trace (only for humans - names don't have to be unique or anything)" type="text" value={savedTrace[0]} style={{width: 'calc(100% - 124px)'}} onChange={e => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1, [e.target.value, savedTraces[i][1]]))}/>
<button title="forget trace" onClick={() => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
</div>
)}
</div>
<div className="toolbar">
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/>
<label title="plant steps are steps where only the state of the plant changed" htmlFor="checkbox-show-plant-items">show plant steps</label>
<input id="checkbox-autoscroll" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)}/>
<label title="automatically scroll down event trace when new events occur" htmlFor="checkbox-autoscroll">auto-scroll</label>
&emsp;
<button title="save current trace" disabled={trace === null} onClick={() => onSaveTrace()}>
<SaveOutlinedIcon fontSize="small"/> save trace
</button>
</div>
</details>
</div>
{/* We cheat a bit, and render the execution trace depending on whether the <details> above is 'open' or not, rather than putting it as a child of the <details>. We do this because only then can we get the execution trace to scroll without the rest scrolling as well. */}
{showExecutionTrace &&
<div style={{
flexGrow:1,
overflow:'auto',
minHeight: '50vh',
// minHeight: '75%', // <-- allows us to always scroll down the sidebar far enough such that the execution history is enough in view
}}>
<div ref={refRightSideBar}>
{ast && <RTHistory {...{ast, trace, setTrace, setTime, showPlantTrace,
propertyTrace: propertyResults && propertyResults[activeProperty] && propertyResults[activeProperty][0] || []}}/>}
</div>
</div>}
</>;
}
function autoDetectConns(ast: Statechart, plant: Plant<any, any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
for (const {event: a} of plant.uiEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['plant', b]}));
break;
}
}
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['sc', b]}));
}
}
}
for (const a of ast.outputEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['sc.'+a]: ['plant', b]}));
}
}
}
for (const {event: a} of plant.outputEvents) {
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.'+a]: ['sc', b]}));
}
}
}
}
function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns, setPlantConns: Dispatch<SetStateAction<Conns>>) {
const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</>
const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>;
return <>
{/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-sc-${e}`} style={{width:'50%'}}>sc.{e}&nbsp;&nbsp;</label>
<select id={`select-dst-sc-${e}`}
style={{width:'50%'}}
value={plantConns['sc.'+e]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`sc.${e}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{plantInputs}
</select>
</div>)}
{/* Plant output events can go to Statechart */}
{[...plant.outputEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-${e.event}`} style={{width:'50%'}}>plant.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.'+e.event]?.join('.')}
// @ts-ignore
onChange={(domEvent => setPlantConns(conns => ({...conns, [`plant.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))})))}>
<option key="none" value=""></option>
{scInputs}
</select>
</div>)]}
{/* Plant UI events typically go to the Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%', color: 'grey'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.ui.'+e.event]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
</>;
}

View file

@ -1,6 +1,6 @@
import { RT_Statechart } from "@/statecharts/runtime_types";
import { TraceItem } from "./App";
import { Plant } from "./Plant/Plant";
import { Plant } from "../Plant/Plant";
import { TraceItem } from "../hooks/useSimulator";
// const endpoint = "http://localhost:15478/check_property";
const endpoint = "https://deemz.org/apis/mtl-aas/check_property";
@ -36,7 +36,10 @@ export async function checkProperty(plant: Plant<RT_Statechart, any>, property:
return [entry];
}, [] as {simtime: number, state: any}[]);
let traces = {} as {[key: string]: [number, any][]};
let traces = {
'true': [[0, true] as [number, any]],
'false': [[0, false] as [number, any]],
} as {[key: string]: [number, any][]};
for (const {simtime, state} of cleanPlantStates) {
for (const [key, value] of Object.entries(state)) {
// just append
@ -49,8 +52,6 @@ export async function checkProperty(plant: Plant<RT_Statechart, any>, property:
}
}
console.log({cleanPlantStates, traces});
try {
const response = await fetch(endpoint, {
method: "POST",

View file

@ -18,12 +18,19 @@ export function PseudoStateIcon(props: {}) {
${w - 1} ${h / 2},
${w / 2} ${h - 1},
${1} ${h / 2},
`} fill="white" stroke="black" strokeWidth={1.2} />
`}
style={{
fill: 'var(--and-state-bg-color',
stroke: 'var(--rountangle-stroke-color',
}} strokeWidth={1.2} />
</svg>;
}
export function HistoryIcon(props: { kind: "shallow" | "deep"; }) {
const w = 20, h = 20;
const text = props.kind === "shallow" ? "H" : "H*";
return <svg width={w} height={h}><circle cx={w / 2} cy={h / 2} r={Math.min(w, h) / 2 - 1} fill="white" stroke="black" /><text x={w / 2} y={h / 2 + 4} textAnchor="middle" fontSize={11} fontWeight={400}>{text}</text></svg>;
return <svg width={w} height={h}><circle cx={w / 2} cy={h / 2} r={Math.min(w, h) / 2 - 1} style={{
fill: 'var(--and-state-bg-color',
stroke: 'var(--rountangle-stroke-color',
}}/><text x={w / 2} y={h / 2 + 4} style={{fill: 'var(--rountangle-stroke-color'}} textAnchor="middle" fontSize={11} fontWeight={400}>{text}</text></svg>;
}

View file

@ -3,6 +3,7 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
import { useShortcuts } from "@/hooks/useShortcuts";
export type InsertMode = "and" | "or" | "pseudo" | "shallow" | "deep" | "transition" | "text";
@ -18,45 +19,14 @@ const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
export const InsertModes = memo(function InsertModes({showKeys, insertMode, setInsertMode}: {showKeys: boolean, insertMode: InsertMode, setInsertMode: Dispatch<SetStateAction<InsertMode>>}) {
const onKeyDown = useCallback((e: KeyboardEvent) => {
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (!e.ctrlKey) {
if (e.key === "a") {
e.preventDefault();
setInsertMode("and");
}
if (e.key === "o") {
e.preventDefault();
setInsertMode("or");
}
if (e.key === "p") {
e.preventDefault();
setInsertMode("pseudo");
}
if (e.key === "t") {
e.preventDefault();
setInsertMode("transition");
}
if (e.key === "x") {
e.preventDefault();
setInsertMode("text");
}
if (e.key === "h") {
e.preventDefault();
setInsertMode(oldMode => {
if (oldMode === "shallow") return "deep";
return "shallow";
})
}
}
}, [setInsertMode]);
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
() => window.removeEventListener("keydown", onKeyDown);
}, [onKeyDown]);
useShortcuts([
{keys: ["a"], action: () => setInsertMode("and")},
{keys: ["o"], action: () => setInsertMode("or")},
{keys: ["p"], action: () => setInsertMode("pseudo")},
{keys: ["t"], action: () => setInsertMode("transition")},
{keys: ["x"], action: () => setInsertMode("text")},
{keys: ["h"], action: () => setInsertMode(mode => mode === "shallow" ? "deep" : "shallow")},
]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => <KeyInfo key={m} keyInfo={keyInfo}>

View file

@ -2,6 +2,9 @@ import { Dispatch, memo, SetStateAction, useCallback, useEffect } from "react";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { setRealtime, TimeMode } from "@/statecharts/time";
import SpeedIcon from '@mui/icons-material/Speed';
import { useShortcuts } from "@/hooks/useShortcuts";
export const SpeedControl = memo(function SpeedControl({showKeys, timescale, setTimescale, setTime}: {showKeys: boolean, timescale: number, setTimescale: Dispatch<SetStateAction<number>>, setTime: Dispatch<SetStateAction<TimeMode>>}) {
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
@ -29,29 +32,14 @@ export const SpeedControl = memo(function SpeedControl({showKeys, timescale, set
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
}, [onTimeScaleChange, timescale]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (!e.ctrlKey) {
if (e.key === "s") {
e.preventDefault();
onSlower();
}
if (e.key === "f") {
e.preventDefault();
onFaster();
}
}
}, [onSlower, onFaster])
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onKeyDown])
useShortcuts([
{keys: ["s"], action: onSlower},
{keys: ["f"], action: onFaster},
]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>
<label htmlFor="number-timescale">speed</label>&nbsp;
<label htmlFor="number-timescale"><SpeedIcon fontSize="small"/></label>&nbsp;
<KeyInfo keyInfo={<kbd>S</kbd>}>
<button title="slower" onClick={onSlower}>÷2</button>
</KeyInfo>

View file

@ -1,14 +1,22 @@
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react";
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
import { InsertMode } from "./InsertModes";
import { About } from "../Modals/About";
import { EditHistory, TraceState } from "../App";
import { AppState, EditHistory, LightMode } from "../App";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { UndoRedoButtons } from "./UndoRedoButtons";
import { ZoomButtons } from "./ZoomButtons";
import { formatTime } from "../../util/util";
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import BrightnessAutoIcon from '@mui/icons-material/BrightnessAuto';
import SpeedIcon from '@mui/icons-material/Speed';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import FindInPageIcon from '@mui/icons-material/FindInPage';
import FindInPageOutlinedIcon from '@mui/icons-material/FindInPageOutlined';
import AccessAlarmIcon from '@mui/icons-material/AccessAlarm';
import CachedIcon from '@mui/icons-material/Cached';
import InfoOutlineIcon from '@mui/icons-material/InfoOutline';
@ -18,13 +26,20 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import SkipNextIcon from '@mui/icons-material/SkipNext';
import StopIcon from '@mui/icons-material/Stop';
import { InsertModes } from "./InsertModes";
import { usePersistentState } from "@/App/persistent_state";
import { usePersistentState } from "@/hooks/usePersistentState";
import { RotateButtons } from "./RotateButtons";
import { SpeedControl } from "./SpeedControl";
import { TraceState } from "../hooks/useSimulator";
import { FindReplace } from "../BottomPanel/FindReplace";
import { VisualEditorState } from "../VisualEditor/VisualEditor";
import { Setters } from "../makePartialSetter";
import { TwoStateButton } from "../Components/TwoStateButton";
import { useShortcuts } from "@/hooks/useShortcuts";
export type TopPanelProps = {
trace: TraceState | null,
time: TimeMode,
setTime: Dispatch<SetStateAction<TimeMode>>,
onUndo: () => void,
onRedo: () => void,
@ -32,32 +47,41 @@ export type TopPanelProps = {
onInit: () => void,
onClear: () => void,
onBack: () => void,
insertMode: InsertMode,
setInsertMode: Dispatch<SetStateAction<InsertMode>>,
// lightMode: LightMode,
// setLightMode: Dispatch<SetStateAction<LightMode>>,
// insertMode: InsertMode,
// setInsertMode: Dispatch<SetStateAction<InsertMode>>,
setModal: Dispatch<SetStateAction<ReactElement|null>>,
zoom: number,
setZoom: Dispatch<SetStateAction<number>>,
showKeys: boolean,
setShowKeys: Dispatch<SetStateAction<boolean>>,
// zoom: number,
// setZoom: Dispatch<SetStateAction<number>>,
// showKeys: boolean,
// setShowKeys: Dispatch<SetStateAction<boolean>>,
editHistory: EditHistory,
}
setEditorState: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
} & AppState & Setters<AppState>
const ShortCutShowKeys = <kbd>~</kbd>;
export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}: TopPanelProps) {
const [displayTime, setDisplayTime] = useState("0.000");
function toggle(booleanSetter: Dispatch<(state: boolean) => boolean>) {
return () => booleanSetter(x => !x);
}
export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory, showFindReplace, setShowFindReplace, setEditorState}: TopPanelProps) {
const [displayTime, setDisplayTime] = useState(0);
const [timescale, setTimescale] = usePersistentState("timescale", 1);
const config = trace && trace.trace[trace.idx];
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
const updateDisplayedTime = useCallback(() => {
const now = Math.round(performance.now());
const timeMs = getSimTime(time, now);
setDisplayTime(formatTime(timeMs));
setDisplayTime((timeMs));
}, [time, setDisplayTime]);
const formattedDisplayTime = useMemo(() => formatTime(displayTime), [displayTime]);
const lastSimTime = config?.simtime || 0;
useEffect(() => {
// This has no effect on statechart execution. In between events, the statechart is doing nothing. However, by updating the displayed time, we give the illusion of continuous progress.
const interval = setInterval(() => {
@ -98,53 +122,35 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
}
}, [nextTimedTransition, setTime]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
// don't capture keyboard events when focused on an input element:
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
useShortcuts([
{keys: ["`"], action: toggle(setShowKeys)},
{keys: ["Ctrl", "Shift", "F"], action: toggle(setShowFindReplace)},
{keys: ["i"], action: onInit},
{keys: ["c"], action: onClear},
{keys: ["Tab"], action: config && onSkip || onInit},
{keys: ["Backspace"], action: onBack},
{keys: ["Shift", "Tab"], action: onBack},
{keys: [" "], action: () => config && onChangePaused(time.kind !== "paused", Math.round(performance.now()))},
]);
if (!e.ctrlKey) {
if (e.key === " ") {
e.preventDefault();
if (config) {
onChangePaused(time.kind !== "paused", Math.round(performance.now()));
}
};
if (e.key === "i") {
e.preventDefault();
onInit();
}
if (e.key === "c") {
e.preventDefault();
onClear();
}
if (e.key === "Tab") {
if (config === null) {
onInit();
}
else {
onSkip();
}
e.preventDefault();
}
if (e.key === "`") {
e.preventDefault();
setShowKeys(show => !show);
}
if (e.key === "Backspace") {
e.preventDefault();
onBack();
}
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [config, time, onInit, onChangePaused, setShowKeys, onSkip, onBack, onClear]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <div className="toolbar">
{/* light / dark mode
<div className="toolbarGroup">
<button title="force light mode" className={lightMode==="light"?"active":""} onClick={() => setLightMode("light")}>
<LightModeIcon fontSize="small"/>
</button>
<button title="auto light / dark mode (follows system theme)" className={lightMode==="auto"?"active":""} onClick={() => setLightMode("auto")}>
<BrightnessAutoIcon fontSize="small"/>
</button>
<button title="force dark mode" className={lightMode==="dark"?"active":""} onClick={() => setLightMode("dark")}>
<DarkModeIcon fontSize="small"/>
</button>
&emsp;
</div> */}
{/* shortcuts / about */}
<div className="toolbarGroup">
<KeyInfo keyInfo={ShortCutShowKeys}>
@ -172,11 +178,26 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
&emsp;
</div>
{/* rotate */}
<div className="toolbarGroup">
<RotateButtons selection={editHistory.current.selection} onRotate={onRotate}/>
&emsp;
</div>
{/* find, replace */}
<div className="toolbarGroup">
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></>}>
<TwoStateButton
title="show find & replace"
active={showFindReplace}
onClick={() => setShowFindReplace(x => !x)}
>
<FindInPageOutlinedIcon fontSize="small"/>
</TwoStateButton>
</KeyInfo>
&emsp;
</div>
{/* execution */}
<div className="toolbarGroup">
@ -206,15 +227,20 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
{/* time, next */}
<div className="toolbarGroup">
<div className="toolbarGroup">
<label htmlFor="time">time (s)</label>&nbsp;
<input title="the current simulated time" id="time" disabled={!config} value={displayTime} readOnly={true} className="readonlyTextBox" />
<label htmlFor="time"><AccessTimeIcon fontSize="small"/></label>&nbsp;
<progress style={{position:'absolute', width: 60, marginTop: 23, height: 2, background: 'rgba(0,0,0,0)', border: 0, accentColor: 'var(--accent-border-color)', appearance: 'none'}} max={1} value={(displayTime-lastSimTime)/((nextTimedTransition?.[0]||Infinity)-lastSimTime)}/>
<input title="the current simulated time" id="time" disabled={!config} value={formattedDisplayTime} readOnly={true} className="readonlyTextBox" />
</div>
&emsp;
<div className="toolbarGroup">
<label htmlFor="next-timeout">next (s)</label>&nbsp;
<label htmlFor="next-timeout"><AccessAlarmIcon fontSize="small"/></label>&nbsp;
<input title="next point in simulated time where a timed transition may fire" id="next-timeout" disabled={!config} value={nextTimedTransition ? formatTime(nextTimedTransition[0]) : '+inf'} readOnly={true} className="readonlyTextBox"/>
<KeyInfo keyInfo={<kbd>Tab</kbd>}>
<button title="advance time just enough for the next timer to elapse" disabled={nextTimedTransition===undefined} onClick={onSkip}><SkipNextIcon fontSize="small"/><AccessAlarmIcon fontSize="small"/></button>
<button title="advance time just enough for the next timer to elapse" disabled={nextTimedTransition===undefined} onClick={onSkip}>
<SkipNextIcon fontSize="small"/>
</button>
</KeyInfo>
&emsp;
</div>

View file

@ -3,27 +3,14 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import { useShortcuts } from "@/hooks/useShortcuts";
export const UndoRedoButtons = memo(function UndoRedoButtons({showKeys, onUndo, onRedo, historyLength, futureLength}: {showKeys: boolean, onUndo: () => void, onRedo: () => void, historyLength: number, futureLength: number}) {
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.ctrlKey) {
// ctrl is down
if (e.key === "z") {
e.preventDefault();
onUndo();
}
if (e.key === "Z") {
e.preventDefault();
onRedo();
}
}
}, [onUndo, onRedo]);
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onKeyDown]);
useShortcuts([
{keys: ["Ctrl", "z"], action: onUndo},
{keys: ["Ctrl", "Shift", "Z"], action: onRedo},
])
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>

View file

@ -4,12 +4,20 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import { useShortcuts } from "@/hooks/useShortcuts";
const shortcutZoomIn = <><kbd>Ctrl</kbd>+<kbd>-</kbd></>;
const shortcutZoomOut = <><kbd>Ctrl</kbd>+<kbd>+</kbd></>;
export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}: {showKeys: boolean, zoom: number, setZoom: Dispatch<SetStateAction<number>>}) {
useShortcuts([
{keys: ["Ctrl", "+"], action: onZoomIn}, // plus on numerical keypad
{keys: ["Ctrl", "Shift", "+"], action: onZoomIn}, // plus on normal keyboard requires Shift key
{keys: ["Ctrl", "="], action: onZoomIn}, // most browsers also bind this shortcut so it would be confusing if we also did not override it
{keys: ["Ctrl", "-"], action: onZoomOut},
]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
function onZoomIn() {
@ -19,27 +27,6 @@ export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}:
setZoom(zoom => Math.max(zoom / ZOOM_STEP, ZOOM_MIN));
}
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey) {
if (e.key === "+") {
e.preventDefault();
e.stopPropagation();
onZoomIn();
}
if (e.key === "-") {
e.preventDefault();
e.stopPropagation();
onZoomOut();
}
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, []);
return <>
<KeyInfo keyInfo={shortcutZoomOut}>
<button title="zoom out" onClick={onZoomOut} disabled={zoom <= ZOOM_MIN}><ZoomOutIcon fontSize="small"/></button>

View file

@ -2,7 +2,7 @@ import { memo } from "react";
import { Arrow, ArrowPart } from "../../statecharts/concrete_syntax";
import { ArcDirection, euclideanDistance } from "../../util/geometry";
import { CORNER_HELPER_RADIUS } from "../parameters";
import { arraysEqual } from "@/util/util";
import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart[]; error: string; highlight: boolean; fired: boolean; arc: ArcDirection; initialMarker: boolean }) {
@ -81,7 +81,7 @@ export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart
</g>;
}, (prevProps, nextProps) => {
return prevProps.arrow === nextProps.arrow
return jsonDeepEqual(prevProps.arrow, nextProps.arrow)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& prevProps.highlight === nextProps.highlight
&& prevProps.error === nextProps.error

View file

@ -3,7 +3,7 @@ import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { Vec2D } from "../../util/geometry";
import { RectHelper } from "./RectHelpers";
import { memo } from "react";
import { arraysEqual } from "@/util/util";
import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const DiamondShape = memo(function DiamondShape(props: {size: Vec2D, extraAttrs: object}) {
const minSize = rountangleMinSize(props.size);
@ -14,7 +14,8 @@ export const DiamondShape = memo(function DiamondShape(props: {size: Vec2D, extr
${minSize.x/2} ${minSize.y},
${0} ${minSize.y/2}
`}
fill="white"
style={{fill: 'var(--and-state-bg-color', stroke: 'var(--rountangle-stroke-color)'}}
// fill="white"
stroke="black"
strokeWidth={2}
{...props.extraAttrs}
@ -41,7 +42,7 @@ export const DiamondSVG = memo(function DiamondSVG(props: { diamond: Diamond; se
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
</g>;
}, (prevProps, nextProps) => {
return prevProps.diamond === nextProps.diamond
return jsonDeepEqual(prevProps.diamond, nextProps.diamond)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error

View file

@ -9,8 +9,10 @@ export const HistorySVG = memo(function HistorySVG(props: {uid: string, topLeft:
cx={props.topLeft.x+HISTORY_RADIUS}
cy={props.topLeft.y+HISTORY_RADIUS}
r={HISTORY_RADIUS}
fill="white"
stroke="black"
style={{
fill: 'var(--and-state-bg-color)',
stroke: 'var(--rountangle-stroke-color)'
}}
strokeWidth={2}
data-uid={props.uid}
data-parts="history"
@ -20,6 +22,7 @@ export const HistorySVG = memo(function HistorySVG(props: {uid: string, topLeft:
y={props.topLeft.y+HISTORY_RADIUS+5}
textAnchor="middle"
fontWeight={500}
style={{fill: 'var(--rountangle-stroke-color)'}}
>{text}</text>
<circle
className="helper"

View file

@ -3,7 +3,7 @@ import { Rountangle, RectSide } from "../../statecharts/concrete_syntax";
import { ROUNTANGLE_RADIUS } from "../parameters";
import { RectHelper } from "./RectHelpers";
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { arraysEqual } from "@/util/util";
import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const RountangleSVG = memo(function RountangleSVG(props: {rountangle: Rountangle; selected: RectSide[]; highlight: RectSide[]; error?: string; active: boolean; }) {
@ -40,7 +40,7 @@ export const RountangleSVG = memo(function RountangleSVG(props: {rountangle: Rou
highlight={props.highlight} />
</g>;
}, (prevProps, nextProps) => {
return prevProps.rountangle === nextProps.rountangle
return jsonDeepEqual(prevProps.rountangle, nextProps.rountangle)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error

View file

@ -2,6 +2,7 @@ import { TextDialog } from "@/App/Modals/TextDialog";
import { TraceableError } from "../../statecharts/parser";
import {Text} from "../../statecharts/concrete_syntax";
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
import { jsonDeepEqual } from "@/util/util";
export const TextSVG = memo(function TextSVG(props: {text: Text, error: TraceableError|undefined, selected: boolean, highlight: boolean, onEdit: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
const commonProps = {
@ -44,4 +45,11 @@ export const TextSVG = memo(function TextSVG(props: {text: Text, error: Traceabl
{textNode}
<text className="draggableText helper" textAnchor="middle" data-uid={props.text.uid} data-parts="text" style={{whiteSpace: "preserve"}}>{props.text.text}</text>
</g>;
}, (prevProps, newProps) => {
return jsonDeepEqual(prevProps.text, newProps)
&& prevProps.highlight === newProps.highlight
&& prevProps.onEdit === newProps.onEdit
&& prevProps.setModal === newProps.setModal
&& prevProps.error === newProps.error
&& prevProps.selected === newProps.selected
});

View file

@ -1,6 +1,6 @@
.svgCanvas {
cursor: crosshair;
background-color: #eee;
background-color: var(--or-state-bg-color);
}
.svgCanvas.dragging {
@ -19,16 +19,15 @@
/* rectangle drawn while a selection is being made */
.selecting {
fill: blue;
fill-opacity: 0.2;
fill: var(--light-accent-color);
stroke-width: 1px;
stroke:black;
stroke: var(--accent-border-color);
stroke-dasharray: 7 6;
}
.rountangle {
fill: white;
stroke: black;
fill: var(--and-state-bg-color);
stroke: var(--rountangle-stroke-color);
stroke-width: 2px;
}
@ -39,9 +38,10 @@
stroke: var(--error-color);
}
.rountangle.active {
stroke: rgb(205, 133, 0);
stroke: var(--active-state-border-color);
/* stroke: none; */
fill:rgb(255, 240, 214);
/* fill:rgb(255, 240, 214); */
fill: var(--active-state-bg-color);
/* filter: drop-shadow( 2px 2px 2px rgba(124, 37, 10, 0.729)); */
}
@ -54,8 +54,7 @@ line.helper {
stroke-width: 16px;
}
line.helper:hover:not(:active) {
stroke: blue;
stroke-opacity: 0.2;
stroke: var(--light-accent-color);
cursor: grab;
}
@ -65,8 +64,7 @@ path.helper {
stroke-width: 16px;
}
path.helper:hover:not(:active) {
stroke: blue;
stroke-opacity: 0.2;
stroke: var(--light-accent-color);
cursor: grab;
}
@ -74,23 +72,22 @@ circle.helper {
fill: rgba(0, 0, 0, 0);
}
circle.helper:hover:not(:active) {
fill: blue;
fill-opacity: 0.2;
fill: var(--light-accent-color);
cursor: grab;
}
.rountangle.or {
stroke-dasharray: 7 6;
fill: #eee;
fill: var(--or-state-bg-color);
}
.arrow {
fill: none;
stroke: black;
stroke: var(--rountangle-stroke-color);
stroke-width: 2px;
}
.arrow.selected {
stroke: blue;
stroke: var(--accent-border-color);
stroke-width: 3px;
}
@ -110,34 +107,29 @@ circle.helper:hover:not(:active) {
}
line.selected, circle.selected {
fill: rgba(0, 0, 255, 0.2);
/* stroke-dasharray: 7 6; */
stroke: blue;
fill: var(--light-accent-color);
stroke: var(--accent-border-color);
stroke-width: 4px;
}
.draggableText.selected, .draggableText.selected:hover {
fill: blue;
fill: var(--accent-border-color);
font-weight: 600;
}
.draggableText:hover:not(:active) {
/* fill: blue; */
/* cursor: grab; */
}
text.helper {
fill: rgba(0,0,0,0);
stroke: rgba(0,0,0,0);
stroke-width: 6px;
}
text.helper:hover {
stroke: blue;
stroke-opacity: 0.2;
stroke: var(--light-accent-color);
cursor: grab;
}
.draggableText, .draggableText.highlight {
paint-order: stroke;
stroke: white;
fill: var(--text-color);
stroke: var(--background-color);
stroke-width: 4px;
stroke-linecap: butt;
stroke-linejoin: miter;
@ -146,12 +138,16 @@ text.helper:hover {
}
.draggableText.highlight:not(.selected) {
fill: green;
fill: var(--associated-color);
font-weight: 600;
}
.draggableText.selected {
fill: var(--accent-border-color);
}
.highlight:not(.selected):not(text) {
stroke: green;
stroke: var(--associated-color);
stroke-width: 3px;
fill: none;
}
@ -160,19 +156,19 @@ text.helper:hover {
stroke: var(--error-color);
}
.arrow.fired {
stroke: rgb(160 0 168);
stroke: var(--fired-transition-color);
stroke-width: 3px;
animation: blinkTransition 1s;
}
@keyframes blinkTransition {
0% {
stroke: rgb(255, 128, 9);
stroke: var(--firing-transition-color);
stroke-width: 6px;
filter: drop-shadow(0 0 5px rgba(255, 128, 9, 1));
filter: drop-shadow(0 0 5px var(--firing-transition-color));
}
100% {
stroke: rgb(160 0 168);
stroke: var(--fired-transition-color);
}
}
@ -191,5 +187,5 @@ g:hover > .errorHover {
}
text.uid {
fill: lightgrey;
fill: var(--separator-color);
}

View file

@ -1,31 +1,20 @@
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { TraceState } from "@/App/App";
import { InsertMode } from "../TopPanel/InsertModes";
import { Mode } from "@/statecharts/runtime_types";
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
import { Arrow, ArrowPart, Diamond, History, RectSide, Rountangle, Text } from "../../statecharts/concrete_syntax";
import { ArrowPart, ConcreteSyntax, Diamond, RectSide, Rountangle, Text } from "../../statecharts/concrete_syntax";
import { Connections } from "../../statecharts/detect_connections";
import { TraceableError } from "../../statecharts/parser";
import { ArcDirection, arcDirection } from "../../util/geometry";
import { InsertMode } from "../TopPanel/InsertModes";
import { ArrowSVG } from "./ArrowSVG";
import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG";
import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG";
import { useCopyPaste } from "./useCopyPaste";
import "./VisualEditor.css";
import { useMouse } from "./useMouse";
import { Selecting } from "./Selection";
export type ConcreteSyntax = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
diamonds: Diamond[];
history: History[];
};
import { useCopyPaste } from "./hooks/useCopyPaste";
import { useMouse } from "./hooks/useMouse";
export type VisualEditorState = ConcreteSyntax & {
nextID: number;
@ -33,21 +22,19 @@ export type VisualEditorState = ConcreteSyntax & {
};
export type RountangleSelectable = {
// kind: "rountangle";
parts: RectSide[];
part: RectSide;
uid: string;
}
type ArrowSelectable = {
// kind: "arrow";
parts: ArrowPart[];
part: ArrowPart;
uid: string;
}
type TextSelectable = {
parts: ["text"];
part: "text";
uid: string;
}
type HistorySelectable = {
parts: ["history"];
part: "history";
uid: string;
}
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
@ -56,22 +43,26 @@ export type Selection = Selectable[];
type VisualEditorProps = {
state: VisualEditorState,
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
commitState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
replaceState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
conns: Connections,
syntaxErrors: TraceableError[],
trace: TraceState | null,
insertMode: InsertMode,
highlightActive: Set<string>,
highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>,
makeCheckPoint: () => void;
zoom: number;
};
export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom}: VisualEditorProps) {
// While dragging, the editor is in a temporary state (a state that is not committed to the edit history). If the temporary state is not null, then this state will be what you see.
// const [temporaryState, setTemporaryState] = useState<VisualEditorState | null>(null);
// const state = temporaryState || committedState;
// uid's of selected rountangles
const selection = state.selection || [];
const selection = state.selection;
const refSVG = useRef<SVGSVGElement>(null);
@ -87,12 +78,15 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
})
});
})
}, [trace && trace.idx]);
}, [highlightTransitions]);
const {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection);
const {onCopy, onPaste, onCut} = useCopyPaste(state, commitState, selection);
const {onMouseDown, selectionRect} = useMouse(makeCheckPoint, insertMode, zoom, refSVG, state, setState, deleteSelection);
const {onMouseDown, selectionRect} = useMouse(insertMode, zoom, refSVG,
state,
commitState,
replaceState);
// for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected.
@ -115,12 +109,10 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
for (const textUid of texts) {
textsToHighlight[textUid] = true;
}
for (const part of selected.parts) {
const arrows = conns.side2ArrowMap.get(selected.uid + '/' + part) || [];
if (arrows) {
for (const [arrowPart, arrowUid] of arrows) {
arrowsToHighlight[arrowUid] = true;
}
const arrows = conns.side2ArrowMap.get(selected.uid + '/' + selected.part) || [];
if (arrows) {
for (const [arrowPart, arrowUid] of arrows) {
arrowsToHighlight[arrowUid] = true;
}
}
const arrow2 = conns.text2ArrowMap.get(selected.uid);
@ -144,13 +136,13 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
const onEditText = useCallback((text: Text, newText: string) => {
if (newText === "") {
// delete text node
setState(state => ({
commitState(state => ({
...state,
texts: state.texts.filter(t => t.uid !== text.uid),
}));
}
else {
setState(state => ({
commitState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === text.uid) {
@ -165,17 +157,14 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
}),
}));
}
}, [setState]);
// @ts-ignore
const active = trace && trace.trace[trace.idx].mode || new Set();
}, [commitState]);
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
const size = 4000*zoom;
return <svg width={size} height={size}
className={"svgCanvas"+(active.has("root")?" active":"")/*+(dragging ? " dragging" : "")*/}
className={"svgCanvas"+(highlightActive.has("root")?" active":"")}
onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()}
ref={refSVG}
@ -234,7 +223,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
return <ArrowSVG
key={arrow.uid}
arrow={arrow}
selected={selection.find(a => a.uid === arrow.uid)?.parts as ArrowPart[] || []}
selected={selection.filter(a => a.uid === arrow.uid).map(({part})=> part as ArrowPart)}
error={errors
.filter(({shapeUid}) => shapeUid === arrow.uid)
.map(({message}) => message).join(', ')}
@ -257,7 +246,7 @@ const Rountangles = memo(function Rountangles({rountangles, selection, sidesToHi
return <RountangleSVG
key={rountangle.uid}
rountangle={rountangle}
selected={selection.find(r => r.uid === rountangle.uid)?.parts as RectSide[] || []}
selected={selection.filter(r => r.uid === rountangle.uid).map(({part}) => part as RectSide)}
highlight={[...(sidesToHighlight[rountangle.uid] || []), ...(rountanglesToHighlight[rountangle.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors
.filter(({shapeUid}) => shapeUid === rountangle.uid)
@ -278,7 +267,7 @@ const Diamonds = memo(function Diamonds({diamonds, selection, sidesToHighlight,
<DiamondSVG
key={diamond.uid}
diamond={diamond}
selected={selection.find(r => r.uid === diamond.uid)?.parts as RectSide[] || []}
selected={selection.filter(r => r.uid === diamond.uid).map(({part})=>part as RectSide)}
highlight={[...(sidesToHighlight[diamond.uid] || []), ...(rountanglesToHighlight[diamond.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors
.filter(({shapeUid}) => shapeUid === diamond.uid)
@ -299,7 +288,7 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
key={txt.uid}
error={errors.find(({shapeUid}) => txt.uid === shapeUid)}
text={txt}
selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)}
selected={Boolean(selection.filter(s => s.uid === txt.uid).length)}
highlight={textsToHighlight.hasOwnProperty(txt.uid)}
onEdit={onEditText}
setModal={setModal}

View file

@ -1,18 +1,19 @@
import { Arrow, Diamond, Rountangle, Text, History } from "@/statecharts/concrete_syntax";
import { ClipboardEvent, Dispatch, SetStateAction, useCallback, useEffect } from "react";
import { Selection, VisualEditorState } from "./VisualEditor";
import { Selection, VisualEditorState } from "../VisualEditor";
import { addV2D } from "@/util/geometry";
import { useShortcuts } from "@/hooks/useShortcuts";
// const offset = {x: 40, y: 40};
const offset = {x: 0, y: 0};
export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorState, setState: Dispatch<(v:VisualEditorState) => VisualEditorState>, selection: Selection) {
export function useCopyPaste(state: VisualEditorState, commitState: Dispatch<(v:VisualEditorState) => VisualEditorState>, selection: Selection) {
const onPaste = useCallback((e: ClipboardEvent) => {
const data = e.clipboardData?.getData("text/plain");
if (data) {
try {
const parsed = JSON.parse(data);
setState(state => {
commitState(state => {
try {
let nextID = state.nextID;
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
@ -49,7 +50,6 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
];
makeCheckPoint();
return {
...state,
rountangles: [...state.rountangles, ...copiedRountangles],
@ -72,7 +72,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
}
e.preventDefault();
}
}, [setState]);
}, [commitState]);
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
const uidsToCopy = new Set(selection.map(shape => shape.uid));
@ -106,7 +106,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
}, [state, selection]);
const deleteSelection = useCallback(() => {
setState(state => ({
commitState(state => ({
...state,
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
@ -115,23 +115,11 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [],
}));
}, [setState]);
}, [commitState]);
const onKeyDown = (e: KeyboardEvent) => {
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (e.key === "Delete") {
// delete selection
makeCheckPoint();
deleteSelection();
e.preventDefault();
}
}
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
})
useShortcuts([
{keys: ["Delete"], action: deleteSelection},
])
return {onCopy, onPaste, onCut, deleteSelection};
}

View file

@ -2,20 +2,34 @@ import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { addV2D, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "@/util/geometry";
import { getBBoxInSvgCoords } from "@/util/svg_helper";
import { Dispatch, useCallback, useEffect, useState } from "react";
import { MIN_ROUNTANGLE_SIZE } from "../parameters";
import { InsertMode } from "../TopPanel/InsertModes";
import { Selecting, SelectingState } from "./Selection";
import { Selection, VisualEditorState } from "./VisualEditor";
import { MIN_ROUNTANGLE_SIZE } from "../../parameters";
import { InsertMode } from "../../TopPanel/InsertModes";
import { Selecting, SelectingState } from "../Selection";
import { Selection, VisualEditorState } from "../VisualEditor";
import { useShortcuts } from "@/hooks/useShortcuts";
export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoom: number, refSVG: {current: SVGSVGElement|null}, state: VisualEditorState, setState: Dispatch<(v: VisualEditorState) => VisualEditorState>, deleteSelection: () => void) {
export function useMouse(
insertMode: InsertMode,
zoom: number,
refSVG: {current: SVGSVGElement|null},
state: VisualEditorState,
commitState: Dispatch<(v: VisualEditorState) => VisualEditorState>,
replaceState: Dispatch<(v: VisualEditorState) => VisualEditorState>)
{
const [dragging, setDragging] = useState(false);
const [shiftOrCtrlPressed, setShiftOrCtrlPressed] = useState(false);
// not null while the user is making a selection
const [selectingState, setSelectingState] = useState<SelectingState>(null);
const selection = state.selection;
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
const commitSelection = useCallback((cb: (oldSelection: Selection) => Selection) => {
commitState(oldState => ({...oldState, selection: cb(oldState.selection)}));
},[commitState]);
const replaceSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
replaceState(oldState => ({...oldState, selection: cb(oldState.selection)})),[replaceState]);
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
const bbox = refSVG.current!.getBoundingClientRect();
@ -28,9 +42,8 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
const currentPointer = getCurrentPointer(e);
if (e.button === 2) {
makeCheckPoint();
// ignore selection, middle mouse button always inserts
setState(state => {
// ignore selection, right mouse button always inserts
commitState(state => {
const newID = state.nextID.toString();
if (insertMode === "and" || insertMode === "or") {
// insert rountangle
@ -43,7 +56,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
kind: insertMode,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
selection: [{uid: newID, part: "bottom"}, {uid: newID, part: "right"}],
};
}
else if (insertMode === "pseudo") {
@ -55,7 +68,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
size: MIN_ROUNTANGLE_SIZE,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
selection: [{uid: newID, part: "bottom"}, {uid: newID, part: "right"}],
};
}
else if (insertMode === "shallow" || insertMode === "deep") {
@ -67,7 +80,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["history"]}],
selection: [{uid: newID, part: "history"}],
}
}
else if (insertMode === "transition") {
@ -79,7 +92,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
end: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["end"]}],
selection: [{uid: newID, part: "end"}],
}
}
else if (insertMode === "text") {
@ -91,7 +104,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["text"]}],
selection: [{uid: newID, part: "text"}],
}
}
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
@ -100,82 +113,106 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
return;
}
let appendTo: Selection;
if (shiftOrCtrlPressed) {
appendTo = selection;
}
else {
appendTo = [];
}
const startMakingSelection = () => {
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
commitSelection(_ => appendTo);
}
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.
// left mouse button
const uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
if (uid && parts.length > 0) {
makeCheckPoint();
// if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on
// mouse hovers over a shape or part of a shape
let allPartsInSelection = true;
for (const part of parts) {
if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) {
if (!(selection.some(s => (s.uid === uid) && (s.part === part)))) {
allPartsInSelection = false;
break;
}
}
if (!allPartsInSelection) {
// the part is not in existing selection
if (e.target.classList.contains("helper")) {
setSelection(() => [{uid, parts}] as Selection);
// it's only a helper
// -> update selection by the part and start dragging it
commitSelection(() => [
...appendTo,
...parts.map(part => ({uid, part})) as Selection,
]);
setDragging(true);
}
else {
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
return;
// it's an actual shape
// (we treat shapes differently from helpers because in a big hierarchical model it is nearly impossible to click anywhere without clicking inside a shape)
startMakingSelection();
}
}
// start dragging
setDragging(true);
return;
else {
// the part is in existing selection
// -> just start dragging
commitSelection(s => s); // <-- but also create an undo-checkpoint!
setDragging(true);
}
}
else {
// mouse is not on any shape
startMakingSelection();
}
}
// otherwise, just start making a selection
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
}, [getCurrentPointer, makeCheckPoint, insertMode, selection]);
else {
// any other mouse button (e.g., middle mouse button)
// -> just start making a selection
startMakingSelection();
}
}, [commitState, commitSelection, getCurrentPointer, insertMode, selection, shiftOrCtrlPressed]);
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
const currentPointer = getCurrentPointer(e);
if (dragging) {
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
// we're moving / resizing
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
setState(state => ({
const getParts = (uid: string) => {
return selection.filter(s => s.uid === uid).map(s => s.part);
}
replaceState(state => ({
...state,
rountangles: state.rountangles.map(r => {
const parts = state.selection.find(selected => selected.uid === r.uid)?.parts || [];
if (parts.length === 0) {
const selectedParts = getParts(r.uid);
if (selectedParts.length === 0) {
return r;
}
return {
...r,
...transformRect(r, parts, pointerDelta),
...transformRect(r, selectedParts, pointerDelta),
};
})
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
diamonds: state.diamonds.map(d => {
const parts = state.selection.find(selected => selected.uid === d.uid)?.parts || [];
if (parts.length === 0) {
const selectedParts = getParts(d.uid);
if (selectedParts.length === 0) {
return d;
}
return {
...d,
...transformRect(d, parts, pointerDelta),
...transformRect(d, selectedParts, pointerDelta),
}
}),
history: state.history.map(h => {
const parts = state.selection.find(selected => selected.uid === h.uid)?.parts || [];
if (parts.length === 0) {
const selectedParts = getParts(h.uid);
if (selectedParts.length === 0) {
return h;
}
return {
@ -184,18 +221,18 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
}
}),
arrows: state.arrows.map(a => {
const parts = state.selection.find(selected => selected.uid === a.uid)?.parts || [];
if (parts.length === 0) {
const selectedParts = getParts(a.uid);
if (selectedParts.length === 0) {
return a;
}
return {
...a,
...transformLine(a, parts, pointerDelta),
...transformLine(a, selectedParts, pointerDelta),
}
}),
texts: state.texts.map(t => {
const parts = state.selection.find(selected => selected.uid === t.uid)?.parts || [];
if (parts.length === 0) {
const selectedParts = getParts(t.uid);
if (selectedParts.length === 0) {
return t;
}
return {
@ -207,6 +244,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
setDragging(true);
}
else if (selectingState) {
// we're making a selection
setSelectingState(ss => {
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
return {
@ -215,13 +253,15 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
};
});
}
}, [getCurrentPointer, selectingState, dragging]);
}, [replaceState, getCurrentPointer, selectingState, setSelectingState, selection, dragging]);
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
if (dragging) {
// we were moving / resizing
setDragging(false);
// do not persist sizes smaller than 40x40
setState(state => {
replaceState(state => {
return {
...state,
rountangles: state.rountangles.map(r => ({
@ -236,20 +276,24 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
});
}
if (selectingState) {
// we were making a selection
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
// it was only a click (mouse didn't move)
// -> select the clicked part(s)
// (btw, this is only here to allow selecting rountangles by clicking inside them, all other shapes can be selected entirely by their 'helpers')
const uid = e.target?.dataset.uid;
if (uid) {
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="") || [];
if (uid) {
setSelection(() => [{
uid,
parts,
}]);
replaceSelection(oldSelection => [
...oldSelection,
...parts.map((part: string) => ({uid, part})),
]);
}
}
}
else {
// we were making a selection
// complete selection
const normalizedSS = normalizeRect(selectingState);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
@ -261,83 +305,74 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
return isEntirelyWithin(scaledBBox, 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<string> = uidToParts.get(uid) || new Set();
for (const part of shape.dataset.parts?.split(' ') || []) {
parts.add(part);
// @ts-ignore
replaceSelection(oldSelection => {
const newSelection = [...oldSelection];
for (const shape of shapesInSelection) {
const uid = shape.dataset.uid;
if (uid) {
const parts = shape.dataset.parts?.split(' ') || [];
for (const part of parts) {
if (newSelection.some(({uid: oldUid, part: oldPart}) =>
uid === oldUid && part === oldPart)) {
}
else {
// @ts-ignore
newSelection.push({uid, part});
}
}
}
uidToParts.set(uid, parts);
}
}
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
uid,
parts: [...parts],
})));
return newSelection;
})
}
}
setSelectingState(null); // no longer making a selection
}, [dragging, selectingState, refSVG.current]);
}, [replaceState, replaceSelection, dragging, selectingState, setSelectingState, refSVG.current]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
// don't capture keyboard events when focused on an input element:
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
const trackShiftKey = useCallback((e: KeyboardEvent) => {
setShiftOrCtrlPressed(e.shiftKey || e.ctrlKey);
}, []);
if (e.key === "o") {
// selected states become OR-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
}));
}
if (e.key === "a") {
// selected states become AND-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
}));
}
// 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 === "a") {
e.preventDefault();
setDragging(false);
setState(state => ({
...state,
// @ts-ignore
selection: [
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
...state.history.map(h => ({uid: h.uid, parts: ["history"]})),
]
}))
}
}
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
const onSelectAll = useCallback(() => {
setDragging(false);
commitState(state => ({
...state,
// @ts-ignore
selection: [
...state.rountangles.flatMap(r => ["left", "top", "right", "bottom"].map(part => ({uid: r.uid, part}))),
...state.diamonds.flatMap(d => ["left", "top", "right", "bottom"].map(part => ({uid: d.uid, part}))),
...state.arrows.flatMap(a => ["start", "end"].map(part => ({uid: a.uid, part}))),
...state.texts.map(t => ({uid: t.uid, part: "text"})),
...state.history.map(h => ({uid: h.uid, part: "history"})),
],
}));
}, [commitState, setDragging]);
const convertSelection = useCallback((kind: "or"|"and") => {
commitState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind}) : r),
}));
}, [commitState]);
useShortcuts([
{keys: ["o"], action: useCallback(() => convertSelection("or"), [convertSelection])},
{keys: ["a"], action: useCallback(() => convertSelection("and"), [convertSelection])},
{keys: ["Ctrl", "a"], action: onSelectAll},
]);
useEffect(() => {
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keydown", trackShiftKey);
window.addEventListener("keyup", trackShiftKey);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keydown", trackShiftKey);
window.removeEventListener("keyup", trackShiftKey);
};
}, [selectingState, dragging]);

View file

@ -1,8 +1,9 @@
import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
import { HISTORY_RADIUS } from "./parameters";
import { HISTORY_RADIUS } from "../parameters";
import { Dispatch, SetStateAction, useCallback, useEffect } from "react";
import { EditHistory } from "./App";
import { VisualEditorState } from "./VisualEditor/VisualEditor";
import { EditHistory } from "../App";
import { jsonDeepEqual } from "@/util/util";
import { VisualEditorState } from "../VisualEditor/VisualEditor";
export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) {
useEffect(() => {
@ -12,13 +13,27 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
}
}, []);
// append editor state to undo history
const makeCheckPoint = useCallback(() => {
setEditHistory(historyState => historyState && ({
...historyState,
history: [...historyState.history, historyState.current],
future: [],
}));
const commitState = useCallback((callback: (oldState: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => {
if (historyState === null) return null; // no change
const newEditorState = callback(historyState.current);
return {
current: newEditorState,
history: [...historyState.history, historyState.current],
future: [],
}
// }
});
}, [setEditHistory]);
const replaceState = useCallback((callback: (oldState: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => {
if (historyState === null) return null; // no change
const newEditorState = callback(historyState.current);
return {
...historyState,
current: newEditorState,
};
});
}, [setEditHistory]);
const onUndo = useCallback(() => {
setEditHistory(historyState => {
@ -47,62 +62,54 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
});
}, [setEditHistory]);
const onRotate = useCallback((direction: "ccw" | "cw") => {
makeCheckPoint();
setEditHistory(historyState => {
if (historyState === null) return null;
const selection = historyState.current.selection;
commitState(editorState => {
const selection = editorState.selection;
if (selection.length === 0) {
return historyState;
return editorState;
}
// determine bounding box... in a convoluted manner
let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity;
function addPointToBBox({x,y}: Vec2D) {
minX = Math.max(minX, x);
minY = Math.max(minY, y);
maxX = Math.min(maxX, x);
maxY = Math.min(maxY, y);
}
for (const rt of historyState.current.rountangles) {
for (const rt of editorState.rountangles) {
if (selection.some(s => s.uid === rt.uid)) {
addPointToBBox(rt.topLeft);
addPointToBBox(addV2D(rt.topLeft, rt.size));
}
}
for (const d of historyState.current.diamonds) {
for (const d of editorState.diamonds) {
if (selection.some(s => s.uid === d.uid)) {
addPointToBBox(d.topLeft);
addPointToBBox(addV2D(d.topLeft, d.size));
}
}
for (const arr of historyState.current.arrows) {
for (const arr of editorState.arrows) {
if (selection.some(s => s.uid === arr.uid)) {
addPointToBBox(arr.start);
addPointToBBox(arr.end);
}
}
for (const txt of historyState.current.texts) {
for (const txt of editorState.texts) {
if (selection.some(s => s.uid === txt.uid)) {
addPointToBBox(txt.topLeft);
}
}
const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS};
for (const h of historyState.current.history) {
for (const h of editorState.history) {
if (selection.some(s => s.uid === h.uid)) {
addPointToBBox(h.topLeft);
addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2)));
}
}
const center: Vec2D = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
};
const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => {
if (selection.some(s => s.uid === shape.uid)) {
return cb(shape);
@ -111,56 +118,51 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
return shape;
}
}
return {
...historyState,
current: {
...historyState.current,
rountangles: historyState.current.rountangles.map(rt => mapIfSelected(rt, rt => {
return {
...rt,
...(direction === "ccw"
? rotateRect90CCW(rt, center)
: rotateRect90CW(rt, center)),
}
})),
arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
return {
...arr,
...(direction === "ccw"
? rotateLine90CCW(arr, center)
: rotateLine90CW(arr, center)),
};
})),
diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
return {
...d,
...(direction === "ccw"
? rotateRect90CCW(d, center)
: rotateRect90CW(d, center)),
};
})),
texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
return {
...txt,
topLeft: (direction === "ccw"
? rotatePoint90CCW(txt.topLeft, center)
: rotatePoint90CW(txt.topLeft, center)),
};
})),
history: historyState.current.history.map(h => mapIfSelected(h, h => {
return {
...h,
topLeft: (direction === "ccw"
? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
: subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
),
};
})),
},
}
})
...editorState,
rountangles: editorState.rountangles.map(rt => mapIfSelected(rt, rt => {
return {
...rt,
...(direction === "ccw"
? rotateRect90CCW(rt, center)
: rotateRect90CW(rt, center)),
}
})),
arrows: editorState.arrows.map(arr => mapIfSelected(arr, arr => {
return {
...arr,
...(direction === "ccw"
? rotateLine90CCW(arr, center)
: rotateLine90CW(arr, center)),
};
})),
diamonds: editorState.diamonds.map(d => mapIfSelected(d, d => {
return {
...d,
...(direction === "ccw"
? rotateRect90CCW(d, center)
: rotateRect90CW(d, center)),
};
})),
texts: editorState.texts.map(txt => mapIfSelected(txt, txt => {
return {
...txt,
topLeft: (direction === "ccw"
? rotatePoint90CCW(txt.topLeft, center)
: rotatePoint90CW(txt.topLeft, center)),
};
})),
history: editorState.history.map(h => mapIfSelected(h, h => {
return {
...h,
topLeft: (direction === "ccw"
? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
: subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
),
};
})),
};
});
}, [setEditHistory]);
return {makeCheckPoint, onUndo, onRedo, onRotate};
return {commitState, replaceState, onUndo, onRedo, onRotate};
}

View file

@ -0,0 +1,224 @@
import { Statechart } from "@/statecharts/abstract_syntax";
import { RuntimeError } from "@/statecharts/interpreter";
import { BigStep, RaisedEvent } from "@/statecharts/runtime_types";
import { Conns, coupledExecution, statechartExecution } from "@/statecharts/timed_reactive";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plant } from "../Plant/Plant";
import { getSimTime, getWallClkDelay, TimeMode } from "@/statecharts/time";
import { UniversalPlantState } from "../plants";
type CoupledState = {
sc: BigStep,
plant: BigStep,
};
export type TraceItemError = {
cause: BigStepCause, // event name, <init> or <timer>
simtime: number,
error: RuntimeError,
}
export type BigStepCause = {
kind: "init",
simtime: 0,
} | {
kind: "input",
simtime: number,
eventName: string,
param?: any,
} | {
kind: "timer",
simtime: number,
};
export type TraceItem =
{ kind: "error" } & TraceItemError
| { kind: "bigstep", simtime: number, cause: BigStepCause, state: CoupledState, outputEvents: RaisedEvent[] };
export type TraceState = {
trace: [TraceItem, ...TraceItem[]], // non-empty
idx: number,
};
export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPlantState>, plantConns: Conns, onStep: () => void) {
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [trace, setTrace] = useState<TraceState|null>(null);
const currentTraceItem = trace && trace.trace[trace.idx];
// coupled execution
const cE = useMemo(() => ast && coupledExecution({
sc: statechartExecution(ast),
plant: plant.execution,
}, {
...plantConns,
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
}), [ast]);
const onInit = useCallback(() => {
if (cE === null) return;
const metadata = {simtime: 0, cause: {kind: "init" as const, simtime: 0 as const}};
try {
const [outputEvents, state] = cE.initial(); // may throw if initialing the statechart results in a RuntimeError
setTrace({
trace: [{kind: "bigstep", ...metadata, state, outputEvents}],
idx: 0,
});
}
catch (error) {
if (error instanceof RuntimeError) {
setTrace({
trace: [{kind: "error", ...metadata, error}],
idx: 0,
});
}
else {
throw error; // probably a bug in the interpreter
}
}
setTime(time => {
if (time.kind === "paused") {
return {...time, simtime: 0};
}
else {
return {...time, since: {simtime: 0, wallclktime: performance.now()}};
}
});
onStep();
}, [cE, onStep]);
const onClear = useCallback(() => {
setTrace(null);
setTime({kind: "paused", simtime: 0});
}, [setTrace, setTime]);
// raise input event, producing a new runtime configuration (or a runtime error)
const onRaise = (inputEvent: string, param: any) => {
if (cE === null) return;
if (currentTraceItem !== null /*&& ast.inputEvents.some(e => e.event === inputEvent)*/) {
if (currentTraceItem.kind === "bigstep") {
const simtime = getSimTime(time, Math.round(performance.now()));
appendNewConfig(simtime, {kind: "input", simtime, eventName: inputEvent, param}, () => {
return cE.extTransition(simtime, currentTraceItem.state, {kind: "input", name: inputEvent, param});
});
}
}
};
// timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
useEffect(() => {
// console.log('time effect:', time, currentTraceItem);
let timeout: NodeJS.Timeout | undefined;
if (currentTraceItem !== null && cE !== null) {
if (currentTraceItem.kind === "bigstep") {
const nextTimeout = cE?.timeAdvance(currentTraceItem.state);
const raiseTimeEvent = () => {
appendNewConfig(nextTimeout, {kind: "timer", simtime: nextTimeout}, () => {
return cE.intTransition(currentTraceItem.state);
});
}
if (time.kind === "realtime") {
const wallclkDelay = getWallClkDelay(time, nextTimeout, Math.round(performance.now()));
if (wallclkDelay !== Infinity) {
timeout = setTimeout(raiseTimeEvent, wallclkDelay);
}
}
else if (time.kind === "paused") {
if (nextTimeout <= time.simtime) {
raiseTimeEvent();
}
}
}
}
return () => {
if (timeout) clearTimeout(timeout);
}
}, [time, currentTraceItem]); // <-- todo: is this really efficient?
function appendNewConfig(simtime: number, cause: BigStepCause, computeNewState: () => [RaisedEvent[], CoupledState]) {
let newItem: TraceItem;
const metadata = {simtime, cause}
try {
const [outputEvents, state] = computeNewState(); // may throw RuntimeError
newItem = {kind: "bigstep", ...metadata, state, outputEvents};
}
catch (error) {
if (error instanceof RuntimeError) {
newItem = {kind: "error", ...metadata, error};
// also pause the simulation, for dramatic effect:
setTime({kind: "paused", simtime});
}
else {
throw error;
}
}
// @ts-ignore
setTrace(trace => ({
trace: [
...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
newItem,
],
// idx: 0,
idx: trace!.idx+1,
}));
onStep();
}
const onBack = useCallback(() => {
if (trace !== null) {
setTime(() => {
if (trace !== null) {
return {
kind: "paused",
simtime: trace.trace[trace.idx-1].simtime,
}
}
return { kind: "paused", simtime: 0 };
});
setTrace({
...trace,
idx: trace.idx-1,
});
}
}, [trace]);
const onReplayTrace = (causes: BigStepCause[]) => {
if (cE) {
function run_until(simtime: number) {
while (true) {
const nextTimeout = cE!.timeAdvance(lastState);
if (nextTimeout > simtime) {
break;
}
const [outputEvents, coupledState] = cE!.intTransition(lastState);
lastState = coupledState;
lastSimtime = nextTimeout;
newTrace.push({kind: "bigstep", simtime: nextTimeout, state: coupledState, outputEvents, cause: {kind: "timer", simtime: nextTimeout}});
}
}
const [outputEvents, coupledState] = cE.initial();
const newTrace = [{kind: "bigstep", simtime: 0, state: coupledState, outputEvents, cause: {kind: "init"} as BigStepCause} as TraceItem] as [TraceItem, ...TraceItem[]];
let lastState = coupledState;
let lastSimtime = 0;
for (const cause of causes) {
if (cause.kind === "input") {
run_until(cause.simtime); // <-- just make sure we haven't missed any timers elapsing
// @ts-ignore
const [outputEvents, coupledState] = cE.extTransition(cause.simtime, newTrace.at(-1)!.state, {kind: "input", name: cause.eventName, param: cause.param});
lastState = coupledState;
lastSimtime = cause.simtime;
newTrace.push({kind: "bigstep", simtime: cause.simtime, state: coupledState, outputEvents, cause});
}
else if (cause.kind === "timer") {
run_until(cause.simtime);
}
}
setTrace({trace: newTrace, idx: newTrace.length-1});
setTime({kind: "paused", simtime: lastSimtime});
}
}
return {trace, setTrace, plant, onInit, onClear, onBack, onRaise, onReplayTrace, time, setTime};
}

View file

@ -0,0 +1,36 @@
import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
export function makePartialSetter<T, K extends keyof T>(fullSetter: Dispatch<SetStateAction<T>>, key: K): Dispatch<SetStateAction<T[typeof key]>> {
return (newValueOrCallback: T[K] | ((newValue: T[K]) => T[K])) => {
fullSetter(oldFullValue => {
const newValue = (typeof newValueOrCallback === 'function') ? (newValueOrCallback as (newValue: T[K]) => T[K])(oldFullValue[key] as T[K]) : newValueOrCallback as T[K];
if (newValue === oldFullValue[key]) {
return oldFullValue;
}
else {
return {
...oldFullValue,
[key]: newValue,
}
}
})
};
}
export type Setters<T extends {[key: string]: any}> = {
[K in keyof T as `set${Capitalize<Extract<K, string>>}`]: Dispatch<SetStateAction<T[K]>>;
}
export function makeAllSetters<T extends {[key: string]: any}>(
fullSetter: Dispatch<SetStateAction<T>>,
keys: (keyof T)[],
): Setters<T> {
// @ts-ignore
return useMemo(() => {
console.log('creating setters for App');
// @ts-ignore
return Object.fromEntries(keys.map((key: string) => {
return [`set${key.charAt(0).toUpperCase()}${key.slice(1)}`, makePartialSetter(fullSetter, key)];
}));
}, [fullSetter]);
}

14
src/App/plants.ts Normal file
View file

@ -0,0 +1,14 @@
import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
import { dummyPlant } from "./Plant/Dummy/Dummy";
import { microwavePlant } from "./Plant/Microwave/Microwave";
import { Plant } from "./Plant/Plant";
import { trafficLightPlant } from "./Plant/TrafficLight/TrafficLight";
export type UniversalPlantState = {[property: string]: boolean|number};
export const plants: [string, Plant<any, UniversalPlantState>][] = [
["dummy", dummyPlant],
["microwave", microwavePlant as unknown as Plant<any, UniversalPlantState>],
["digital watch", digitalWatchPlant as unknown as Plant<any, UniversalPlantState>],
["traffic light", trafficLightPlant as unknown as Plant<any, UniversalPlantState>],
];

View file

@ -1,3 +0,0 @@
export function useSimulator() {
}

View file

@ -1,203 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { BigStepCause, EditHistory } from "./App";
import { VisualEditorState } from "./VisualEditor/VisualEditor";
import { emptyState } from "@/statecharts/concrete_syntax";
import { InsertMode } from "./TopPanel/InsertModes";
import { Conns } from "@/statecharts/timed_reactive";
export function useUrlHashState(editorState: VisualEditorState | null, setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) {
// i should probably put all these things into a single object, the 'app state'...
const [autoScroll, setAutoScroll] = useState(false);
const [autoConnect, setAutoConnect] = useState(true);
const [plantConns, setPlantConns] = useState<Conns>({});
const [showKeys, setShowKeys] = useState(true);
const [zoom, setZoom] = useState(1);
const [insertMode, setInsertMode] = useState<InsertMode>("and");
const [plantName, setPlantName] = useState("dummy");
const [showConnections, setShowConnections] = useState(false);
const [showProperties, setShowProperties] = useState(false);
const [showExecutionTrace, setShowExecutionTrace] = useState(true);
const [showPlantTrace, setShowPlantTrace] = useState(false);
const [properties, setProperties] = useState<string[]>([]);
const [savedTraces, setSavedTraces] = useState<[string, BigStepCause[]][]>([]);
const [activeProperty, setActiveProperty] = useState<number>(0);
// recover editor state from URL - we need an effect here because decompression is asynchronous
useEffect(() => {
console.log('recovering state...');
const compressedState = window.location.hash.slice(1);
if (compressedState.length === 0) {
// empty URL hash
console.log("no state to recover");
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
let compressedBuffer;
try {
compressedBuffer = Uint8Array.fromBase64(compressedState); // may throw
} catch (e) {
// probably invalid base64
console.error("failed to recover state:", e);
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
const ds = new DecompressionStream("deflate");
const writer = ds.writable.getWriter();
writer.write(compressedBuffer).catch(() => {}); // any promise rejections will be detected when we try to read
writer.close().catch(() => {});
new Response(ds.readable).arrayBuffer()
.then(decompressedBuffer => {
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
// we support two formats
if (recoveredState.nextID) {
// old format
setEditHistory(() => ({current: recoveredState, history: [], future: []}));
}
else {
console.log(recoveredState);
// new format
if (recoveredState.editorState !== undefined) {
setEditHistory(() => ({current: recoveredState.editorState, history: [], future: []}));
}
if (recoveredState.plantName !== undefined) {
setPlantName(recoveredState.plantName);
}
if (recoveredState.autoScroll !== undefined) {
setAutoScroll(recoveredState.autoScroll);
}
if (recoveredState.autoConnect !== undefined) {
setAutoConnect(recoveredState.autoConnect);
}
if (recoveredState.plantConns !== undefined) {
setPlantConns(recoveredState.plantConns);
}
if (recoveredState.showKeys !== undefined) {
setShowKeys(recoveredState.showKeys);
}
if (recoveredState.zoom !== undefined) {
setZoom(recoveredState.zoom);
}
if (recoveredState.insertMode !== undefined) {
setInsertMode(recoveredState.insertMode);
}
if (recoveredState.showConnections !== undefined) {
setShowConnections(recoveredState.showConnections);
}
if (recoveredState.showProperties !== undefined) {
setShowProperties(recoveredState.showProperties);
}
if (recoveredState.showExecutionTrace !== undefined) {
setShowExecutionTrace(recoveredState.showExecutionTrace);
}
if (recoveredState.showPlantTrace !== undefined) {
setShowPlantTrace(recoveredState.showPlantTrace);
}
if (recoveredState.properties !== undefined) {
setProperties(recoveredState.properties);
}
if (recoveredState.savedTraces !== undefined) {
setSavedTraces(recoveredState.savedTraces);
}
if (recoveredState.activeProperty !== undefined) {
setActiveProperty(recoveredState.activeProperty);
}
}
})
.catch(e => {
// any other error: invalid JSON, or decompression failed.
console.error("failed to recover state:", e);
setEditHistory({current: emptyState, history: [], future: []});
});
}, []);
// save editor state in URL
useEffect(() => {
const timeout = setTimeout(() => {
if (editorState === null) {
window.location.hash = "#";
return;
}
const serializedState = JSON.stringify({
autoConnect,
autoScroll,
plantConns,
showKeys,
zoom,
insertMode,
plantName,
editorState,
showConnections,
showProperties,
showExecutionTrace,
showPlantTrace,
properties,
savedTraces,
activeProperty,
});
const stateBuffer = new TextEncoder().encode(serializedState);
const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(stateBuffer);
writer.close();
// todo: cancel this promise handler when concurrently starting another compression job
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
window.location.hash = "#"+compressedStateString;
});
}, 100);
return () => clearTimeout(timeout);
}, [
editorState,
autoConnect,
autoScroll,
plantConns,
showKeys,
zoom,
insertMode,
plantName,
showConnections,
showProperties,
showExecutionTrace,
showPlantTrace,
properties,
savedTraces,
activeProperty,
]);
return {
autoConnect,
setAutoConnect,
autoScroll,
setAutoScroll,
plantConns,
setPlantConns,
showKeys,
setShowKeys,
zoom,
setZoom,
insertMode,
setInsertMode,
plantName,
setPlantName,
showConnections,
setShowConnections,
showProperties,
setShowProperties,
showExecutionTrace,
setShowExecutionTrace,
showPlantTrace,
setShowPlantTrace,
properties,
setProperties,
savedTraces,
setSavedTraces,
activeProperty,
setActiveProperty,
}
}

View file

@ -11,9 +11,9 @@ import { App } from "./App/App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
// <StrictMode>
<App />
</StrictMode>
// </StrictMode>
);
if (import.meta.hot) {

View file

@ -0,0 +1,8 @@
import { useEffect } from "react";
// useful for debugging
export function useDetectChange(expr: any, name: string) {
useEffect(() => {
console.log(name, 'changed to:', expr);
}, [expr]);
}

30
src/hooks/useShortcuts.ts Normal file
View file

@ -0,0 +1,30 @@
import { useEffect } from "react";
export function useShortcuts(spec: {keys: string[], action: () => void}[], ignoreInputs = true) {
// I don't know if this is efficient, but I decided to just register one event listener for every shortcut, rather than generating one big event listener for all shortcuts.
// The benefit is that we don't have to memoize anything: useEffect will only be called if the action updated, and React allows calling useEffect for every item in a list as long as the list doesn't change.
for (const {keys, action} of spec) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (ignoreInputs) {
// @ts-ignore: don't steal keyboard events while the user is typing in a text box, etc.
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
}
if (e.ctrlKey !== keys.includes("Ctrl")) return;
if (e.shiftKey !== keys.includes("Shift")) return;
if (!keys.includes(e.key)) return;
const remainingKeys = keys.filter(key => key !== "Ctrl" && key !== "Shift" && key !== e.key);
if (remainingKeys.length !== 0) {
console.warn("impossible shortcut sequence:", keys.join(' + '));
return;
}
e.preventDefault();
e.stopPropagation();
action();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [action]);
}
}

View file

@ -0,0 +1,56 @@
import { useEffect, useLayoutEffect } from "react";
// persist state in URL hash
export function useUrlHashState<T>(recoverCallback: (recoveredState: (T|null)) => void): (toPersist: T) => void {
// recover editor state from URL - we need an effect here because decompression is asynchronous
// layout effect because we want to run it before rendering the first frame
useLayoutEffect(() => {
console.log('recovering state...');
const compressedState = window.location.hash.slice(1);
if (compressedState.length === 0) {
// empty URL hash
console.log("no state to recover");
return recoverCallback(null);
}
let compressedBuffer;
try {
compressedBuffer = Uint8Array.fromBase64(compressedState); // may throw
} catch (e) {
// probably invalid base64
console.error("failed to recover state:", e);
return recoverCallback(null);
}
const ds = new DecompressionStream("deflate");
const writer = ds.writable.getWriter();
writer.write(compressedBuffer).catch(() => {}); // any promise rejections will be detected when we try to read
writer.close().catch(() => {});
new Response(ds.readable).arrayBuffer()
.then(decompressedBuffer => {
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
console.log('successfully recovered state');
recoverCallback(recoveredState);
})
.catch(e => {
// any other error: invalid JSON, or decompression failed.
console.error("failed to recover state:", e);
recoverCallback(null);
});
}, []);
function persist(state: T) {
const serializedState = JSON.stringify(state);
const stateBuffer = new TextEncoder().encode(serializedState);
const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(stateBuffer);
writer.close();
// todo: cancel this promise handler when concurrently starting another compression job
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
window.location.hash = "#"+compressedStateString;
});
}
return persist;
}

View file

@ -5,9 +5,69 @@ html, body {
font-size: 10pt;
}
body {
/* --error-color: darkred; */
--error-color: rgb(163, 0, 0);
:root {
color-scheme: light dark;
--background-color: light-dark(white, rgb(31, 33, 36));
--text-color: light-dark(black, white);
--error-color: light-dark(rgb(163, 0, 0), rgb(255, 82, 82));
--error-bg-color: light-dark(lightpink, rgb(75, 0, 0));
--error-hover-bg-color: light-dark(rgb(102, 0, 0), rgb(238, 153, 153));
--light-accent-color: light-dark(rgba(0,0,255,0.2), rgba(78, 186, 248, 0.377));
--accent-border-color: light-dark(blue, rgb(64, 185, 255));
--accent-opaque-color: light-dark(#ccccff, #305b73);
--separator-color: light-dark(lightgrey, rgb(58, 58, 58));
--inactive-bg-color: light-dark(#f7f7f7, rgb(29, 29, 29));
--inactive-fg-color: light-dark(grey, rgb(70, 70, 70));
--button-bg-color: light-dark(#fcfcfc, rgb(44, 50, 63));
--textbox-bg-color: light-dark(white, rgb(36, 41, 40));
--modal-backdrop-color: light-dark(rgba(200,200,200,0.7), rgba(23, 22, 32, 0.849));
--status-inactive-color: light-dark(grey, grey);
--status-ok-color: light-dark(forestgreen, forestgreen);
--or-state-bg-color: light-dark(#eee, #000000);
--and-state-bg-color: light-dark(white, rgb(46, 46, 46));
--rountangle-stroke-color: light-dark(black, #d4d4d4);
--active-state-bg-color: light-dark(rgb(255, 240, 214), rgb(53, 37, 18));
--active-state-border-color: light-dark(rgb(205, 133, 0), rgb(235, 124, 21));
--fired-transition-color: light-dark(rgb(160, 0, 168), rgb(160, 0, 168));
--firing-transition-color: light-dark(rgba(255, 128, 9, 1), rgba(255, 128, 9, 1));
--associated-color: light-dark(green, rgb(186, 245, 119));
--greeter-bg-color: light-dark(rgb(255, 249, 235), rgb(24, 40, 70));
/* --bottom-panel-bg-color: light-dark(rgb(219, 219, 219), rgb(31, 33, 36)); */
--summary-hover-bg-color: light-dark(#eee, #2e2f35);
--internal-event-bg-color: light-dark(rgb(255, 218, 252), rgb(99, 27, 94));
--input-event-bg-color: light-dark(rgb(224, 247, 209), rgb(59, 95, 37));
--input-event-hover-bg-color: light-dark(rgb(195, 224, 176), rgb(59, 88, 40));
--input-event-active-bg-color: light-dark(rgb(176, 204, 158), rgb(77, 117, 53));
--output-event-bg-color: light-dark(rgb(230, 249, 255), rgb(28, 83, 104));
background-color: var(--background-color);
color: var(--text-color);
}
input {
background-color: var(--textbox-bg-color);
border: 1px solid var(--separator-color);
}
button {
background-color: var(--button-bg-color);
border: 1px var(--separator-color) solid;
}
button:not(:disabled):hover {
background-color: var(--light-accent-color);
}
button:disabled {
background-color: var(--inactive-bg-color);
color: var(--inactive-fg-color);
}
button.active {
border: solid var(--accent-border-color) 1px;
background-color: var(--light-accent-color);
color: var(--text-color);
}
div#root {
@ -27,17 +87,18 @@ kbd {
box-shadow: inset 0 -1.5px 0 #aaa;
vertical-align: middle;
user-select: none;
color: black;
}
kbd:active { transform: translateY(1px); }
input {
/* border: solid blue 2px; */
accent-color: rgba(0,0,255,0.2);
accent-color: var(--accent-opaque-color);
}
::selection {
background-color: rgba(0,0,255,0.2);
background-color: var(--light-accent-color);
}
label {

View file

@ -28,6 +28,14 @@ export type History = {
topLeft: Vec2D;
};
export type ConcreteSyntax = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
diamonds: Diamond[];
history: History[];
};
// independently moveable parts of our shapes:
export type RectSide = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end";

View file

@ -1,4 +1,5 @@
import { ConcreteSyntax, VisualEditorState } from "@/App/VisualEditor/VisualEditor";
import { VisualEditorState } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "./concrete_syntax";
import { findNearestArrow, findNearestHistory, findNearestSide, findRountangle, RectSide } from "./concrete_syntax";
export type Connections = {

View file

@ -358,7 +358,7 @@ export function handleInternalEvents(simtime: number, statechart: Statechart, {i
const [nextEvent, ...remainingEvents] = internalEvents;
({internalEvents, ...rest} = fairStep(simtime,
{kind: "input", ...nextEvent}, // internal event becomes input event
statechart, statechart.root, { arenasFired: [], internalEvents: remainingEvents, ...rest}));
statechart, statechart.root, { ...rest, arenasFired: [], internalEvents: remainingEvents, }));
}
return rest;
}

View file

@ -5,7 +5,7 @@ import { Action, EventTrigger, Expression, ParsedText } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser";
import { Connections } from "./detect_connections";
import { HISTORY_RADIUS } from "../App/parameters";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { ConcreteSyntax } from "./concrete_syntax";
import { memoize } from "@/util/util";
export type TraceableError = {

View file

@ -24,6 +24,24 @@ export function memoize<InType,OutType>(fn: (i: InType) => OutType) {
}
}
// author: ChatGPT
export function jsonDeepEqual(a: any, b: any) {
if (a === b) return true;
if (a && b && typeof a === "object" && typeof b === "object") {
if (Array.isArray(a) !== Array.isArray(b)) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!jsonDeepEqual(a[key], b[key])) return false;
}
return true;
}
return false;
}
// compare arrays by value
export function arraysEqual<T>(a: T[], b: T[], cmp: (a: T, b: T) => boolean = (a,b)=>a===b): boolean {
if (a === b)

File diff suppressed because one or more lines are too long

View file

@ -31,12 +31,16 @@
TODO
- bugs
editing SC <-> Plant connections at runtime doesn't seem to work
(*) editing SC <-> Plant connections at runtime doesn't seem to work
(*) non-determinism error highlights only one of enabled transitions
- maybe support:
- explicit order of:
- outgoing transitions?
- write documentation
although ideally the tool should explain itself...
- usability stuff:
- action language: add increment operations (++) and (--)
and (+=) and (-=)
@ -48,11 +52,23 @@ TODO
- ability to hide statechart and only show the plant?
- hovering over event in side panel should highlight all occurrences of the event in the SC
- hovering over error in bottom panel should highlight that rror in the SC
- rename events / variables
find/replace?
- hovering over error in bottom panel should highlight that error in the SC
- highlight selected shapes while making a selection
- highlight about-to-fire transitions
- integrate undo-history with browser history (back/forward buttons)
- ability to 'freeze' editor (e.g., to show plant SC)
- show insert mode also next to cursor
- plot plant signals
- show error when states partially overlap?
useful when accidentally pasting the same data multiple times
(otherwise, you can't see that you have multiple states)
- performance:
maybe try this for rendering the execution trace:
https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists
@ -63,11 +79,6 @@ TODO
https://pub.dev/packages/ploeg_tree_layout
- local variable scopes
for the assignment:
*ALL* features
add history (look at original Harel paper)
add microwave oven
add traffic light
Publish StateBuddy paper(s):
compare CS approach to other tools, not only YAKINDU
z