Compare commits

..

30 commits

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

3
.gitignore vendored
View file

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

File diff suppressed because one or more lines are too long

View file

@ -7,13 +7,12 @@
"module": "src/index.tsx", "module": "src/index.tsx",
"scripts": { "scripts": {
"dev": "bun --hot src/index.tsx", "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" "start": "NODE_ENV=production bun src/index.tsx"
}, },
"dependencies": { "dependencies": {
"@fontsource/roboto": "^5.2.8", "@fontsource/roboto": "^5.2.8",
"@mui/icons-material": "^7.3.4", "@mui/icons-material": "^7.3.4",
// "argus-wasm": "git+https://deemz.org/git/joeri/argus-wasm.git#a4491b3433d48aa1f941bd5ad37b36f819d3b2ac",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },

View file

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

View file

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

View file

@ -1,36 +1,23 @@
import "../index.css"; import "../index.css";
import "./App.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 { detectConnections } from "@/statecharts/detect_connections";
import { Conns, coupledExecution, statechartExecution } from "@/statecharts/timed_reactive";
import { RuntimeError } from "../statecharts/interpreter";
import { parseStatechart } from "../statecharts/parser"; import { parseStatechart } from "../statecharts/parser";
import { BigStep, RaisedEvent } from "../statecharts/runtime_types"; import { BottomPanel } from "./BottomPanel/BottomPanel";
import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time"; import { defaultSideBarState, SideBar, SideBarState } from "./SideBar/SideBar";
import { BottomPanel } from "./BottomPanel"; import { InsertMode } from "./TopPanel/InsertModes";
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 { TopPanel } from "./TopPanel/TopPanel"; import { TopPanel } from "./TopPanel/TopPanel";
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor"; import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
import { checkProperty, PropertyCheckResult } from "./check_property"; import { makeAllSetters } from "./makePartialSetter";
import { useEditor } from "./useEditor"; import { useEditor } from "./hooks/useEditor";
import { useUrlHashState } from "./useUrlHashState"; 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 = { export type EditHistory = {
current: VisualEditorState, current: VisualEditorState,
@ -38,110 +25,87 @@ export type EditHistory = {
future: VisualEditorState[], 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>][] = [ const defaultAppState: AppState = {
["dummy", dummyPlant], showKeys: true,
["microwave", microwavePlant as unknown as Plant<any, UniversalPlantState>], zoom: 1,
["digital watch", digitalWatchPlant as unknown as Plant<any, UniversalPlantState>], insertMode: 'and',
["traffic light", trafficLightPlant as unknown as Plant<any, UniversalPlantState>], showFindReplace: false,
] findText: "",
replaceText: "",
export type TraceItemError = { ...defaultSideBarState,
cause: BigStepCause, // event name, <init> or <timer>
simtime: number,
error: RuntimeError,
} }
type CoupledState = { export type LightMode = "light" | "auto" | "dark";
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 function App() { export function App() {
const [editHistory, setEditHistory] = useState<EditHistory|null>(null); 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 [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 editorState = editHistory && editHistory.current;
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => { const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)})); setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
}, [setEditHistory]); }, [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: // parse concrete syntax always:
const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]); const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]);
const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]); const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]);
const ast = parsed && parsed[0]; 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(() => { const scrollDownSidebar = useCallback(() => {
if (autoScroll && refRightSideBar.current) { if (autoScroll && refRightSideBar.current) {
const el = refRightSideBar.current; const el = refRightSideBar.current;
@ -152,234 +116,39 @@ export function App() {
} }
}, [refRightSideBar.current, autoScroll]); }, [refRightSideBar.current, autoScroll]);
// coupled execution const simulator = useSimulator(ast, plant, plantConns, scrollDownSidebar);
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(() => { // console.log('render app', {ast, plant, appState});
if (cE === null) return; // useDetectChange(ast, 'ast');
const metadata = {simtime: 0, cause: {kind: "init" as const, simtime: 0 as const}}; // useDetectChange(plant, 'plant');
try { // useDetectChange(scrollDownSidebar, 'scrollDownSidebar');
const [outputEvents, state] = cE.initial(); // may throw if initialing the statechart results in a RuntimeError // useDetectChange(appState, 'appState');
setTrace({ // useDetectChange(simulator.time, 'simulator.time');
trace: [{kind: "bigstep", ...metadata, state, outputEvents}], // useDetectChange(simulator.trace, 'simulator.trace');
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(() => { const setters = makeAllSetters(setAppState, Object.keys(appState) as (keyof AppState)[]);
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 syntaxErrors = parsed && parsed[1] || [];
const currentTraceItem = simulator.trace && simulator.trace.trace[simulator.trace.idx];
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem; 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 highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || []; 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]; const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1];
useEffect(() => { return <div style={{
ast && autoConnect && autoDetectConns(ast, plant, setPlantConns); height:'100%',
}, [ast, plant, autoConnect]); // doesn't work:
// colorScheme: lightMode !== "auto" ? lightMode : undefined,
const [propertyResults, setPropertyResults] = useState<PropertyCheckResult[] | null>(null); }}>
<ModalOverlay modal={modal} setModal={setModal}>
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 */} {/* top-to-bottom: everything -> bottom panel */}
<div className="stackVertical" style={{height:'100%'}}> <div className="stackVertical" style={{height:'100%'}}>
@ -394,250 +163,44 @@ export function App() {
style={{flex: '0 0 content'}} style={{flex: '0 0 content'}}
> >
{editHistory && <TopPanel {editHistory && <TopPanel
{...{trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}} {...{onUndo, onRedo, onRotate, setModal, editHistory, ...simulator, ...setters, ...appState, setEditorState}}
/>} />}
</div> </div>
{/* Editor */} {/* Editor */}
<div style={{flexGrow: 1, overflow: "auto"}}> <div style={{flexGrow: 1, overflow: "auto"}}>
{editorState && conns && syntaxErrors && {editorState && conns && syntaxErrors &&
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, syntaxErrors: allErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>} <VisualEditor {...{state: editorState, commitState, replaceState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, ...appState}}/>}
</div> </div>
{appState.showFindReplace &&
<div>
<FindReplace setCS={setEditorState} hide={() => setters.setShowFindReplace(false)}/>
</div>
}
</div> </div>
{/* Right: sidebar */} {/* Right: sidebar */}
<div style={{ <div style={{
flex: '0 0 content', flex: '0 0 content',
borderLeft: '1px solid lightgrey', borderLeft: '1px solid var(--separator-color)',
overflowY: "auto", overflowY: "auto",
overflowX: "auto", overflowX: "auto",
maxWidth: 'min(400px, 50vw)', maxWidth: 'min(400px, 50vw)',
}}> }}>
<div className="stackVertical" style={{height:'100%'}}> <div className="stackVertical" style={{height:'100%'}}>
<div <SideBar {...{...appState, refRightSideBar, ast, plantState, ...simulator, ...setters}} />
className={showExecutionTrace ? "shadowBelow" : ""}
style={{flex: '0 0 content', backgroundColor: ''}}
>
{/* State tree */}
<PersistentDetailsLocalStorage localStorageKey="showStateTree" initiallyOpen={true}>
<summary>state tree</summary>
<ul>
{ast && <ShowAST {...{...ast, trace, highlightActive}}/>}
</ul>
</PersistentDetailsLocalStorage>
{/* Input events */}
<PersistentDetailsLocalStorage localStorageKey="showInputEvents" initiallyOpen={true}>
<summary>input events</summary>
{ast && <ShowInputEvents
inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("debug."+e,p)}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={showKeys}/>}
</PersistentDetailsLocalStorage>
{/* Internal events */}
<PersistentDetailsLocalStorage localStorageKey="showInternalEvents" initiallyOpen={true}>
<summary>internal events</summary>
{ast && <ShowInternalEvents internalEvents={ast.internalEvents}/>}
</PersistentDetailsLocalStorage>
{/* Output events */}
<PersistentDetailsLocalStorage localStorageKey="showOutputEvents" initiallyOpen={true}>
<summary>output events</summary>
{ast && <ShowOutputEvents outputEvents={ast.outputEvents}/>}
</PersistentDetailsLocalStorage>
{/* Plant */}
<PersistentDetailsLocalStorage localStorageKey="showPlant" initiallyOpen={true}>
<summary>plant</summary>
<select
disabled={trace!==null}
value={plantName}
onChange={e => setPlantName(() => e.target.value)}>
{plants.map(([plantName, p]) =>
<option>{plantName}</option>
)}
</select>
<br/>
{/* Render plant */}
{<plant.render state={plant.cleanupState(plantState)} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
/>}
</PersistentDetailsLocalStorage>
{/* Connections */}
<PersistentDetails state={showConnections} setState={setShowConnections}>
<summary>connections</summary>
<button title="auto-connect (name-based)" className={autoConnect?"active":""}
onClick={() => setAutoConnect(c => !c)}>
<AutoAwesomeIcon fontSize="small"/>
</button>
{ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
</PersistentDetails>
{/* Properties */}
<details open={showProperties} onToggle={e => setShowProperties(e.newState === "open")}>
<summary>properties</summary>
{plant && <div>
available signals:
&nbsp;
{plant.signals.join(', ')}
</div>}
{properties.map((property, i) => {
const result = propertyResults && propertyResults[i];
let violated = null, propertyError = null;
if (result) {
violated = result[0] && result[0].length > 0 && !result[0][0].satisfied;
propertyError = result[1];
}
return <div style={{width:'100%'}} key={i} className="toolbar">
<div className={"status" + (violated === null ? "" : (violated ? " violated" : " satisfied"))}></div>
<button title="see in trace (below)" className={activeProperty === i ? "active" : ""} onClick={() => setActiveProperty(i)}>
<VisibilityIcon fontSize="small"/>
</button>
<input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
<button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
{propertyError && <div style={{color: 'var(--error-color)'}}>{propertyError}</div>}
</div>;
})}
<div className="toolbar">
<button title="add property" onClick={() => setProperties(properties => [...properties, ""])}>
<AddIcon fontSize="small"/> add property
</button>
</div>
</details>
{/* Traces */}
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary>
<div>
{savedTraces.map((savedTrace, i) =>
<div key={i} className="toolbar">
<button title="replay trace (may give a different result if you changed your model since recording the trace because only input and timer events are recorded)" onClick={() => onReplayTrace(savedTrace[1])}>
<CachedOutlinedIcon fontSize="small"/>
</button>
&nbsp;
<span style={{display:'inline-block', width: 26, fontSize: 9}}>{(Math.floor(savedTrace[1].at(-1)!.simtime/1000))}s</span>
<span style={{display:'inline-block', width: 22, fontSize: 9}}>({savedTrace[1].length})</span>
&nbsp;
<input title="name of the trace (only for humans - names don't have to be unique or anything)" type="text" value={savedTrace[0]} style={{width: 'calc(100% - 124px)'}} onChange={e => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1, [e.target.value, savedTraces[i][1]]))}/>
<button title="forget trace" onClick={() => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
</div>
)}
</div>
<div className="toolbar">
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/>
<label title="plant steps are steps where only the state of the plant changed" htmlFor="checkbox-show-plant-items">show plant steps</label>
<input id="checkbox-autoscroll" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)}/>
<label title="automatically scroll down event trace when new events occur" htmlFor="checkbox-autoscroll">auto-scroll</label>
&emsp;
<button title="save current trace" disabled={trace === null} onClick={() => onSaveTrace()}>
<SaveOutlinedIcon fontSize="small"/> save trace
</button>
</div>
</details>
</div>
{/* We cheat a bit, and render the execution trace depending on whether the <details> above is 'open' or not, rather than putting it as a child of the <details>. We do this because only then can we get the execution trace to scroll without the rest scrolling as well. */}
{showExecutionTrace &&
<div style={{
flexGrow:1,
overflow:'auto',
minHeight: '50vh',
// minHeight: '75%', // <-- allows us to always scroll down the sidebar far enough such that the execution history is enough in view
}}>
<div ref={refRightSideBar}>
{ast && <RTHistory {...{ast, trace, setTrace, setTime, showPlantTrace,
propertyTrace: propertyResults && propertyResults[activeProperty] && propertyResults[activeProperty][0] || []}}/>}
</div>
</div>}
<div style={{flex: '0 0 content'}}>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Bottom panel */} {/* Bottom panel */}
<div style={{flex: '0 0 content'}}> <div style={{flex: '0 0 content'}}>
{syntaxErrors && <BottomPanel {...{errors: syntaxErrors}}/>} {syntaxErrors && <BottomPanel {...{errors: syntaxErrors, ...appState, setEditorState, ...setters}}/>}
</div> </div>
</div> </div>
</>; </ModalOverlay>
} </div>;
function autoDetectConns(ast: Statechart, plant: Plant<any, any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
for (const {event: a} of plant.uiEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['plant', b]}));
break;
}
}
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['sc', b]}));
}
}
}
for (const a of ast.outputEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['sc.'+a]: ['plant', b]}));
}
}
}
for (const {event: a} of plant.outputEvents) {
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.'+a]: ['sc', b]}));
}
}
}
}
function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns, setPlantConns: Dispatch<SetStateAction<Conns>>) {
const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</>
const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>;
return <>
{/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-sc-${e}`} style={{width:'50%'}}>sc.{e}&nbsp;&nbsp;</label>
<select id={`select-dst-sc-${e}`}
style={{width:'50%'}}
value={plantConns['sc.'+e]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`sc.${e}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{plantInputs}
</select>
</div>)}
{/* Plant output events can go to Statechart */}
{[...plant.outputEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-${e.event}`} style={{width:'50%'}}>plant.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.'+e.event]?.join('.')}
// @ts-ignore
onChange={(domEvent => setPlantConns(conns => ({...conns, [`plant.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))})))}>
<option key="none" value=""></option>
{scInputs}
</select>
</div>)]}
{/* Plant UI events typically go to the Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%', color: 'grey'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.ui.'+e.event]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
</>;
} }
export default App; export default App;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,9 @@ import { Dispatch, memo, SetStateAction, useCallback, useEffect } from "react";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo"; import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { setRealtime, TimeMode } from "@/statecharts/time"; 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>>}) { 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) => { 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*2).toString(), Math.round(performance.now()));
}, [onTimeScaleChange, timescale]); }, [onTimeScaleChange, timescale]);
const onKeyDown = useCallback((e: KeyboardEvent) => { useShortcuts([
// @ts-ignore {keys: ["s"], action: onSlower},
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return; {keys: ["f"], action: onFaster},
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])
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden; const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <> return <>
<label htmlFor="number-timescale">speed</label>&nbsp; <label htmlFor="number-timescale"><SpeedIcon fontSize="small"/></label>&nbsp;
<KeyInfo keyInfo={<kbd>S</kbd>}> <KeyInfo keyInfo={<kbd>S</kbd>}>
<button title="slower" onClick={onSlower}>÷2</button> <button title="slower" onClick={onSlower}>÷2</button>
</KeyInfo> </KeyInfo>

View file

@ -1,14 +1,22 @@
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react"; import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types"; import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time"; import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
import { InsertMode } from "./InsertModes";
import { About } from "../Modals/About"; import { About } from "../Modals/About";
import { EditHistory, TraceState } from "../App"; import { AppState, EditHistory, LightMode } from "../App";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo"; import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { UndoRedoButtons } from "./UndoRedoButtons"; import { UndoRedoButtons } from "./UndoRedoButtons";
import { ZoomButtons } from "./ZoomButtons"; import { ZoomButtons } from "./ZoomButtons";
import { formatTime } from "../../util/util"; 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 AccessAlarmIcon from '@mui/icons-material/AccessAlarm';
import CachedIcon from '@mui/icons-material/Cached'; import CachedIcon from '@mui/icons-material/Cached';
import InfoOutlineIcon from '@mui/icons-material/InfoOutline'; 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 SkipNextIcon from '@mui/icons-material/SkipNext';
import StopIcon from '@mui/icons-material/Stop'; import StopIcon from '@mui/icons-material/Stop';
import { InsertModes } from "./InsertModes"; import { InsertModes } from "./InsertModes";
import { usePersistentState } from "@/App/persistent_state"; import { usePersistentState } from "@/hooks/usePersistentState";
import { RotateButtons } from "./RotateButtons"; import { RotateButtons } from "./RotateButtons";
import { SpeedControl } from "./SpeedControl"; 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 = { export type TopPanelProps = {
trace: TraceState | null, trace: TraceState | null,
time: TimeMode, time: TimeMode,
setTime: Dispatch<SetStateAction<TimeMode>>, setTime: Dispatch<SetStateAction<TimeMode>>,
onUndo: () => void, onUndo: () => void,
onRedo: () => void, onRedo: () => void,
@ -32,32 +47,41 @@ export type TopPanelProps = {
onInit: () => void, onInit: () => void,
onClear: () => void, onClear: () => void,
onBack: () => 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>>, setModal: Dispatch<SetStateAction<ReactElement|null>>,
zoom: number, // zoom: number,
setZoom: Dispatch<SetStateAction<number>>, // setZoom: Dispatch<SetStateAction<number>>,
showKeys: boolean, // showKeys: boolean,
setShowKeys: Dispatch<SetStateAction<boolean>>, // setShowKeys: Dispatch<SetStateAction<boolean>>,
editHistory: EditHistory, editHistory: EditHistory,
} setEditorState: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
} & AppState & Setters<AppState>
const ShortCutShowKeys = <kbd>~</kbd>; 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) { function toggle(booleanSetter: Dispatch<(state: boolean) => boolean>) {
const [displayTime, setDisplayTime] = useState("0.000"); 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 [timescale, setTimescale] = usePersistentState("timescale", 1);
const config = trace && trace.trace[trace.idx]; const config = trace && trace.trace[trace.idx];
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
const updateDisplayedTime = useCallback(() => { const updateDisplayedTime = useCallback(() => {
const now = Math.round(performance.now()); const now = Math.round(performance.now());
const timeMs = getSimTime(time, now); const timeMs = getSimTime(time, now);
setDisplayTime(formatTime(timeMs)); setDisplayTime((timeMs));
}, [time, setDisplayTime]); }, [time, setDisplayTime]);
const formattedDisplayTime = useMemo(() => formatTime(displayTime), [displayTime]);
const lastSimTime = config?.simtime || 0;
useEffect(() => { 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. // 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(() => { const interval = setInterval(() => {
@ -98,53 +122,35 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
} }
}, [nextTimedTransition, setTime]); }, [nextTimedTransition, setTime]);
useEffect(() => { useShortcuts([
const onKeyDown = (e: KeyboardEvent) => { {keys: ["`"], action: toggle(setShowKeys)},
// don't capture keyboard events when focused on an input element: {keys: ["Ctrl", "Shift", "F"], action: toggle(setShowFindReplace)},
// @ts-ignore {keys: ["i"], action: onInit},
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return; {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) { const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
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]);
return <div className="toolbar"> return <div className="toolbar">
{/* light / dark mode
<div className="toolbarGroup">
<button title="force light mode" className={lightMode==="light"?"active":""} onClick={() => setLightMode("light")}>
<LightModeIcon fontSize="small"/>
</button>
<button title="auto light / dark mode (follows system theme)" className={lightMode==="auto"?"active":""} onClick={() => setLightMode("auto")}>
<BrightnessAutoIcon fontSize="small"/>
</button>
<button title="force dark mode" className={lightMode==="dark"?"active":""} onClick={() => setLightMode("dark")}>
<DarkModeIcon fontSize="small"/>
</button>
&emsp;
</div> */}
{/* shortcuts / about */} {/* shortcuts / about */}
<div className="toolbarGroup"> <div className="toolbarGroup">
<KeyInfo keyInfo={ShortCutShowKeys}> <KeyInfo keyInfo={ShortCutShowKeys}>
@ -172,11 +178,26 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
&emsp; &emsp;
</div> </div>
{/* rotate */}
<div className="toolbarGroup"> <div className="toolbarGroup">
<RotateButtons selection={editHistory.current.selection} onRotate={onRotate}/> <RotateButtons selection={editHistory.current.selection} onRotate={onRotate}/>
&emsp; &emsp;
</div> </div>
{/* find, replace */}
<div className="toolbarGroup">
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></>}>
<TwoStateButton
title="show find & replace"
active={showFindReplace}
onClick={() => setShowFindReplace(x => !x)}
>
<FindInPageOutlinedIcon fontSize="small"/>
</TwoStateButton>
</KeyInfo>
&emsp;
</div>
{/* execution */} {/* execution */}
<div className="toolbarGroup"> <div className="toolbarGroup">
@ -206,15 +227,20 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
{/* time, next */} {/* time, next */}
<div className="toolbarGroup"> <div className="toolbarGroup">
<div className="toolbarGroup"> <div className="toolbarGroup">
<label htmlFor="time">time (s)</label>&nbsp; <label htmlFor="time"><AccessTimeIcon fontSize="small"/></label>&nbsp;
<input title="the current simulated time" id="time" disabled={!config} value={displayTime} readOnly={true} className="readonlyTextBox" /> <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>
&emsp; &emsp;
<div className="toolbarGroup"> <div className="toolbarGroup">
<label htmlFor="next-timeout">next (s)</label>&nbsp; <label htmlFor="next-timeout"><AccessAlarmIcon fontSize="small"/></label>&nbsp;
<input title="next point in simulated time where a timed transition may fire" id="next-timeout" disabled={!config} value={nextTimedTransition ? formatTime(nextTimedTransition[0]) : '+inf'} readOnly={true} className="readonlyTextBox"/> <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>}> <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> </KeyInfo>
&emsp; &emsp;
</div> </div>

View file

@ -3,27 +3,14 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import UndoIcon from '@mui/icons-material/Undo'; import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo'; 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}) { 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) => { useShortcuts([
if (e.ctrlKey) { {keys: ["Ctrl", "z"], action: onUndo},
// ctrl is down {keys: ["Ctrl", "Shift", "Z"], action: onRedo},
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]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden; const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <> return <>

View file

@ -4,12 +4,20 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import ZoomInIcon from '@mui/icons-material/ZoomIn'; import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut'; import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import { useShortcuts } from "@/hooks/useShortcuts";
const shortcutZoomIn = <><kbd>Ctrl</kbd>+<kbd>-</kbd></>; const shortcutZoomIn = <><kbd>Ctrl</kbd>+<kbd>-</kbd></>;
const shortcutZoomOut = <><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>>}) { 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; const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
function onZoomIn() { function onZoomIn() {
@ -19,27 +27,6 @@ export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}:
setZoom(zoom => Math.max(zoom / ZOOM_STEP, ZOOM_MIN)); 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 <> return <>
<KeyInfo keyInfo={shortcutZoomOut}> <KeyInfo keyInfo={shortcutZoomOut}>
<button title="zoom out" onClick={onZoomOut} disabled={zoom <= ZOOM_MIN}><ZoomOutIcon fontSize="small"/></button> <button title="zoom out" onClick={onZoomOut} disabled={zoom <= ZOOM_MIN}><ZoomOutIcon fontSize="small"/></button>

View file

@ -2,7 +2,7 @@ import { memo } from "react";
import { Arrow, ArrowPart } from "../../statecharts/concrete_syntax"; import { Arrow, ArrowPart } from "../../statecharts/concrete_syntax";
import { ArcDirection, euclideanDistance } from "../../util/geometry"; import { ArcDirection, euclideanDistance } from "../../util/geometry";
import { CORNER_HELPER_RADIUS } from "../parameters"; 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 }) { 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>; </g>;
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
return prevProps.arrow === nextProps.arrow return jsonDeepEqual(prevProps.arrow, nextProps.arrow)
&& arraysEqual(prevProps.selected, nextProps.selected) && arraysEqual(prevProps.selected, nextProps.selected)
&& prevProps.highlight === nextProps.highlight && prevProps.highlight === nextProps.highlight
&& prevProps.error === nextProps.error && prevProps.error === nextProps.error

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { Rountangle, RectSide } from "../../statecharts/concrete_syntax";
import { ROUNTANGLE_RADIUS } from "../parameters"; import { ROUNTANGLE_RADIUS } from "../parameters";
import { RectHelper } from "./RectHelpers"; import { RectHelper } from "./RectHelpers";
import { rountangleMinSize } from "@/statecharts/concrete_syntax"; 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; }) { 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} /> highlight={props.highlight} />
</g>; </g>;
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
return prevProps.rountangle === nextProps.rountangle return jsonDeepEqual(prevProps.rountangle, nextProps.rountangle)
&& arraysEqual(prevProps.selected, nextProps.selected) && arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight) && arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error && prevProps.error === nextProps.error

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -5,9 +5,69 @@ html, body {
font-size: 10pt; font-size: 10pt;
} }
body { :root {
/* --error-color: darkred; */ color-scheme: light dark;
--error-color: rgb(163, 0, 0);
--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 { div#root {
@ -27,17 +87,18 @@ kbd {
box-shadow: inset 0 -1.5px 0 #aaa; box-shadow: inset 0 -1.5px 0 #aaa;
vertical-align: middle; vertical-align: middle;
user-select: none; user-select: none;
color: black;
} }
kbd:active { transform: translateY(1px); } kbd:active { transform: translateY(1px); }
input { input {
/* border: solid blue 2px; */ /* border: solid blue 2px; */
accent-color: rgba(0,0,255,0.2); accent-color: var(--accent-opaque-color);
} }
::selection { ::selection {
background-color: rgba(0,0,255,0.2); background-color: var(--light-accent-color);
} }
label { label {

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -31,12 +31,16 @@
TODO TODO
- bugs - 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: - maybe support:
- explicit order of: - explicit order of:
- outgoing transitions? - outgoing transitions?
- write documentation
although ideally the tool should explain itself...
- usability stuff: - usability stuff:
- action language: add increment operations (++) and (--) - action language: add increment operations (++) and (--)
and (+=) and (-=) and (+=) and (-=)
@ -48,11 +52,23 @@ TODO
- ability to hide statechart and only show the plant? - 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 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 selected shapes while making a selection
- highlight about-to-fire transitions - highlight about-to-fire transitions
- integrate undo-history with browser history (back/forward buttons) - 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: - performance:
maybe try this for rendering the execution trace: maybe try this for rendering the execution trace:
https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists
@ -63,11 +79,6 @@ TODO
https://pub.dev/packages/ploeg_tree_layout https://pub.dev/packages/ploeg_tree_layout
- local variable scopes - 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): Publish StateBuddy paper(s):
compare CS approach to other tools, not only YAKINDU compare CS approach to other tools, not only YAKINDU
z