Compare commits

..

No commits in common. "7994cd6eb07c586e069069f78de0b32185f258fc" and "9fd1c3a9a7fcc4a3e0d0f92c7160246ab01658bd" have entirely different histories.

71 changed files with 1939 additions and 3687 deletions

3
.gitignore vendored
View file

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

366
assignment.html Normal file

File diff suppressed because one or more lines are too long

View file

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

125
src/App/AST.css Normal file
View file

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

View file

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

10
src/App/BottomPanel.css Normal file
View file

@ -0,0 +1,10 @@
.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);
}

39
src/App/BottomPanel.tsx Normal file
View file

@ -0,0 +1,39 @@
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

@ -1,15 +0,0 @@
.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

@ -1,48 +0,0 @@
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

@ -1,48 +0,0 @@
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,5 +0,0 @@
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>
}

File diff suppressed because one or more lines are too long

View file

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

View file

@ -1,17 +0,0 @@
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

@ -1,14 +0,0 @@
// 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,4 +1,4 @@
import { usePersistentState } from "@/hooks/usePersistentState"
import { usePersistentState } from "@/App/persistent_state"
import { DetailsHTMLAttributes, Dispatch, PropsWithChildren, SetStateAction } from "react";
type Props = {

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 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: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

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

View file

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

View file

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

View file

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

View file

@ -1,86 +0,0 @@
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;
}

View file

@ -1,332 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,224 +0,0 @@
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

@ -1,36 +0,0 @@
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]);
}

View file

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

3
src/App/useSimulator.ts Normal file
View file

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

203
src/App/useUrlHashState.ts Normal file
View file

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

View file

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

View file

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

View file

@ -1,30 +0,0 @@
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

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

View file

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

View file

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

View file

@ -358,7 +358,7 @@ export function handleInternalEvents(simtime: number, statechart: Statechart, {i
const [nextEvent, ...remainingEvents] = internalEvents;
({internalEvents, ...rest} = fairStep(simtime,
{kind: "input", ...nextEvent}, // internal event becomes input event
statechart, statechart.root, { ...rest, arenasFired: [], internalEvents: remainingEvents, }));
statechart, statechart.root, { arenasFired: [], internalEvents: remainingEvents, ...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 { Connections } from "./detect_connections";
import { HISTORY_RADIUS } from "../App/parameters";
import { ConcreteSyntax } from "./concrete_syntax";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { memoize } from "@/util/util";
export type TraceableError = {

View file

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

File diff suppressed because one or more lines are too long

View file

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