Compare commits
30 commits
9fd1c3a9a7
...
7994cd6eb0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7994cd6eb0 | |||
| 1bd801ce5d | |||
| 970b9d850e | |||
| 0266675f29 | |||
| d805fa95e4 | |||
| 8fa430846b | |||
| 632cf9b542 | |||
| bdc2a66b1c | |||
| 74361eb162 | |||
| dc948629a7 | |||
| c825830a14 | |||
| e29559e46d | |||
| e3b88b7d89 | |||
| ee899cea2d | |||
| af934c6767 | |||
| 02cbbe601a | |||
| 6efc27453e | |||
| 9646d716c6 | |||
| 848a13e875 | |||
| 1f72542234 | |||
| 07b51dd2f2 | |||
| 5674416623 | |||
| a013fca768 | |||
| e0863c9443 | |||
| 9922f8588d | |||
| 2dd35ab079 | |||
| fcf9448441 | |||
| 2d6b8a764b | |||
| 5713c3a59f | |||
| 3f6b2ba950 |
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
366
assignment.html
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
125
src/App/AST.css
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
721
src/App/App.tsx
|
|
@ -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);
|
||||
|
||||
// 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 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]);
|
||||
|
||||
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"}}>
|
||||
|
||||
|
||||
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:
|
||||
|
||||
{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>
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
 
|
||||
<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} → </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} → </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} → </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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
15
src/App/BottomPanel/BottomPanel.css
Normal 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);
|
||||
}
|
||||
48
src/App/BottomPanel/BottomPanel.tsx
Normal 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>;
|
||||
}
|
||||
48
src/App/BottomPanel/FindReplace.tsx
Normal 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}}/>
|
||||
|
||||
<button onClick={onReplace}>replace all</button>
|
||||
<button onClick={hide} style={{marginLeft: 'auto'}}><CloseIcon fontSize="small"/></button>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { usePersistentState } from "@/App/persistent_state"
|
||||
import { usePersistentState } from "@/hooks/usePersistentState"
|
||||
import { DetailsHTMLAttributes, Dispatch, PropsWithChildren, SetStateAction } from "react";
|
||||
|
||||
type Props = {
|
||||
5
src/App/Components/TwoStateButton.tsx
Normal 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
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
src/App/Overlays/ModalOverlay.tsx
Normal 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}
|
||||
</>;
|
||||
}
|
||||
14
src/App/Overlays/WindowOverlay.tsx
Normal 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}
|
||||
// </>;
|
||||
// }
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
|
||||
|
|
|
|||
BIN
src/App/Plant/Microwave/originals/microwave.xcf
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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", {});
|
||||
86
src/App/SideBar/SideBar.css
Normal 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
|
|
@ -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:
|
||||
|
||||
{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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
 
|
||||
<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} → </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} → </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} → </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>)}
|
||||
</>;
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<label htmlFor="number-timescale"><SpeedIcon fontSize="small"/></label>
|
||||
<KeyInfo keyInfo={<kbd>S</kbd>}>
|
||||
<button title="slower" onClick={onSlower}>÷2</button>
|
||||
</KeyInfo>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
 
|
||||
</div> */}
|
||||
|
||||
{/* shortcuts / about */}
|
||||
<div className="toolbarGroup">
|
||||
<KeyInfo keyInfo={ShortCutShowKeys}>
|
||||
|
|
@ -172,11 +178,26 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
 
|
||||
</div>
|
||||
|
||||
{/* rotate */}
|
||||
<div className="toolbarGroup">
|
||||
<RotateButtons selection={editHistory.current.selection} onRotate={onRotate}/>
|
||||
 
|
||||
</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>
|
||||
 
|
||||
</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>
|
||||
<input title="the current simulated time" id="time" disabled={!config} value={displayTime} readOnly={true} className="readonlyTextBox" />
|
||||
<label htmlFor="time"><AccessTimeIcon fontSize="small"/></label>
|
||||
<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>
|
||||
|
||||
 
|
||||
<div className="toolbarGroup">
|
||||
<label htmlFor="next-timeout">next (s)</label>
|
||||
<label htmlFor="next-timeout"><AccessAlarmIcon fontSize="small"/></label>
|
||||
<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>
|
||||
 
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 <>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
@ -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};
|
||||
}
|
||||
224
src/App/hooks/useSimulator.ts
Normal 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};
|
||||
}
|
||||
36
src/App/makePartialSetter.ts
Normal 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
|
|
@ -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>],
|
||||
];
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export function useSimulator() {
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
8
src/hooks/useDetectChange.ts
Normal 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
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
56
src/hooks/useUrlHashState.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
376
teaching/mosis25/assignment.html
Normal file
27
todo.txt
|
|
@ -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
|
||||