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 # Finder (MacOS) folder config
.DS_Store .DS_Store
# When building the app, we include the git rev in the status bar. We do this by calling git and writing the rev to a file, which is then included by the app.
src/git-rev.txt

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

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

View file

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

View file

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

View file

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

Binary file not shown.

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 { EventTrigger } from "@/statecharts/label_ast";
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types"; import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive"; import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
import { setsEqual } from "@/util/util";
export type PlantRenderProps<StateType> = { export type PlantRenderProps<StateType> = {
state: StateType, state: StateType,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
.svgCanvas { .svgCanvas {
cursor: crosshair; cursor: crosshair;
background-color: var(--or-state-bg-color); background-color: #eee;
} }
.svgCanvas.dragging { .svgCanvas.dragging {
@ -19,15 +19,16 @@
/* rectangle drawn while a selection is being made */ /* rectangle drawn while a selection is being made */
.selecting { .selecting {
fill: var(--light-accent-color); fill: blue;
fill-opacity: 0.2;
stroke-width: 1px; stroke-width: 1px;
stroke: var(--accent-border-color); stroke:black;
stroke-dasharray: 7 6; stroke-dasharray: 7 6;
} }
.rountangle { .rountangle {
fill: var(--and-state-bg-color); fill: white;
stroke: var(--rountangle-stroke-color); stroke: black;
stroke-width: 2px; stroke-width: 2px;
} }
@ -38,10 +39,9 @@
stroke: var(--error-color); stroke: var(--error-color);
} }
.rountangle.active { .rountangle.active {
stroke: var(--active-state-border-color); stroke: rgb(205, 133, 0);
/* stroke: none; */ /* stroke: none; */
/* fill:rgb(255, 240, 214); */ fill:rgb(255, 240, 214);
fill: var(--active-state-bg-color);
/* filter: drop-shadow( 2px 2px 2px rgba(124, 37, 10, 0.729)); */ /* filter: drop-shadow( 2px 2px 2px rgba(124, 37, 10, 0.729)); */
} }
@ -54,7 +54,8 @@ line.helper {
stroke-width: 16px; stroke-width: 16px;
} }
line.helper:hover:not(:active) { line.helper:hover:not(:active) {
stroke: var(--light-accent-color); stroke: blue;
stroke-opacity: 0.2;
cursor: grab; cursor: grab;
} }
@ -64,7 +65,8 @@ path.helper {
stroke-width: 16px; stroke-width: 16px;
} }
path.helper:hover:not(:active) { path.helper:hover:not(:active) {
stroke: var(--light-accent-color); stroke: blue;
stroke-opacity: 0.2;
cursor: grab; cursor: grab;
} }
@ -72,22 +74,23 @@ circle.helper {
fill: rgba(0, 0, 0, 0); fill: rgba(0, 0, 0, 0);
} }
circle.helper:hover:not(:active) { circle.helper:hover:not(:active) {
fill: var(--light-accent-color); fill: blue;
fill-opacity: 0.2;
cursor: grab; cursor: grab;
} }
.rountangle.or { .rountangle.or {
stroke-dasharray: 7 6; stroke-dasharray: 7 6;
fill: var(--or-state-bg-color); fill: #eee;
} }
.arrow { .arrow {
fill: none; fill: none;
stroke: var(--rountangle-stroke-color); stroke: black;
stroke-width: 2px; stroke-width: 2px;
} }
.arrow.selected { .arrow.selected {
stroke: var(--accent-border-color); stroke: blue;
stroke-width: 3px; stroke-width: 3px;
} }
@ -107,29 +110,34 @@ circle.helper:hover:not(:active) {
} }
line.selected, circle.selected { line.selected, circle.selected {
fill: var(--light-accent-color); fill: rgba(0, 0, 255, 0.2);
stroke: var(--accent-border-color); /* stroke-dasharray: 7 6; */
stroke: blue;
stroke-width: 4px; stroke-width: 4px;
} }
.draggableText.selected, .draggableText.selected:hover { .draggableText.selected, .draggableText.selected:hover {
fill: var(--accent-border-color); fill: blue;
font-weight: 600; font-weight: 600;
} }
.draggableText:hover:not(:active) {
/* fill: blue; */
/* cursor: grab; */
}
text.helper { text.helper {
fill: rgba(0,0,0,0); fill: rgba(0,0,0,0);
stroke: rgba(0,0,0,0); stroke: rgba(0,0,0,0);
stroke-width: 6px; stroke-width: 6px;
} }
text.helper:hover { text.helper:hover {
stroke: var(--light-accent-color); stroke: blue;
stroke-opacity: 0.2;
cursor: grab; cursor: grab;
} }
.draggableText, .draggableText.highlight { .draggableText, .draggableText.highlight {
paint-order: stroke; paint-order: stroke;
fill: var(--text-color); stroke: white;
stroke: var(--background-color);
stroke-width: 4px; stroke-width: 4px;
stroke-linecap: butt; stroke-linecap: butt;
stroke-linejoin: miter; stroke-linejoin: miter;
@ -138,16 +146,12 @@ text.helper:hover {
} }
.draggableText.highlight:not(.selected) { .draggableText.highlight:not(.selected) {
fill: var(--associated-color); fill: green;
font-weight: 600; font-weight: 600;
} }
.draggableText.selected {
fill: var(--accent-border-color);
}
.highlight:not(.selected):not(text) { .highlight:not(.selected):not(text) {
stroke: var(--associated-color); stroke: green;
stroke-width: 3px; stroke-width: 3px;
fill: none; fill: none;
} }
@ -156,19 +160,19 @@ text.helper:hover {
stroke: var(--error-color); stroke: var(--error-color);
} }
.arrow.fired { .arrow.fired {
stroke: var(--fired-transition-color); stroke: rgb(160 0 168);
stroke-width: 3px; stroke-width: 3px;
animation: blinkTransition 1s; animation: blinkTransition 1s;
} }
@keyframes blinkTransition { @keyframes blinkTransition {
0% { 0% {
stroke: var(--firing-transition-color); stroke: rgb(255, 128, 9);
stroke-width: 6px; 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% { 100% {
stroke: var(--fired-transition-color); stroke: rgb(160 0 168);
} }
} }
@ -187,5 +191,5 @@ g:hover > .errorHover {
} }
text.uid { 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 { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { TraceState } from "@/App/App";
import { InsertMode } from "../TopPanel/InsertModes";
import { Mode } from "@/statecharts/runtime_types"; import { Mode } from "@/statecharts/runtime_types";
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util"; import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
import { 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 { Connections } from "../../statecharts/detect_connections";
import { TraceableError } from "../../statecharts/parser"; import { TraceableError } from "../../statecharts/parser";
import { ArcDirection, arcDirection } from "../../util/geometry"; import { ArcDirection, arcDirection } from "../../util/geometry";
import { InsertMode } from "../TopPanel/InsertModes";
import { ArrowSVG } from "./ArrowSVG"; import { ArrowSVG } from "./ArrowSVG";
import { DiamondSVG } from "./DiamondSVG"; import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG"; import { HistorySVG } from "./HistorySVG";
import { RountangleSVG } from "./RountangleSVG"; import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG"; import { TextSVG } from "./TextSVG";
import { useCopyPaste } from "./useCopyPaste";
import "./VisualEditor.css"; import "./VisualEditor.css";
import { useCopyPaste } from "./hooks/useCopyPaste"; import { useMouse } from "./useMouse";
import { useMouse } from "./hooks/useMouse"; import { Selecting } from "./Selection";
export type ConcreteSyntax = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
diamonds: Diamond[];
history: History[];
};
export type VisualEditorState = ConcreteSyntax & { export type VisualEditorState = ConcreteSyntax & {
nextID: number; nextID: number;
@ -22,19 +33,21 @@ export type VisualEditorState = ConcreteSyntax & {
}; };
export type RountangleSelectable = { export type RountangleSelectable = {
part: RectSide; // kind: "rountangle";
parts: RectSide[];
uid: string; uid: string;
} }
type ArrowSelectable = { type ArrowSelectable = {
part: ArrowPart; // kind: "arrow";
parts: ArrowPart[];
uid: string; uid: string;
} }
type TextSelectable = { type TextSelectable = {
part: "text"; parts: ["text"];
uid: string; uid: string;
} }
type HistorySelectable = { type HistorySelectable = {
part: "history"; parts: ["history"];
uid: string; uid: string;
} }
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable; type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
@ -43,26 +56,22 @@ export type Selection = Selectable[];
type VisualEditorProps = { type VisualEditorProps = {
state: VisualEditorState, state: VisualEditorState,
commitState: Dispatch<(v:VisualEditorState) => VisualEditorState>, setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
replaceState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
conns: Connections, conns: Connections,
syntaxErrors: TraceableError[], syntaxErrors: TraceableError[],
trace: TraceState | null,
insertMode: InsertMode, insertMode: InsertMode,
highlightActive: Set<string>, highlightActive: Set<string>,
highlightTransitions: string[], highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>, setModal: Dispatch<SetStateAction<ReactElement|null>>,
makeCheckPoint: () => void;
zoom: number; zoom: number;
}; };
export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom}: VisualEditorProps) { export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
// While dragging, the editor is in a temporary state (a state that is not committed to the edit history). If the temporary state is not null, then this state will be what you see.
// const [temporaryState, setTemporaryState] = useState<VisualEditorState | null>(null);
// const state = temporaryState || committedState;
// uid's of selected rountangles // uid's of selected rountangles
const selection = state.selection; const selection = state.selection || [];
const refSVG = useRef<SVGSVGElement>(null); const refSVG = useRef<SVGSVGElement>(null);
@ -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, const {onMouseDown, selectionRect} = useMouse(makeCheckPoint, insertMode, zoom, refSVG, state, setState, deleteSelection);
state,
commitState,
replaceState);
// for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected. // for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected.
@ -109,10 +115,12 @@ export const VisualEditor = memo(function VisualEditor({state, commitState, repl
for (const textUid of texts) { for (const textUid of texts) {
textsToHighlight[textUid] = true; textsToHighlight[textUid] = true;
} }
const arrows = conns.side2ArrowMap.get(selected.uid + '/' + selected.part) || []; for (const part of selected.parts) {
if (arrows) { const arrows = conns.side2ArrowMap.get(selected.uid + '/' + part) || [];
for (const [arrowPart, arrowUid] of arrows) { if (arrows) {
arrowsToHighlight[arrowUid] = true; for (const [arrowPart, arrowUid] of arrows) {
arrowsToHighlight[arrowUid] = true;
}
} }
} }
const arrow2 = conns.text2ArrowMap.get(selected.uid); const arrow2 = conns.text2ArrowMap.get(selected.uid);
@ -136,13 +144,13 @@ export const VisualEditor = memo(function VisualEditor({state, commitState, repl
const onEditText = useCallback((text: Text, newText: string) => { const onEditText = useCallback((text: Text, newText: string) => {
if (newText === "") { if (newText === "") {
// delete text node // delete text node
commitState(state => ({ setState(state => ({
...state, ...state,
texts: state.texts.filter(t => t.uid !== text.uid), texts: state.texts.filter(t => t.uid !== text.uid),
})); }));
} }
else { else {
commitState(state => ({ setState(state => ({
...state, ...state,
texts: state.texts.map(t => { texts: state.texts.map(t => {
if (t.uid === text.uid) { 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 rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
const size = 4000*zoom; const size = 4000*zoom;
return <svg width={size} height={size} return <svg width={size} height={size}
className={"svgCanvas"+(highlightActive.has("root")?" active":"")} className={"svgCanvas"+(active.has("root")?" active":"")/*+(dragging ? " dragging" : "")*/}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}
ref={refSVG} ref={refSVG}
@ -223,7 +234,7 @@ export const VisualEditor = memo(function VisualEditor({state, commitState, repl
return <ArrowSVG return <ArrowSVG
key={arrow.uid} key={arrow.uid}
arrow={arrow} 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 error={errors
.filter(({shapeUid}) => shapeUid === arrow.uid) .filter(({shapeUid}) => shapeUid === arrow.uid)
.map(({message}) => message).join(', ')} .map(({message}) => message).join(', ')}
@ -246,7 +257,7 @@ const Rountangles = memo(function Rountangles({rountangles, selection, sidesToHi
return <RountangleSVG return <RountangleSVG
key={rountangle.uid} key={rountangle.uid}
rountangle={rountangle} rountangle={rountangle}
selected={selection.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[]]} highlight={[...(sidesToHighlight[rountangle.uid] || []), ...(rountanglesToHighlight[rountangle.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors error={errors
.filter(({shapeUid}) => shapeUid === rountangle.uid) .filter(({shapeUid}) => shapeUid === rountangle.uid)
@ -267,7 +278,7 @@ const Diamonds = memo(function Diamonds({diamonds, selection, sidesToHighlight,
<DiamondSVG <DiamondSVG
key={diamond.uid} key={diamond.uid}
diamond={diamond} 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[]]} highlight={[...(sidesToHighlight[diamond.uid] || []), ...(rountanglesToHighlight[diamond.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors error={errors
.filter(({shapeUid}) => shapeUid === diamond.uid) .filter(({shapeUid}) => shapeUid === diamond.uid)
@ -288,7 +299,7 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
key={txt.uid} key={txt.uid}
error={errors.find(({shapeUid}) => txt.uid === shapeUid)} error={errors.find(({shapeUid}) => txt.uid === shapeUid)}
text={txt} text={txt}
selected={Boolean(selection.filter(s => s.uid === txt.uid).length)} selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)}
highlight={textsToHighlight.hasOwnProperty(txt.uid)} highlight={textsToHighlight.hasOwnProperty(txt.uid)}
onEdit={onEditText} onEdit={onEditText}
setModal={setModal} setModal={setModal}

View file

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

View file

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

View file

@ -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 { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
import { HISTORY_RADIUS } from "../parameters"; import { HISTORY_RADIUS } from "./parameters";
import { Dispatch, SetStateAction, useCallback, useEffect } from "react"; import { Dispatch, SetStateAction, useCallback, useEffect } from "react";
import { EditHistory } from "../App"; import { EditHistory } from "./App";
import { jsonDeepEqual } from "@/util/util"; import { VisualEditorState } from "./VisualEditor/VisualEditor";
import { VisualEditorState } from "../VisualEditor/VisualEditor";
export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) { export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) {
useEffect(() => { useEffect(() => {
@ -13,27 +12,13 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
} }
}, []); }, []);
const commitState = useCallback((callback: (oldState: VisualEditorState) => VisualEditorState) => { // append editor state to undo history
setEditHistory(historyState => { const makeCheckPoint = useCallback(() => {
if (historyState === null) return null; // no change setEditHistory(historyState => historyState && ({
const newEditorState = callback(historyState.current); ...historyState,
return { history: [...historyState.history, historyState.current],
current: newEditorState, future: [],
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]); }, [setEditHistory]);
const onUndo = useCallback(() => { const onUndo = useCallback(() => {
setEditHistory(historyState => { setEditHistory(historyState => {
@ -62,54 +47,62 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
}); });
}, [setEditHistory]); }, [setEditHistory]);
const onRotate = useCallback((direction: "ccw" | "cw") => { const onRotate = useCallback((direction: "ccw" | "cw") => {
commitState(editorState => { makeCheckPoint();
const selection = editorState.selection; setEditHistory(historyState => {
if (historyState === null) return null;
const selection = historyState.current.selection;
if (selection.length === 0) { if (selection.length === 0) {
return editorState; return historyState;
} }
// determine bounding box... in a convoluted manner // determine bounding box... in a convoluted manner
let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity; let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity;
function addPointToBBox({x,y}: Vec2D) { function addPointToBBox({x,y}: Vec2D) {
minX = Math.max(minX, x); minX = Math.max(minX, x);
minY = Math.max(minY, y); minY = Math.max(minY, y);
maxX = Math.min(maxX, x); maxX = Math.min(maxX, x);
maxY = Math.min(maxY, y); maxY = Math.min(maxY, y);
} }
for (const rt of editorState.rountangles) {
for (const rt of historyState.current.rountangles) {
if (selection.some(s => s.uid === rt.uid)) { if (selection.some(s => s.uid === rt.uid)) {
addPointToBBox(rt.topLeft); addPointToBBox(rt.topLeft);
addPointToBBox(addV2D(rt.topLeft, rt.size)); addPointToBBox(addV2D(rt.topLeft, rt.size));
} }
} }
for (const d of editorState.diamonds) { for (const d of historyState.current.diamonds) {
if (selection.some(s => s.uid === d.uid)) { if (selection.some(s => s.uid === d.uid)) {
addPointToBBox(d.topLeft); addPointToBBox(d.topLeft);
addPointToBBox(addV2D(d.topLeft, d.size)); addPointToBBox(addV2D(d.topLeft, d.size));
} }
} }
for (const arr of editorState.arrows) { for (const arr of historyState.current.arrows) {
if (selection.some(s => s.uid === arr.uid)) { if (selection.some(s => s.uid === arr.uid)) {
addPointToBBox(arr.start); addPointToBBox(arr.start);
addPointToBBox(arr.end); addPointToBBox(arr.end);
} }
} }
for (const txt of editorState.texts) { for (const txt of historyState.current.texts) {
if (selection.some(s => s.uid === txt.uid)) { if (selection.some(s => s.uid === txt.uid)) {
addPointToBBox(txt.topLeft); addPointToBBox(txt.topLeft);
} }
} }
const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS}; const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS};
for (const h of editorState.history) { for (const h of historyState.current.history) {
if (selection.some(s => s.uid === h.uid)) { if (selection.some(s => s.uid === h.uid)) {
addPointToBBox(h.topLeft); addPointToBBox(h.topLeft);
addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2))); addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2)));
} }
} }
const center: Vec2D = { const center: Vec2D = {
x: (minX + maxX) / 2, x: (minX + maxX) / 2,
y: (minY + maxY) / 2, y: (minY + maxY) / 2,
}; };
const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => { const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => {
if (selection.some(s => s.uid === shape.uid)) { if (selection.some(s => s.uid === shape.uid)) {
return cb(shape); return cb(shape);
@ -118,51 +111,56 @@ export function useEditor(setEditHistory: Dispatch<SetStateAction<EditHistory|nu
return shape; return shape;
} }
} }
return { return {
...editorState, ...historyState,
rountangles: editorState.rountangles.map(rt => mapIfSelected(rt, rt => { current: {
return { ...historyState.current,
...rt, rountangles: historyState.current.rountangles.map(rt => mapIfSelected(rt, rt => {
...(direction === "ccw" return {
? rotateRect90CCW(rt, center) ...rt,
: rotateRect90CW(rt, center)), ...(direction === "ccw"
} ? rotateRect90CCW(rt, center)
})), : rotateRect90CW(rt, center)),
arrows: editorState.arrows.map(arr => mapIfSelected(arr, arr => { }
return { })),
...arr, arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
...(direction === "ccw" return {
? rotateLine90CCW(arr, center) ...arr,
: rotateLine90CW(arr, center)), ...(direction === "ccw"
}; ? rotateLine90CCW(arr, center)
})), : rotateLine90CW(arr, center)),
diamonds: editorState.diamonds.map(d => mapIfSelected(d, d => { };
return { })),
...d, diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
...(direction === "ccw" return {
? rotateRect90CCW(d, center) ...d,
: rotateRect90CW(d, center)), ...(direction === "ccw"
}; ? rotateRect90CCW(d, center)
})), : rotateRect90CW(d, center)),
texts: editorState.texts.map(txt => mapIfSelected(txt, txt => { };
return { })),
...txt, texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
topLeft: (direction === "ccw" return {
? rotatePoint90CCW(txt.topLeft, center) ...txt,
: rotatePoint90CW(txt.topLeft, center)), topLeft: (direction === "ccw"
}; ? rotatePoint90CCW(txt.topLeft, center)
})), : rotatePoint90CW(txt.topLeft, center)),
history: editorState.history.map(h => mapIfSelected(h, h => { };
return { })),
...h, history: historyState.current.history.map(h => mapIfSelected(h, h => {
topLeft: (direction === "ccw" return {
? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize) ...h,
: subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize) topLeft: (direction === "ccw"
), ? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
}; : subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
})), ),
}; };
}); })),
},
}
})
}, [setEditHistory]); }, [setEditHistory]);
return {commitState, replaceState, onUndo, onRedo, onRotate};
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 elem = document.getElementById("root")!;
const app = ( const app = (
// <StrictMode> <StrictMode>
<App /> <App />
// </StrictMode> </StrictMode>
); );
if (import.meta.hot) { 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; font-size: 10pt;
} }
:root { body {
color-scheme: light dark; /* --error-color: darkred; */
--error-color: rgb(163, 0, 0);
--background-color: light-dark(white, rgb(31, 33, 36));
--text-color: light-dark(black, white);
--error-color: light-dark(rgb(163, 0, 0), rgb(255, 82, 82));
--error-bg-color: light-dark(lightpink, rgb(75, 0, 0));
--error-hover-bg-color: light-dark(rgb(102, 0, 0), rgb(238, 153, 153));
--light-accent-color: light-dark(rgba(0,0,255,0.2), rgba(78, 186, 248, 0.377));
--accent-border-color: light-dark(blue, rgb(64, 185, 255));
--accent-opaque-color: light-dark(#ccccff, #305b73);
--separator-color: light-dark(lightgrey, rgb(58, 58, 58));
--inactive-bg-color: light-dark(#f7f7f7, rgb(29, 29, 29));
--inactive-fg-color: light-dark(grey, rgb(70, 70, 70));
--button-bg-color: light-dark(#fcfcfc, rgb(44, 50, 63));
--textbox-bg-color: light-dark(white, rgb(36, 41, 40));
--modal-backdrop-color: light-dark(rgba(200,200,200,0.7), rgba(23, 22, 32, 0.849));
--status-inactive-color: light-dark(grey, grey);
--status-ok-color: light-dark(forestgreen, forestgreen);
--or-state-bg-color: light-dark(#eee, #000000);
--and-state-bg-color: light-dark(white, rgb(46, 46, 46));
--rountangle-stroke-color: light-dark(black, #d4d4d4);
--active-state-bg-color: light-dark(rgb(255, 240, 214), rgb(53, 37, 18));
--active-state-border-color: light-dark(rgb(205, 133, 0), rgb(235, 124, 21));
--fired-transition-color: light-dark(rgb(160, 0, 168), rgb(160, 0, 168));
--firing-transition-color: light-dark(rgba(255, 128, 9, 1), rgba(255, 128, 9, 1));
--associated-color: light-dark(green, rgb(186, 245, 119));
--greeter-bg-color: light-dark(rgb(255, 249, 235), rgb(24, 40, 70));
/* --bottom-panel-bg-color: light-dark(rgb(219, 219, 219), rgb(31, 33, 36)); */
--summary-hover-bg-color: light-dark(#eee, #2e2f35);
--internal-event-bg-color: light-dark(rgb(255, 218, 252), rgb(99, 27, 94));
--input-event-bg-color: light-dark(rgb(224, 247, 209), rgb(59, 95, 37));
--input-event-hover-bg-color: light-dark(rgb(195, 224, 176), rgb(59, 88, 40));
--input-event-active-bg-color: light-dark(rgb(176, 204, 158), rgb(77, 117, 53));
--output-event-bg-color: light-dark(rgb(230, 249, 255), rgb(28, 83, 104));
background-color: var(--background-color);
color: var(--text-color);
}
input {
background-color: var(--textbox-bg-color);
border: 1px solid var(--separator-color);
}
button {
background-color: var(--button-bg-color);
border: 1px var(--separator-color) solid;
}
button:not(:disabled):hover {
background-color: var(--light-accent-color);
}
button:disabled {
background-color: var(--inactive-bg-color);
color: var(--inactive-fg-color);
}
button.active {
border: solid var(--accent-border-color) 1px;
background-color: var(--light-accent-color);
color: var(--text-color);
} }
div#root { div#root {
@ -87,18 +27,17 @@ kbd {
box-shadow: inset 0 -1.5px 0 #aaa; box-shadow: inset 0 -1.5px 0 #aaa;
vertical-align: middle; vertical-align: middle;
user-select: none; user-select: none;
color: black;
} }
kbd:active { transform: translateY(1px); } kbd:active { transform: translateY(1px); }
input { input {
/* border: solid blue 2px; */ /* border: solid blue 2px; */
accent-color: var(--accent-opaque-color); accent-color: rgba(0,0,255,0.2);
} }
::selection { ::selection {
background-color: var(--light-accent-color); background-color: rgba(0,0,255,0.2);
} }
label { label {

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ import { Action, EventTrigger, Expression, ParsedText } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser"; import { parse as parseLabel, SyntaxError } from "./label_parser";
import { Connections } from "./detect_connections"; import { Connections } from "./detect_connections";
import { HISTORY_RADIUS } from "../App/parameters"; import { HISTORY_RADIUS } from "../App/parameters";
import { ConcreteSyntax } from "./concrete_syntax"; import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { memoize } from "@/util/util"; import { memoize } from "@/util/util";
export type TraceableError = { 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 // compare arrays by value
export function arraysEqual<T>(a: T[], b: T[], cmp: (a: T, b: T) => boolean = (a,b)=>a===b): boolean { export function arraysEqual<T>(a: T[], b: T[], cmp: (a: T, b: T) => boolean = (a,b)=>a===b): boolean {
if (a === b) if (a === b)

File diff suppressed because one or more lines are too long

View file

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