diff --git a/.gitignore b/.gitignore
index a14702c..d281236 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
+
+# When building the app, we include the git rev in the status bar. We do this by calling git and writing the rev to a file, which is then included by the app.
+src/git-rev.txt
diff --git a/assignment.html b/assignment.html
deleted file mode 100644
index b8252bb..0000000
--- a/assignment.html
+++ /dev/null
@@ -1,366 +0,0 @@
-
-
-
-Practical stuff
-
- Due Date: 23 November 2025, before 23:59 (Blackboard's clock).
- Team Size: 2 (pair design/programming)!
- Note that as of the 2017-2018 Academic Year, each International student should team up with "local"
- (i.e., whose Bachelor degree was obtained at the University of Antwerp).
- Assignment overview:
-
- Review the exercises
- Implement Statechart
- Write report
-
-
-
- Submitting your solution:
-
-
- Only one member of each team submits a full solution. Either a ZIP-archive if you have multiple files, or just a PDF containing your report.
-
-
- The other team member must submits a single (plain text or HTML) file containing only the names of both team members. This will allow us to put in grades for both team members in BlackBoard.
-
-
-
-
- Submission Medium:
- BlackBoard .
-
-
- Contact / TA:
- Joeri Exelmans .
-
-
-
-Goals
-The goal of this assignment is to familiarize yourself with Statechart modeling, simulation, debugging and,
- to a lesser extent, automatically checking requirements -specified as temporal logic formulas over behaviour traces- on your solution.
-
-
-
-Getting Started
-We will use the brand-new Statechart editor, simulation and testing environment StateBuddy , created by yours truly.
-
-No need to install anything. StateBuddy runs in the browser.
-
-Note: StateBuddy will be updated from time to time, to fix bugs or add new features.
-To make sure you have the latest version, in StateBuddy, use the shortcut Ctrl +Shift +R to refresh the page while clearing your browser cache.
-StateBuddy was tested in Firefox 144.0 and Chromium 141.0.7390.107.
-
-
-Exercises
-Before we start working on this assignment, we will solve 5 small exercises.
-Each exercise shows you a small Statechart model, and asks a question about its behavior.
-If you can solve the exercises, you will have a good (enough) understanding of the precise semantics of StateBuddy.
-
-The exercises can be opened by clicking on their respective links:
-
-
- nested timed transitions
-
- parent-first
-
- order of orthogonal regions
-
- crossing orthogonal regions
-
- internal events (yes, there's a bug here that i should fix)
-
-
-
-
- To solve the exercises, you
must have a good understanding of the precise semantics of StateBuddy .
- The semantics are as follows:
-
- The execution of a Statechart is a sequence of Run-To-Completion (RTC) steps
- An RTC-step can only triggered by:
-
- An input event
- A timer that elapses (actually, an elapsing timer generates an input event behind the scenes)
-
- An RTC-step is instanteneous: it takes zero time.
- In between RTC-steps, the Statechart is idle (it will not change its state), and time may pass.
- An RTC-step consists of one or more fair-steps:
-
- During the first fair-step, only the input event (or timer event) that triggered the RTC-step is active.
- During a fair-step, orthogonal regions are visited in lexicographical order . For instance, if you have regions labeled A and B, then A will be visited before B.
-
- Within one fair step, every (orthogonal / non-overlapping) region is allowed to fire at most one transition . A region will fire a transition only if it has an enabled transition (wrt. the currently active event, and the transition's guard condition).
-
- When a transition fires: first, all the exit actions of all the exited states are executed (in order: child to parent), then the action of the transition itself, followed by the enter actions of the entered states (in order: parent to child)
-
- In this example , when firing the transition from A to F, first the exit actions of A, B, and C are executed, then the actions of the transition itself, and finally the enter actions of D, E and F (in that order).
-
-
- Any internal events that are raised (as a result of firing transitions), are added to the internal event (FIFO) queue.
-
-
-
- When a fair-step has completed:
-
- if the internal event (FIFO) queue is not empty, then a new fair-step starts. The next event is popped from the queue, and it becomes the new active event.
- if the internal event queue is empty, then no more fair-steps are executed, and the RTC-step ends.
-
-
-
-
-
Example:
- Consider the linked Statechart .
- After initialization, the current states are: OrthogonalState, A, C, E.
- Then, the Statechart remains idle until it receives an input event. Suppose at time T=5s, the input event e is received. This triggers the execution of an RTC step.
- The RTC step starts with a fair-step, where regions r1 , r2 and r3 (in that order) are allowed to fire at most one transition each.
- Only r1 has an enabled transition (because event e is active), so only r1 fires.
- During the firing of that transition, the internal event f is raised, an appended to the internal event queue.
- The fair-step ends, and one more fair-step is executed, because the internal event queue is not empty.
- So again, r1 , r2 and r3 are allowed to fire at most one transition.
- This time, the regions r2 and r3 will fire, because event f is active.
- The second fair-step ends, and since the internal event queue is empty, the RTC step also ends.
- Even though all transitions fired in a certain order, all of it happened at the same point in (simulated) time.
- Now, the Statechart will again remain idle until another input event occurs.
-
-
- Time r1 r2 r3
- =0 Initialization enter A enter C enter E
- >0 && <5s Idle
- =5s RTC step (input=e ) Fair-step (event=e ) fire (exit A , raise f , enter B )
- Fair-step (event=f ) fire (exit C , enter D) fire (exit E , enter F )
- =5s End of RTC step
-
-
-
- Please remember that these precise semantics are specific to StateBuddy, although they are very similar to YAKINDU / Itemis Create. Other Statechart tools (e.g., STATEMATE, Rhapsody, StateFlow) have different semantics.
-
-
-Introduction to Assignment
-
-You will use the Statecharts formalism to model the controller of a classic digital watch (before smart watches existed).
-
-
-
-
-
-
-All user input happens through 4 buttons (one in each corner).
-The watch can display 6 numbers, in the form HH:MM:SS when displaying the current time, or the time of the alarm, or in the form MM:SS:HS (HS means hectoseconds) when displaying the chronometer.
-The watch has a background light that can be on or off.
-The watch can make a beep-sound.
-
The time can be edited.
-An alarm can be turned on or off. The alarm time can also be edited.
-The chronometer can be started, paused, and reset.
-
-
-Interfaces
-You will implement the plant (= digital watch) controller as a Statechart. The controller only talks to the plant via input-events and output-events. In StateBuddy, you can also interactively raise input events directly into the controller statechart (Debugger UI). Finally, the plant also has its own UI, which sends input events to the plant.
-For the curious student: Yes, the (simulated) plant is also implemented as a (rather big) Statechart .
-
-
- Overview of our simulated system-under-study.
-
-
-The plant can send the following events to the controller:
-
-
- Controller input event(s) Received when...
-
-
- topLeftPressed, topRightPressed, bottomLeftPressed, bottomRightPressed
- one of the 4 buttons is pressed
-
-
- topLeftReleased, topRightReleased, bottomLeftReleased, bottomRightReleased
- one of the 4 buttons is released
-
-
- alarm
- the alarm should go off
-
-
-
-
-The controller can send the following events to the plant:
-
-
- Controller output event(s) Effect
-
-
- lightOn, lightOff
- turns on / off the background light
-
-
- beep
- make a beep sound for 10ms
-
-
- incTime
- increment the watch's time by one second
-
-
- incChrono
- increment the watch's chrono by 1/100 second
-
-
- resetChrono
- sets the chronometer back to 00:00:00
-
-
- displayTime
- puts the watch into a mode where it displays the current time
-
-
- displayChrono
- puts the watch into a mode where it displays the chronometer
-
-
- displayAlarm
- puts the watch into a mode where it displays the time of the alarm
-
-
- setAlarm(boolean)
- turns the alarm on (true) or off (false). if the alarm is on, and the plant detects that the current time is equal to the alarm time, then the plant will immediately send the 'alarm' event (explained above) to the controller.
-
-
-
- beginEdit
- puts the plant into 'edit mode'. if the plant was displaying the current time, you can now edit the current time. if the plant was dispalying the alarm time, then you can now edit the alarm time. After entering edit mode, the 'hours' part of the display will start blinking, indicating that the 'hours' can be edited.
-
-
- endEdit
- ends the 'edit mode'.
-
-
- incSelection
- when in 'edit mode', will increase the currently blinking part (i.e, hours, minutes or seconds) of the display by one
-
-
- selectNext
- when in 'edit mode', will select the next item (hours -> minutes -> seconds -> hours) to edit
-
-
-
-Behavioral Requirements
-
- You may assume that initially, the plant is displaying the current time, the light is off, the alarm is off, the speaker is not beeping, and we are not in 'edit mode'. The chrono is zero and not running.
-
- For as long as the top-right button is pressed, the light should be on, and after the top-right button is released, the light should remain on for 2 seconds.
-
- When displaying the time, or displaying the chrono, pressing the top-left button toggles between time and chrono mode.
-
- When in chrono mode, pressing the bottom-right button toggles the chrono between 'paused' and 'running'.
- When in chrono mode, pressing the bottom-left button resets the chronometer to zero.
-
- When the chrono is running, the chronometer value is incremented by 1/100 second 100 times per second. The chronometer remains running until it is paused, even if we leave the chrono mode.
-
- The current time is incremented (ticks) by 1 second every second, even when we are not displaying the current time, except when we are editing the current time: then the time should not tick.
-
- Pressing the bottom-left button when the time is being displayed will show the alarm time and toggle the alarm (on/off).
- If then, the bottom-left button is held pressed for 2 seconds, we go into alarm edit mode.
-
- Likewise, when displaying the current time, and pressing and holding the bottom-right button for 2 seconds, we go into time edit mode.
-
- In edit mode, pressing the bottom-left button will immediately increment the current selected (blinking) numbers.
- In edit mode, holding the bottom-left button has the additional effect incrementing the current selected numbers every 100ms.
-
- In edit mode, pressing the bottom-right button will select the next numbers (hours -> minutes -> seconds -> hours).
-
-
-Starting point
-
-Use this link to the starting point for this assignment.
-
-Testing your solution
-To test your solution, initialize the execution, and interact with the plant UI. The execution can run in (scaled) real-time, with the ability to pause/resume.
-To gain more confidence that you correctly implemented the requirements, you can write Metric Temporal Logic (MTL) properties. An example of such a property is:
-
G (topRightPressed -> (lightOn U (~topRightPressed & F[0,2] lightOn)))
-meaning: "as long as the top-right button is pressed, the light should be on, and after the top-right button is released, the light should remain on for 2 seconds" AKA Requirement 1.
-
-
-Tip: ChatGPT is quite good at translating natural language to MTL properties!
-
-Note that none of these testing approaches are exhaustive (unlike model checking, which is exhaustive). Any property you write will only be checked on the current simulation trace.
-
-Report
-You are also required to write a small(*) (HTML or PDF) report.
-(*) I don't have time to read 100 pages!
-It must include the following:
-
- The names and student IDs of the team members
- The amount of time spent working on the project: Please be honest, this helps us estimate the workload for future assignments.
- Your workflow : How did you work together? (pair programming / divided the work / ...) In what order did you implement different features? Encountered any difficulties?
- A link to your solution (just copy the big URL including the hashtag when you're done).
- If there are non-trivial parts in your solution that require explanation, then include this explanation either in the model itself (as comments (starting with '//')), or in your report, whatever you prefer.
-
-
-
-What is expected
-Your solution needs to be precisely correct: superficially correct behavior when running the generated code with the GUI (e.g., seeing the water levels change) is not enough : the timing-related requirements are exact .
-The assignment has been designed specifically to encourage use of as many Statechart features as possible:
-
- composite states
- orthogonal states
- timed transitions
- internal events
- guard conditions
- transition actions
- enter/exit actions
- (variables)
- (history)
-
-Make sure you understand these features, and use them, where you think they are appropriate.
-To give you an indication of the complexity, my own solution consists of 19 AND-states, 10 OR-states, and 36 transitions.
-
-
-Additional resources
-
-
-
diff --git a/package.json b/package.json
index 73cf039..188c9c2 100644
--- a/package.json
+++ b/package.json
@@ -7,13 +7,12 @@
"module": "src/index.tsx",
"scripts": {
"dev": "bun --hot src/index.tsx",
- "build": "NODE_ENV=production bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
+ "build": "git rev-parse HEAD > src/git-rev.txt && NODE_ENV=production bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx"
},
"dependencies": {
"@fontsource/roboto": "^5.2.8",
"@mui/icons-material": "^7.3.4",
- // "argus-wasm": "git+https://deemz.org/git/joeri/argus-wasm.git#a4491b3433d48aa1f941bd5ad37b36f819d3b2ac",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
diff --git a/src/App/AST.css b/src/App/AST.css
deleted file mode 100644
index f2081f6..0000000
--- a/src/App/AST.css
+++ /dev/null
@@ -1,125 +0,0 @@
-details.active {
- border: rgb(192, 125, 0);
- background-color:rgb(255, 251, 244);
- filter: drop-shadow( 0px 0px 3px rgba(192, 125, 0, 0.856));
-}
-
-details > summary {
- padding: 2px;
-}
-
-/* these two rules add a bit of padding to an opened 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 has no children (besides the obvious 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;
-}
diff --git a/src/App/App.css b/src/App/App.css
index 51defe8..2929afe 100644
--- a/src/App/App.css
+++ b/src/App/App.css
@@ -1,61 +1,44 @@
-/* details {
- padding-left: 20;
-}
-summary {
- margin-left: -20;
-} */
-
details:has(+ details) {
- border-bottom: 1px lightgrey solid;
+ border-bottom: 1px var(--separator-color) solid;
}
.runtimeState {
padding: 4px;
- /* padding-left: 4px;
- padding-right: 4px;
- padding-top: 2px;
- padding-bottom: 2px; */
}
.runtimeState:has(+.runtimeState) {
- border-bottom: 1px lightgrey solid;
+ border-bottom: 1px var(--separator-color) solid;
}
.runtimeState:has(+.runtimeState.active) {
border-bottom: 0;
}
.runtimeState:hover {
- /* background-color: rgba(255, 140, 0, 0.2); */
- background-color: rgba(0,0,255,0.2);
+ background-color: var(--light-accent-color);
cursor: pointer;
}
.runtimeState.active {
- background-color: rgba(0,0,255,0.2);
- border: solid blue 1px;
+ background-color: var(--light-accent-color);
+ border: solid var(--accent-border-color) 1px;
}
.runtimeState.plantStep:not(.active) {
- background-color: #f7f7f7;
+ background-color: var(--inactive-bg-color);
}
.runtimeState.plantStep * {
- color: grey;
+ color: var(--inactive-fg-color);
}
.runtimeState.runtimeError {
- background-color: lightpink;
- color: darkred;
+ background-color: var(--error-bg-color);
+ color: var(--error-color); /* used to be darkred, but this one's a bit lighter */
}
.runtimeState.runtimeError.active {
- border-color: darkred;
+ border-color: var(--error-color);/* used to be darkred, but this one's a bit lighter */
}
-/* details:not(:has(details)) > summary::marker {
- color: white;
-} */
-
.readonlyTextBox {
width: 56;
- background-color:"#eee";
text-align: "right";
}
@@ -64,7 +47,7 @@ details:has(+ details) {
}
.toolbar input {
- height: 20px;
+ height: 22px;
}
.toolbar div {
vertical-align: bottom;
@@ -77,23 +60,6 @@ details:has(+ details) {
display: inline-block;
}
-button {
- background-color: #fcfcfc;
- border: 1px lightgrey solid;
-}
-
-button:not(:disabled):hover {
- background-color: rgba(0, 0, 255, 0.2);
-}
-
-button.active {
- border: solid blue 1px;
- background-color: rgba(0,0,255,0.2);
- /* margin-right: 1px; */
- /* margin-left: 0; */
- color: black;
-}
-
.modalOuter {
position: absolute;
width: 100%;
@@ -102,7 +68,7 @@ button.active {
justify-content: center;
align-items: center;
text-align: center;
- background-color: rgba(200,200,200,0.7);
+ background-color: var(--modal-backdrop-color);
z-index: 1000;
}
@@ -110,7 +76,7 @@ button.active {
position: relative;
text-align: center;
display: inline-block;
- background-color: white;
+ background-color: var(--background-color);
max-height: 100vh;
overflow: auto;
}
@@ -128,7 +94,7 @@ div.stackHorizontal {
div.status {
display: inline-block;
vertical-align: middle;
- background-color: grey;
+ background-color: var(--status-inactive-color);
border-radius: 50%;
height: 12px;
width: 12px;
@@ -141,6 +107,6 @@ div.status.violated {
}
div.status.satisfied {
- background-color: forestgreen;
+ background-color: var(--status-ok-color);
}
diff --git a/src/App/App.tsx b/src/App/App.tsx
index b18d96a..9855a60 100644
--- a/src/App/App.tsx
+++ b/src/App/App.tsx
@@ -1,36 +1,23 @@
import "../index.css";
import "./App.css";
-import { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
-import AddIcon from '@mui/icons-material/Add';
-import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
-import CachedOutlinedIcon from '@mui/icons-material/CachedOutlined';
-import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
-import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
-import VisibilityIcon from '@mui/icons-material/Visibility';
-
-import { Statechart } from "@/statecharts/abstract_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
-import { Conns, coupledExecution, statechartExecution } from "@/statecharts/timed_reactive";
-import { RuntimeError } from "../statecharts/interpreter";
import { parseStatechart } from "../statecharts/parser";
-import { BigStep, RaisedEvent } from "../statecharts/runtime_types";
-import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
-import { BottomPanel } from "./BottomPanel";
-import { PersistentDetails, PersistentDetailsLocalStorage } from "./PersistentDetails";
-import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
-import { dummyPlant } from "./Plant/Dummy/Dummy";
-import { microwavePlant } from "./Plant/Microwave/Microwave";
-import { Plant } from "./Plant/Plant";
-import { trafficLightPlant } from "./Plant/TrafficLight/TrafficLight";
-import { RTHistory } from "./RTHistory";
-import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
+import { BottomPanel } from "./BottomPanel/BottomPanel";
+import { defaultSideBarState, SideBar, SideBarState } from "./SideBar/SideBar";
+import { InsertMode } from "./TopPanel/InsertModes";
import { TopPanel } from "./TopPanel/TopPanel";
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
-import { checkProperty, PropertyCheckResult } from "./check_property";
-import { useEditor } from "./useEditor";
-import { useUrlHashState } from "./useUrlHashState";
+import { makeAllSetters } from "./makePartialSetter";
+import { useEditor } from "./hooks/useEditor";
+import { useSimulator } from "./hooks/useSimulator";
+import { useUrlHashState } from "../hooks/useUrlHashState";
+import { plants } from "./plants";
+import { emptyState } from "@/statecharts/concrete_syntax";
+import { ModalOverlay } from "./Overlays/ModalOverlay";
+import { FindReplace } from "./BottomPanel/FindReplace";
export type EditHistory = {
current: VisualEditorState,
@@ -38,110 +25,87 @@ export type EditHistory = {
future: VisualEditorState[],
}
-type UniversalPlantState = {[property: string]: boolean|number};
+export type AppState = {
+ showKeys: boolean,
+ zoom: number,
+ insertMode: InsertMode,
+ showFindReplace: boolean,
+ findText: string,
+ replaceText: string,
+} & SideBarState;
-const plants: [string, Plant][] = [
- ["dummy", dummyPlant],
- ["microwave", microwavePlant as unknown as Plant],
- ["digital watch", digitalWatchPlant as unknown as Plant],
- ["traffic light", trafficLightPlant as unknown as Plant],
-]
-
-export type TraceItemError = {
- cause: BigStepCause, // event name, or
- simtime: number,
- error: RuntimeError,
+const defaultAppState: AppState = {
+ showKeys: true,
+ zoom: 1,
+ insertMode: 'and',
+ showFindReplace: false,
+ findText: "",
+ replaceText: "",
+ ...defaultSideBarState,
}
-type CoupledState = {
- sc: BigStep,
- plant: BigStep,
- // plantCleanState: {[prop: string]: boolean|number},
-};
-
-export type BigStepCause = {
- kind: "init",
- simtime: 0,
-} | {
- kind: "input",
- simtime: number,
- eventName: string,
- param?: any,
-} | {
- kind: "timer",
- simtime: number,
-};
-
-export type TraceItem =
- { kind: "error" } & TraceItemError
-| { kind: "bigstep", simtime: number, cause: BigStepCause, state: CoupledState, outputEvents: RaisedEvent[] };
-
-export type TraceState = {
- trace: [TraceItem, ...TraceItem[]], // non-empty
- idx: number,
-};
+export type LightMode = "light" | "auto" | "dark";
export function App() {
const [editHistory, setEditHistory] = useState(null);
- const [trace, setTrace] = useState(null);
- const [time, setTime] = useState({kind: "paused", simtime: 0});
const [modal, setModal] = useState(null);
- const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
+ const {commitState, replaceState, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
const editorState = editHistory && editHistory.current;
const setEditorState = useCallback((cb: (value: VisualEditorState) => VisualEditorState) => {
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
}, [setEditHistory]);
- const {
- autoConnect,
- setAutoConnect,
- autoScroll,
- setAutoScroll,
- plantConns,
- setPlantConns,
- showKeys,
- setShowKeys,
- zoom,
- setZoom,
- insertMode,
- setInsertMode,
- plantName,
- setPlantName,
- showConnections,
- setShowConnections,
- showProperties,
- setShowProperties,
- showExecutionTrace,
- setShowExecutionTrace,
- showPlantTrace,
- setShowPlantTrace,
- properties,
- setProperties,
- savedTraces,
- setSavedTraces,
- activeProperty,
- setActiveProperty,
- } = useUrlHashState(editorState, setEditHistory);
- const plant = plants.find(([pn, p]) => pn === plantName)![1];
-
- const refRightSideBar = useRef(null);
-
// parse concrete syntax always:
const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]);
const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]);
const ast = parsed && parsed[0];
- const syntaxErrors = parsed && parsed[1] || [];
- const currentTraceItem = trace && trace.trace[trace.idx];
- const allErrors = [
- ...syntaxErrors,
- ...(currentTraceItem && currentTraceItem.kind === "error") ? [{
- message: currentTraceItem.error.message,
- shapeUid: currentTraceItem.error.highlight[0],
- }] : [],
- ];
+ const [appState, setAppState] = useState(defaultAppState);
+
+ const persist = useUrlHashState(
+ 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(null);
const scrollDownSidebar = useCallback(() => {
if (autoScroll && refRightSideBar.current) {
const el = refRightSideBar.current;
@@ -152,492 +116,91 @@ export function App() {
}
}, [refRightSideBar.current, autoScroll]);
- // coupled execution
- const cE = useMemo(() => ast && coupledExecution({
- sc: statechartExecution(ast),
- plant: plant.execution,
- }, {
- ...plantConns,
- ...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
- }), [ast]);
+ const simulator = useSimulator(ast, plant, plantConns, scrollDownSidebar);
+
+ // console.log('render app', {ast, plant, appState});
+ // useDetectChange(ast, 'ast');
+ // useDetectChange(plant, 'plant');
+ // useDetectChange(scrollDownSidebar, 'scrollDownSidebar');
+ // useDetectChange(appState, 'appState');
+ // useDetectChange(simulator.time, 'simulator.time');
+ // useDetectChange(simulator.trace, 'simulator.trace');
- const onInit = useCallback(() => {
- if (cE === null) return;
- const metadata = {simtime: 0, cause: {kind: "init" as const, simtime: 0 as const}};
- try {
- const [outputEvents, state] = cE.initial(); // may throw if initialing the statechart results in a RuntimeError
- setTrace({
- trace: [{kind: "bigstep", ...metadata, state, outputEvents}],
- idx: 0,
- });
- }
- catch (error) {
- if (error instanceof RuntimeError) {
- setTrace({
- trace: [{kind: "error", ...metadata, error}],
- idx: 0,
- });
- }
- else {
- throw error; // probably a bug in the interpreter
- }
- }
- setTime(time => {
- if (time.kind === "paused") {
- return {...time, simtime: 0};
- }
- else {
- return {...time, since: {simtime: 0, wallclktime: performance.now()}};
- }
- });
- scrollDownSidebar();
- }, [cE, scrollDownSidebar]);
-
- const onClear = useCallback(() => {
- setTrace(null);
- setTime({kind: "paused", simtime: 0});
- }, [setTrace, setTime]);
-
- // raise input event, producing a new runtime configuration (or a runtime error)
- const onRaise = (inputEvent: string, param: any) => {
- if (cE === null) return;
- if (currentTraceItem !== null /*&& ast.inputEvents.some(e => e.event === inputEvent)*/) {
- if (currentTraceItem.kind === "bigstep") {
- const simtime = getSimTime(time, Math.round(performance.now()));
- appendNewConfig(simtime, {kind: "input", simtime, eventName: inputEvent, param}, () => {
- return cE.extTransition(simtime, currentTraceItem.state, {kind: "input", name: inputEvent, param});
- });
- }
- }
- };
-
- // timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
- useEffect(() => {
- let timeout: NodeJS.Timeout | undefined;
- if (currentTraceItem !== null && cE !== null) {
- if (currentTraceItem.kind === "bigstep") {
- const nextTimeout = cE?.timeAdvance(currentTraceItem.state);
-
- const raiseTimeEvent = () => {
- appendNewConfig(nextTimeout, {kind: "timer", simtime: nextTimeout}, () => {
- return cE.intTransition(currentTraceItem.state);
- });
- }
-
- if (time.kind === "realtime") {
- const wallclkDelay = getWallClkDelay(time, nextTimeout, Math.round(performance.now()));
- if (wallclkDelay !== Infinity) {
- timeout = setTimeout(raiseTimeEvent, wallclkDelay);
- }
- }
- else if (time.kind === "paused") {
- if (nextTimeout <= time.simtime) {
- raiseTimeEvent();
- }
- }
- }
- }
- return () => {
- if (timeout) clearTimeout(timeout);
- }
- }, [time, currentTraceItem]); // <-- todo: is this really efficient?
-
- function appendNewConfig(simtime: number, cause: BigStepCause, computeNewState: () => [RaisedEvent[], CoupledState]) {
- let newItem: TraceItem;
- const metadata = {simtime, cause}
- try {
- const [outputEvents, state] = computeNewState(); // may throw RuntimeError
- newItem = {kind: "bigstep", ...metadata, state, outputEvents};
- }
- catch (error) {
- if (error instanceof RuntimeError) {
- newItem = {kind: "error", ...metadata, error};
- // also pause the simulation, for dramatic effect:
- setTime({kind: "paused", simtime});
- }
- else {
- throw error;
- }
- }
- // @ts-ignore
- setTrace(trace => ({
- trace: [
- ...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
- newItem,
- ],
- // idx: 0,
- idx: trace!.idx+1,
- }));
- scrollDownSidebar();
- }
-
- const onBack = useCallback(() => {
- if (trace !== null) {
- setTime(() => {
- if (trace !== null) {
- return {
- kind: "paused",
- simtime: trace.trace[trace.idx-1].simtime,
- }
- }
- return { kind: "paused", simtime: 0 };
- });
- setTrace({
- ...trace,
- idx: trace.idx-1,
- });
- }
- }, [trace]);
+ const setters = makeAllSetters(setAppState, Object.keys(appState) as (keyof AppState)[]);
+ const syntaxErrors = parsed && parsed[1] || [];
+ const currentTraceItem = simulator.trace && simulator.trace.trace[simulator.trace.idx];
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
+ const allErrors = [
+ ...syntaxErrors,
+ ...(currentTraceItem && currentTraceItem.kind === "error") ? [{
+ message: currentTraceItem.error.message,
+ shapeUid: currentTraceItem.error.highlight[0],
+ }] : [],
+ ];
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || [];
-
- const speed = time.kind === "paused" ? 0 : time.scale;
-
const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1];
- useEffect(() => {
- ast && autoConnect && autoDetectConns(ast, plant, setPlantConns);
- }, [ast, plant, autoConnect]);
+ return
+
+ {/* top-to-bottom: everything -> bottom panel */}
+
- const [propertyResults, setPropertyResults] = useState
(null);
+ {/* left-to-right: main -> sidebar */}
+
-
- const onSaveTrace = () => {
- if (trace) {
- setSavedTraces(savedTraces => [
- ...savedTraces,
- ["untitled", trace.trace.map((item) => item.cause)] as [string, BigStepCause[]],
- ]);
- }
- }
-
- const onReplayTrace = (causes: BigStepCause[]) => {
- if (cE) {
- function run_until(simtime: number) {
- while (true) {
- const nextTimeout = cE!.timeAdvance(lastState);
- if (nextTimeout > simtime) {
- break;
- }
- const [outputEvents, coupledState] = cE!.intTransition(lastState);
- lastState = coupledState;
- lastSimtime = nextTimeout;
- newTrace.push({kind: "bigstep", simtime: nextTimeout, state: coupledState, outputEvents, cause: {kind: "timer", simtime: nextTimeout}});
- }
- }
- const [outputEvents, coupledState] = cE.initial();
- const newTrace = [{kind: "bigstep", simtime: 0, state: coupledState, outputEvents, cause: {kind: "init"} as BigStepCause} as TraceItem] as [TraceItem, ...TraceItem[]];
- let lastState = coupledState;
- let lastSimtime = 0;
- for (const cause of causes) {
- if (cause.kind === "input") {
- run_until(cause.simtime); // <-- just make sure we haven't missed any timers elapsing
- // @ts-ignore
- const [outputEvents, coupledState] = cE.extTransition(cause.simtime, newTrace.at(-1)!.state, {kind: "input", name: cause.eventName, param: cause.param});
- lastState = coupledState;
- lastSimtime = cause.simtime;
- newTrace.push({kind: "bigstep", simtime: cause.simtime, state: coupledState, outputEvents, cause});
- }
- else if (cause.kind === "timer") {
- run_until(cause.simtime);
- }
- }
- setTrace({trace: newTrace, idx: newTrace.length-1});
- setTime({kind: "paused", simtime: lastSimtime});
- }
- }
-
- // if some properties change, re-evaluate them:
- useEffect(() => {
- let timeout: NodeJS.Timeout;
- if (trace) {
- setPropertyResults(null);
- timeout = setTimeout(() => {
- Promise.all(properties.map((property, i) => {
- return checkProperty(plant, property, trace.trace);
- }))
- .then(results => {
- setPropertyResults(results);
- })
- })
- }
- return () => clearTimeout(timeout);
- }, [properties, trace, plant]);
-
- return <>
-
- {/* Modal dialog */}
- {modal &&
setModal(null)}>
-
- e.stopPropagation()}>
- {modal}
-
-
-
}
-
- {/* top-to-bottom: everything -> bottom panel */}
-
-
- {/* left-to-right: main -> sidebar */}
-
-
- {/* top-to-bottom: top bar, editor */}
-
- {/* Top bar */}
-
- {editHistory && }
-
- {/* Editor */}
-
- {editorState && conns && syntaxErrors &&
- }
-
-
-
- {/* Right: sidebar */}
-
-
-
- {/* State tree */}
-
- state tree
-
-
- {/* Input events */}
-
- input events
- {ast && onRaise("debug."+e,p)}
- disabled={trace===null || trace.trace[trace.idx].kind === "error"}
- showKeys={showKeys}/>}
-
- {/* Internal events */}
-
- internal events
- {ast && }
-
- {/* Output events */}
-
- output events
- {ast && }
-
- {/* Plant */}
-
- plant
- setPlantName(() => e.target.value)}>
- {plants.map(([plantName, p]) =>
- {plantName}
- )}
-
-
- {/* Render plant */}
- { onRaise("plant.ui."+e.name, e.param)}
- />}
-
- {/* Connections */}
-
- connections
- setAutoConnect(c => !c)}>
-
-
- {ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
-
- {/* Properties */}
-
setShowProperties(e.newState === "open")}>
- properties
- {plant &&
- available signals:
-
- {plant.signals.join(', ')}
-
}
- {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
-
-
setActiveProperty(i)}>
-
-
-
setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
-
setProperties(properties => properties.toSpliced(i, 1))}>
-
-
- {propertyError &&
{propertyError}
}
-
;
- })}
-
-
setProperties(properties => [...properties, ""])}>
- add property
-
-
-
- {/* Traces */}
-
setShowExecutionTrace(e.newState === "open")}>execution trace
+ {/* top-to-bottom: top bar, editor */}
+
+ {/* Top bar */}
+
+ {editHistory && }
+
+ {/* Editor */}
+
+ {editorState && conns && syntaxErrors &&
+ }
+
+
+ {appState.showFindReplace &&
- {savedTraces.map((savedTrace, i) =>
-
- onReplayTrace(savedTrace[1])}>
-
-
-
- {(Math.floor(savedTrace[1].at(-1)!.simtime/1000))}s
- ({savedTrace[1].length})
-
- setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1, [e.target.value, savedTraces[i][1]]))}/>
- setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1))}>
-
-
-
- )}
+
setters.setShowFindReplace(false)}/>
-
- setShowPlantTrace(e.target.checked)}/>
- show plant steps
- setAutoScroll(e.target.checked)}/>
- auto-scroll
-
- onSaveTrace()}>
- save trace
-
-
-
+ }
+
- {/* We cheat a bit, and render the execution trace depending on whether the above is 'open' or not, rather than putting it as a child of the . We do this because only then can we get the execution trace to scroll without the rest scrolling as well. */}
- {showExecutionTrace &&
- }
-
+ {/* Right: sidebar */}
+
+
+ {/* Bottom panel */}
+
+ {syntaxErrors && }
+
-
-
- {/* Bottom panel */}
-
- {syntaxErrors && }
-
-
- >;
-}
-
-function autoDetectConns(ast: Statechart, plant: Plant
, setPlantConns: Dispatch>) {
- 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, plantConns: Conns, setPlantConns: Dispatch>) {
- const plantInputs = <>{plant.inputEvents.map(e => plant.{e.event} )}>
- const scInputs = <>{ast.inputEvents.map(e => sc.{e.event} )}>;
- return <>
-
- {/* SC output events can go to Plant */}
- {[...ast.outputEvents].map(e =>
- sc.{e} →
- setPlantConns(conns => ({...conns, [`sc.${e}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
-
- {plantInputs}
-
-
)}
-
- {/* Plant output events can go to Statechart */}
- {[...plant.outputEvents.map(e =>
- plant.{e.event} →
- setPlantConns(conns => ({...conns, [`plant.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))})))}>
-
- {scInputs}
-
-
)]}
-
- {/* Plant UI events typically go to the Plant */}
- {plant.uiEvents.map(e =>
- ui.{e.event} →
- setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
-
- {scInputs}
- {plantInputs}
-
-
)}
- >;
+
+ ;
}
export default App;
-
diff --git a/src/App/BottomPanel.css b/src/App/BottomPanel.css
deleted file mode 100644
index 92c1a48..0000000
--- a/src/App/BottomPanel.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.errorStatus {
- /* background-color: rgb(230,0,0); */
- background-color: var(--error-color);
- color: white;
-}
-
-.bottom {
- border-top: 1px lightgrey solid;
- background-color: rgb(255, 249, 235);
-}
\ No newline at end of file
diff --git a/src/App/BottomPanel.tsx b/src/App/BottomPanel.tsx
deleted file mode 100644
index 2f84992..0000000
--- a/src/App/BottomPanel.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useEffect, useState } from "react";
-import { TraceableError } from "../statecharts/parser";
-
-import "./BottomPanel.css";
-
-import logo from "../../artwork/logo-playful.svg";
-import { PersistentDetailsLocalStorage } from "./PersistentDetails";
-
-export function BottomPanel(props: {errors: TraceableError[]}) {
- const [greeting, setGreeting] = useState(
-
-
- Welcome to
-
-
);
-
- useEffect(() => {
- setTimeout(() => {
- setGreeting(<>>);
- }, 2000);
- }, []);
-
- return
- {greeting}
- {props.errors.length > 0 &&
-
-
- {props.errors.length} errors
-
- {props.errors.map(({message, shapeUid})=>
-
- {shapeUid}: {message}
-
)}
-
-
-
- }
-
;
-}
\ No newline at end of file
diff --git a/src/App/BottomPanel/BottomPanel.css b/src/App/BottomPanel/BottomPanel.css
new file mode 100644
index 0000000..098ef48
--- /dev/null
+++ b/src/App/BottomPanel/BottomPanel.css
@@ -0,0 +1,15 @@
+.errorStatus {
+ /* background-color: rgb(230,0,0); */
+ background-color: var(--error-color);
+ color: var(--background-color);
+}
+
+.greeter {
+ /* border-top: 1px var(--separator-color) solid; */
+ background-color: var(--greeter-bg-color);
+}
+
+.bottom {
+ border-top: 1px var(--separator-color) solid;
+ background-color: var(--bottom-panel-bg-color);
+}
\ No newline at end of file
diff --git a/src/App/BottomPanel/BottomPanel.tsx b/src/App/BottomPanel/BottomPanel.tsx
new file mode 100644
index 0000000..d1f5e3a
--- /dev/null
+++ b/src/App/BottomPanel/BottomPanel.tsx
@@ -0,0 +1,48 @@
+import { Dispatch, useEffect, useState } from "react";
+import { TraceableError } from "../../statecharts/parser";
+
+import "./BottomPanel.css";
+
+import { PersistentDetailsLocalStorage } from "../Components/PersistentDetails";
+import { Logo } from "@/App/Logo/Logo";
+import { AppState } from "../App";
+import { FindReplace } from "./FindReplace";
+import { VisualEditorState } from "../VisualEditor/VisualEditor";
+import { Setters } from "../makePartialSetter";
+
+import gitRev from "@/git-rev.txt";
+
+export function BottomPanel(props: {errors: TraceableError[], setEditorState: Dispatch<(state: VisualEditorState) => VisualEditorState>} & AppState & Setters
) {
+ const [greeting, setGreeting] = useState(
+
+
+ Welcome to
+
+
);
+
+ useEffect(() => {
+ setTimeout(() => {
+ setGreeting(<>>);
+ }, 2000);
+ }, []);
+
+ return
+ {/* {props.showFindReplace &&
+
+ props.setShowFindReplace(false)}/>
+
+ } */}
+
+
+ {props.errors.length} errors
+
+ {props.errors.map(({message, shapeUid})=>
+
+ {shapeUid}: {message}
+
)}
+
+
+
+ {greeting}
+
;
+}
diff --git a/src/App/BottomPanel/FindReplace.tsx b/src/App/BottomPanel/FindReplace.tsx
new file mode 100644
index 0000000..4ea9326
--- /dev/null
+++ b/src/App/BottomPanel/FindReplace.tsx
@@ -0,0 +1,48 @@
+import { Dispatch, useCallback, useEffect } from "react";
+import { VisualEditorState } from "../VisualEditor/VisualEditor";
+import { usePersistentState } from "@/hooks/usePersistentState";
+
+import CloseIcon from '@mui/icons-material/Close';
+import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
+import { useShortcuts } from "@/hooks/useShortcuts";
+
+type FindReplaceProps = {
+ setCS: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
+ // setModal: (modal: null) => void;
+ hide: () => void,
+};
+
+export function FindReplace({setCS, hide}: FindReplaceProps) {
+ const [findTxt, setFindText] = usePersistentState("findTxt", "");
+ const [replaceTxt, setReplaceTxt] = usePersistentState("replaceTxt", "");
+
+ const onReplace = useCallback(() => {
+ setCS(cs => {
+ return {
+ ...cs,
+ texts: cs.texts.map(txt => ({
+ ...txt,
+ text: txt.text.replaceAll(findTxt, replaceTxt)
+ })),
+ };
+ });
+ }, [findTxt, replaceTxt]);
+
+ useShortcuts([
+ {keys: ["Enter"], action: onReplace},
+ ])
+
+ const onSwap = useCallback(() => {
+ setReplaceTxt(findTxt);
+ setFindText(replaceTxt);
+ }, [findTxt, replaceTxt]);
+
+ return
+ setFindText(e.target.value)} style={{width:300}}/>
+
+ setReplaceTxt(e.target.value))} style={{width:300}}/>
+
+ replace all
+
+
;
+}
\ No newline at end of file
diff --git a/src/App/PersistentDetails.tsx b/src/App/Components/PersistentDetails.tsx
similarity index 93%
rename from src/App/PersistentDetails.tsx
rename to src/App/Components/PersistentDetails.tsx
index 4d24bb0..c2f553b 100644
--- a/src/App/PersistentDetails.tsx
+++ b/src/App/Components/PersistentDetails.tsx
@@ -1,4 +1,4 @@
-import { usePersistentState } from "@/App/persistent_state"
+import { usePersistentState } from "@/hooks/usePersistentState"
import { DetailsHTMLAttributes, Dispatch, PropsWithChildren, SetStateAction } from "react";
type Props = {
diff --git a/src/App/Components/TwoStateButton.tsx b/src/App/Components/TwoStateButton.tsx
new file mode 100644
index 0000000..60cec2c
--- /dev/null
+++ b/src/App/Components/TwoStateButton.tsx
@@ -0,0 +1,5 @@
+import { ButtonHTMLAttributes, PropsWithChildren } from "react";
+
+export function TwoStateButton({active, children, className, ...rest}: PropsWithChildren<{active: boolean} & ButtonHTMLAttributes>) {
+ return {children}
+}
diff --git a/src/App/Logo/Logo.tsx b/src/App/Logo/Logo.tsx
new file mode 100644
index 0000000..1aa429f
--- /dev/null
+++ b/src/App/Logo/Logo.tsx
@@ -0,0 +1,1578 @@
+// i couldn't find a better way to make the text in the logo adapt to light/dark mode...
+export function Logo() {
+ return
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ;
+}
diff --git a/src/App/Modals/About.tsx b/src/App/Modals/About.tsx
index 9595c81..cd0605b 100644
--- a/src/App/Modals/About.tsx
+++ b/src/App/Modals/About.tsx
@@ -1,9 +1,9 @@
+import { Logo } from "@/App/Logo/Logo";
import { Dispatch, ReactElement, SetStateAction } from "react";
-import logo from "../../../artwork/logo-playful.svg";
export function About(props: {setModal: Dispatch>}) {
return
-
+
StateBuddy is an open source tool for Statechart editing, simulation, (omniscient) debugging and testing.
diff --git a/src/App/Modals/TextDialog.tsx b/src/App/Modals/TextDialog.tsx
index a478877..ead2abc 100644
--- a/src/App/Modals/TextDialog.tsx
+++ b/src/App/Modals/TextDialog.tsx
@@ -1,24 +1,20 @@
-import { Dispatch, ReactElement, SetStateAction, useState, KeyboardEvent, useEffect, useRef } from "react";
+import { Dispatch, ReactElement, SetStateAction, useState, useCallback } from "react";
import { cachedParseLabel } from "@/statecharts/parser";
+import { useShortcuts } from "@/hooks/useShortcuts";
export function TextDialog(props: {setModal: Dispatch
>, text: string, done: (newText: string|undefined) => void}) {
const [text, setText] = useState(props.text);
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Enter") {
- if (!e.shiftKey) {
- e.preventDefault();
+ useShortcuts([
+ {keys: ["Enter"], action: useCallback(() => {
props.done(text);
props.setModal(null);
- }
- }
- if (e.key === "Escape") {
- props.setModal(null);
- e.stopPropagation();
- }
- e.stopPropagation();
- }
+ }, [text, props.done, props.setModal])},
+ {keys: ["Escape"], action: useCallback(() => {
+ props.setModal(null);
+ }, [props.setModal])},
+ ], false);
let parseError = "";
try {
@@ -28,13 +24,15 @@ export function TextDialog(props: {setModal: Dispatch
- Text label:
+ return ;
-}
\ No newline at end of file
+}
diff --git a/src/App/Overlays/ModalOverlay.tsx b/src/App/Overlays/ModalOverlay.tsx
new file mode 100644
index 0000000..965879d
--- /dev/null
+++ b/src/App/Overlays/ModalOverlay.tsx
@@ -0,0 +1,17 @@
+import { Dispatch, PropsWithChildren, ReactElement, SetStateAction } from "react";
+
+export function ModalOverlay(props: PropsWithChildren<{modal: ReactElement|null, setModal: Dispatch>}>) {
+ return <>
+ {props.modal && props.setModal(null)}>
+
+ e.stopPropagation()}>
+ {props.modal}
+
+
+
}
+
+ {props.children}
+ >;
+}
diff --git a/src/App/Overlays/WindowOverlay.tsx b/src/App/Overlays/WindowOverlay.tsx
new file mode 100644
index 0000000..01f51f7
--- /dev/null
+++ b/src/App/Overlays/WindowOverlay.tsx
@@ -0,0 +1,14 @@
+// import { Dispatch, PropsWithChildren, ReactElement, SetStateAction } from "react";
+// import { OverlayWindow } from "../App";
+
+// export function WindowOverlay(props: PropsWithChildren<{overlayWindows: OverlayWindow[]}>) {
+
+// return <>
+// {props.modal && props.setModal(null)}>
+//
}
+
+// {props.children}
+// >;
+// }
diff --git a/src/App/Plant/DigitalWatch/DigitalWatch.tsx b/src/App/Plant/DigitalWatch/DigitalWatch.tsx
index 9b41f94..95f0750 100644
--- a/src/App/Plant/DigitalWatch/DigitalWatch.tsx
+++ b/src/App/Plant/DigitalWatch/DigitalWatch.tsx
@@ -1,5 +1,5 @@
-import { useAudioContext } from "@/App/useAudioContext";
-import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
+import { useAudioContext } from "@/hooks/useAudioContext";
+import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import { RT_Statechart } from "@/statecharts/runtime_types";
diff --git a/src/App/Plant/Microwave/Microwave.tsx b/src/App/Plant/Microwave/Microwave.tsx
index 516b444..194a0bc 100644
--- a/src/App/Plant/Microwave/Microwave.tsx
+++ b/src/App/Plant/Microwave/Microwave.tsx
@@ -12,13 +12,13 @@ import { RT_Statechart } from "@/statecharts/runtime_types";
import { memo, useEffect } from "react";
import "./Microwave.css";
-import { useAudioContext } from "../../useAudioContext";
+import { useAudioContext } from "../../../hooks/useAudioContext";
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import microwaveConcreteSyntax from "./model.json";
-import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
+import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { objectsEqual } from "@/util/util";
export const [microwaveAbstractSyntax, microwaveErrors] = parseStatechart(microwaveConcreteSyntax as ConcreteSyntax, detectConnections(microwaveConcreteSyntax as ConcreteSyntax));
@@ -92,7 +92,7 @@ export const Microwave = memo(function Microwave({state: {bellRinging, magnetron
src: url(${fontDigital});
}
`}
-
+
{/* @ts-ignore */}
diff --git a/src/App/Plant/Microwave/originals/microwave.xcf b/src/App/Plant/Microwave/originals/microwave.xcf
new file mode 100644
index 0000000..b0c8c6d
Binary files /dev/null and b/src/App/Plant/Microwave/originals/microwave.xcf differ
diff --git a/src/App/Plant/Microwave/originals/transparent_small_closed_off.png b/src/App/Plant/Microwave/originals/transparent_small_closed_off.png
new file mode 100644
index 0000000..49346b2
Binary files /dev/null and b/src/App/Plant/Microwave/originals/transparent_small_closed_off.png differ
diff --git a/src/App/Plant/Microwave/originals/transparent_small_closed_on.png b/src/App/Plant/Microwave/originals/transparent_small_closed_on.png
new file mode 100644
index 0000000..cb5d557
Binary files /dev/null and b/src/App/Plant/Microwave/originals/transparent_small_closed_on.png differ
diff --git a/src/App/Plant/Microwave/originals/transparent_small_opened_off.png b/src/App/Plant/Microwave/originals/transparent_small_opened_off.png
new file mode 100644
index 0000000..43adb99
Binary files /dev/null and b/src/App/Plant/Microwave/originals/transparent_small_opened_off.png differ
diff --git a/src/App/Plant/Microwave/originals/transparent_small_opened_on.png b/src/App/Plant/Microwave/originals/transparent_small_opened_on.png
new file mode 100644
index 0000000..54181a6
Binary files /dev/null and b/src/App/Plant/Microwave/originals/transparent_small_opened_on.png differ
diff --git a/src/App/Plant/Microwave/small_closed_off.webp b/src/App/Plant/Microwave/small_closed_off.webp
index 00fa078..d742739 100644
Binary files a/src/App/Plant/Microwave/small_closed_off.webp and b/src/App/Plant/Microwave/small_closed_off.webp differ
diff --git a/src/App/Plant/Microwave/small_closed_on.webp b/src/App/Plant/Microwave/small_closed_on.webp
index 79051cf..29e5809 100644
Binary files a/src/App/Plant/Microwave/small_closed_on.webp and b/src/App/Plant/Microwave/small_closed_on.webp differ
diff --git a/src/App/Plant/Microwave/small_opened_off.webp b/src/App/Plant/Microwave/small_opened_off.webp
index 1ac707a..6bf6e37 100644
Binary files a/src/App/Plant/Microwave/small_opened_off.webp and b/src/App/Plant/Microwave/small_opened_off.webp differ
diff --git a/src/App/Plant/Microwave/small_opened_on.webp b/src/App/Plant/Microwave/small_opened_on.webp
index 13027f4..4bb9c48 100644
Binary files a/src/App/Plant/Microwave/small_opened_on.webp and b/src/App/Plant/Microwave/small_opened_on.webp differ
diff --git a/src/App/Plant/Plant.ts b/src/App/Plant/Plant.ts
index 444c848..afa26ef 100644
--- a/src/App/Plant/Plant.ts
+++ b/src/App/Plant/Plant.ts
@@ -3,7 +3,6 @@ import { Statechart } from "@/statecharts/abstract_syntax";
import { EventTrigger } from "@/statecharts/label_ast";
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
-import { setsEqual } from "@/util/util";
export type PlantRenderProps = {
state: StateType,
diff --git a/src/App/Plant/TrafficLight/TrafficLight.tsx b/src/App/Plant/TrafficLight/TrafficLight.tsx
index fb47f48..a361726 100644
--- a/src/App/Plant/TrafficLight/TrafficLight.tsx
+++ b/src/App/Plant/TrafficLight/TrafficLight.tsx
@@ -9,11 +9,11 @@ import { preload } from "react-dom";
import trafficLightConcreteSyntax from "./model.json";
import { parseStatechart } from "@/statecharts/parser";
-import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
+import { ConcreteSyntax } from "@/statecharts/concrete_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { RT_Statechart } from "@/statecharts/runtime_types";
-import { useAudioContext } from "@/App/useAudioContext";
+import { useAudioContext } from "@/hooks/useAudioContext";
import { memo, useEffect } from "react";
import { objectsEqual } from "@/util/util";
diff --git a/src/App/RTHistory.tsx b/src/App/SideBar/RTHistory.tsx
similarity index 94%
rename from src/App/RTHistory.tsx
rename to src/App/SideBar/RTHistory.tsx
index 938867d..7e69844 100644
--- a/src/App/RTHistory.tsx
+++ b/src/App/SideBar/RTHistory.tsx
@@ -1,10 +1,10 @@
import { Dispatch, memo, SetStateAction, useCallback } from "react";
-import { Statechart, stateDescription } from "../statecharts/abstract_syntax";
-import { Mode, RaisedEvent, RT_Event } from "../statecharts/runtime_types";
-import { formatTime } from "../util/util";
-import { TimeMode, timeTravel } from "../statecharts/time";
-import { BigStepCause, TraceItem, TraceState } from "./App";
+import { Statechart, stateDescription } from "../../statecharts/abstract_syntax";
+import { Mode, RaisedEvent, RT_Event } from "../../statecharts/runtime_types";
+import { formatTime } from "../../util/util";
+import { TimeMode, timeTravel } from "../../statecharts/time";
import { Environment } from "@/statecharts/environment";
+import { BigStepCause, TraceItem, TraceState } from "../hooks/useSimulator";
type RTHistoryProps = {
trace: TraceState|null,
diff --git a/src/App/ShowAST.tsx b/src/App/SideBar/ShowAST.tsx
similarity index 75%
rename from src/App/ShowAST.tsx
rename to src/App/SideBar/ShowAST.tsx
index 8657331..37e7cb9 100644
--- a/src/App/ShowAST.tsx
+++ b/src/App/SideBar/ShowAST.tsx
@@ -1,7 +1,10 @@
-import { ConcreteState, UnstableState, stateDescription, Transition } from "../statecharts/abstract_syntax";
-import { Action, EventTrigger, Expression } from "../statecharts/label_ast";
-
-import "./AST.css";
+import BoltIcon from '@mui/icons-material/Bolt';
+import { memo, useEffect } from "react";
+import { usePersistentState } from "../../hooks/usePersistentState";
+import { ConcreteState, stateDescription, Transition, UnstableState } from "../../statecharts/abstract_syntax";
+import { Action, EventTrigger, Expression } from "../../statecharts/label_ast";
+import { KeyInfoHidden, KeyInfoVisible } from "../TopPanel/KeyInfo";
+import { useShortcuts } from '@/hooks/useShortcuts';
export function ShowTransition(props: {transition: Transition}) {
return <>➝ {stateDescription(props.transition.tgt)}>;
@@ -46,12 +49,8 @@ export const ShowAST = memo(function ShowASTx(props: {root: ConcreteState | Unst
;
});
-import BoltIcon from '@mui/icons-material/Bolt';
-import { KeyInfoHidden, KeyInfoVisible } from "./TopPanel/KeyInfo";
-import { memo, useEffect } from "react";
-import { usePersistentState } from "./persistent_state";
-export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean, showKeys: boolean}) {
+export function ShowInputEvents({inputEvents, onRaise, disabled}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean}) {
const raiseHandlers = inputEvents.map(({event}) => {
return () => {
// @ts-ignore
@@ -69,23 +68,16 @@ export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inp
onRaise(event, paramParsed);
};
});
- const onKeyDown = (e: KeyboardEvent) => {
- // don't capture keyboard events when focused on an input element:
- // @ts-ignore
- if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
- const n = (parseInt(e.key)+9) % 10;
- if (raiseHandlers[n] !== undefined) {
- raiseHandlers[n]();
- e.stopPropagation();
- e.preventDefault();
- }
- }
- useEffect(() => {
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [raiseHandlers]);
- // const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
+ const shortcutSpec = raiseHandlers.map((handler, i) => {
+ const n = (i+1)%10;
+ return {
+ keys: [n.toString()],
+ action: handler,
+ };
+ });
+ useShortcuts(shortcutSpec);
+
const KeyInfo = KeyInfoVisible; // always show keyboard shortcuts on input events, we can't expect the user to remember them
const [inputParams, setInputParams] = usePersistentState<{[eventName:string]: string}>("inputParams", {});
diff --git a/src/App/SideBar/SideBar.css b/src/App/SideBar/SideBar.css
new file mode 100644
index 0000000..4f52df7
--- /dev/null
+++ b/src/App/SideBar/SideBar.css
@@ -0,0 +1,86 @@
+
+details > summary {
+ padding: 2px;
+}
+
+details > summary:hover {
+ background-color: var(--summary-hover-bg-color);
+ cursor: default;
+}
+
+.errorStatus details > summary:hover {
+ background-color: var(--error-hover-bg-color);
+}
+
+.stateTree > * {
+ padding-left: 10px;
+ background-color: white;
+}
+
+.outputEvent {
+ cursor: default;
+ border: 1px var(--separator-color) solid;
+ border-radius: 6px;
+ padding-left: 2px;
+ padding-right: 2px;
+ background-color: var(--output-event-bg-color);
+ color: var(--text-color);
+ display: inline-block;
+}
+
+.internalEvent {
+ cursor: default;
+ border: 1px var(--separator-color) solid;
+ border-radius: 6px;
+ padding-left: 2px;
+ padding-right: 2px;
+ background-color: var(--internal-event-bg-color);
+ color: var(--text-color);
+ display: inline-block;
+}
+
+.inputEvent {
+ border: 1px var(--separator-color) solid;
+ border-radius: 6px;
+ padding-left: 2px;
+ padding-right: 2px;
+ background-color: var(--input-event-bg-color);
+ color: var(--text-color);
+ display: inline-block;
+}
+.inputEvent:disabled {
+ color: darkgrey;
+}
+.inputEvent * {
+ vertical-align: middle;
+}
+button.inputEvent:hover:not(:disabled) {
+ background-color: var(--input-event-hover-bg-color);
+}
+button.inputEvent:active:not(:disabled) {
+ background-color: var(--input-event-active-bg-color);
+}
+
+.activeState {
+ border: var(--active-state-border-color) 1px solid;
+ background-color: var(--active-state-bg-color);
+ border-radius: 6px;
+ margin-left: 4px;
+ padding-left: 2px;
+ padding-right: 2px;
+ display: inline-block;
+ color: var(--text-color);
+}
+
+ul {
+ list-style-type: circle;
+ margin-block-start: 0;
+ margin-block-end: 0;
+ padding-inline-start: 24px;
+ /* list-style-position: ; */
+}
+
+.shadowBelow {
+ box-shadow: 0 -5px 5px 5px rgba(0, 0, 0, 0.4);
+ z-index: 1;
+}
diff --git a/src/App/SideBar/SideBar.tsx b/src/App/SideBar/SideBar.tsx
new file mode 100644
index 0000000..488bd38
--- /dev/null
+++ b/src/App/SideBar/SideBar.tsx
@@ -0,0 +1,332 @@
+import AddIcon from '@mui/icons-material/Add';
+import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
+import CachedOutlinedIcon from '@mui/icons-material/CachedOutlined';
+import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
+import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
+import VisibilityIcon from '@mui/icons-material/Visibility';
+import { Conns } from '@/statecharts/timed_reactive';
+import { Dispatch, Ref, SetStateAction, useEffect, useRef, useState } from 'react';
+import { Statechart } from '@/statecharts/abstract_syntax';
+import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from './ShowAST';
+import { Plant } from '../Plant/Plant';
+import { checkProperty, PropertyCheckResult } from './check_property';
+import { Setters } from '../makePartialSetter';
+import { RTHistory } from './RTHistory';
+import { BigStepCause, TraceState } from '../hooks/useSimulator';
+import { plants, UniversalPlantState } from '../plants';
+import { TimeMode } from '@/statecharts/time';
+import { PersistentDetails } from '../Components/PersistentDetails';
+import "./SideBar.css";
+
+type SavedTraces = [string, BigStepCause[]][];
+
+export type SideBarState = {
+ showStateTree: boolean,
+ showInputEvents: boolean,
+ showInternalEvents: boolean,
+ showOutputEvents: boolean,
+ showPlant: boolean,
+ showConnections: boolean,
+ showProperties: boolean,
+ showExecutionTrace: boolean,
+
+ plantName: string,
+ plantConns: Conns,
+ autoConnect: boolean,
+
+ properties: string[],
+ activeProperty: number,
+ savedTraces: SavedTraces,
+ autoScroll: boolean,
+ showPlantTrace: boolean,
+};
+
+export const defaultSideBarState = {
+ showStateTree: false,
+ showInputEvents: true,
+ showInternalEvents: true,
+ showOutputEvents: true,
+ showPlant: true,
+ showConnections: false,
+ showProperties: false,
+ showExecutionTrace: true,
+
+ plantName: 'dummy',
+ plantConns: {},
+ autoConnect: true,
+
+ properties: [],
+ activeProperty: 0,
+ savedTraces: [],
+ autoScroll: false,
+ showPlantTrace: false,
+};
+
+type SideBarProps = SideBarState & {
+ refRightSideBar: Ref,
+ ast: Statechart | null,
+ plant: Plant,
+ // setSavedTraces: Dispatch>,
+ trace: TraceState|null,
+ setTrace: Dispatch>,
+ plantState: UniversalPlantState,
+ onRaise: (inputEvent: string, param: any) => void,
+ onReplayTrace: (causes: BigStepCause[]) => void,
+ setTime: Dispatch>,
+ time: TimeMode,
+} & Setters;
+
+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(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 <>
+
+ {/* State tree */}
+
+ state tree
+
+
+ {/* Input events */}
+
+ input events
+ {ast && onRaise("debug."+e,p)}
+ disabled={trace===null || trace.trace[trace.idx].kind === "error"}
+ />}
+
+ {/* Internal events */}
+
+ internal events
+ {ast && }
+
+ {/* Output events */}
+
+ output events
+ {ast && }
+
+ {/* Plant */}
+
+ plant
+ setPlantName(() => e.target.value)}>
+ {plants.map(([plantName, p]) =>
+ {plantName}
+ )}
+
+
+ {/* Render plant */}
+ { onRaise("plant.ui."+e.name, e.param)}
+ />}
+
+ {/* Connections */}
+
+ connections
+ setAutoConnect(c => !c)}>
+
+
+ {ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
+
+ {/* Properties */}
+
setShowProperties(e.newState === "open")}>
+ properties
+ {plant &&
+ available signals:
+
+ {plant.signals.join(', ')}
+
}
+ {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
+
+
setActiveProperty(i)}>
+
+
+
setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
+
setProperties(properties => properties.toSpliced(i, 1))}>
+
+
+ {propertyError &&
{propertyError}
}
+
;
+ })}
+
+
setProperties(properties => [...properties, ""])}>
+ add property
+
+
+
+ {/* Traces */}
+
setShowExecutionTrace(e.newState === "open")}>execution trace
+
+ {savedTraces.map((savedTrace, i) =>
+
+ onReplayTrace(savedTrace[1])}>
+
+
+
+ {(Math.floor(savedTrace[1].at(-1)!.simtime/1000))}s
+ ({savedTrace[1].length})
+
+ setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1, [e.target.value, savedTraces[i][1]]))}/>
+ setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1))}>
+
+
+
+ )}
+
+
+ setShowPlantTrace(e.target.checked)}/>
+ show plant steps
+ setAutoScroll(e.target.checked)}/>
+ auto-scroll
+
+ onSaveTrace()}>
+ save trace
+
+
+
+
+
+ {/* We cheat a bit, and render the execution trace depending on whether the above is 'open' or not, rather than putting it as a child of the . We do this because only then can we get the execution trace to scroll without the rest scrolling as well. */}
+ {showExecutionTrace &&
+ }
+ >;
+}
+
+function autoDetectConns(ast: Statechart, plant: Plant, setPlantConns: Dispatch>) {
+ 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, plantConns: Conns, setPlantConns: Dispatch>) {
+ const plantInputs = <>{plant.inputEvents.map(e => plant.{e.event} )}>
+ const scInputs = <>{ast.inputEvents.map(e => sc.{e.event} )}>;
+ return <>
+
+ {/* SC output events can go to Plant */}
+ {[...ast.outputEvents].map(e =>
+ sc.{e} →
+ setPlantConns(conns => ({...conns, [`sc.${e}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
+
+ {plantInputs}
+
+
)}
+
+ {/* Plant output events can go to Statechart */}
+ {[...plant.outputEvents.map(e =>
+ plant.{e.event} →
+ setPlantConns(conns => ({...conns, [`plant.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))})))}>
+
+ {scInputs}
+
+
)]}
+
+ {/* Plant UI events typically go to the Plant */}
+ {plant.uiEvents.map(e =>
+ ui.{e.event} →
+ setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
+
+ {scInputs}
+ {plantInputs}
+
+
)}
+ >;
+}
+
diff --git a/src/App/check_property.ts b/src/App/SideBar/check_property.ts
similarity index 90%
rename from src/App/check_property.ts
rename to src/App/SideBar/check_property.ts
index ff6e25a..54d3a5e 100644
--- a/src/App/check_property.ts
+++ b/src/App/SideBar/check_property.ts
@@ -1,6 +1,6 @@
import { RT_Statechart } from "@/statecharts/runtime_types";
-import { TraceItem } from "./App";
-import { Plant } from "./Plant/Plant";
+import { Plant } from "../Plant/Plant";
+import { TraceItem } from "../hooks/useSimulator";
// const endpoint = "http://localhost:15478/check_property";
const endpoint = "https://deemz.org/apis/mtl-aas/check_property";
@@ -36,7 +36,10 @@ export async function checkProperty(plant: Plant, property:
return [entry];
}, [] as {simtime: number, state: any}[]);
- let traces = {} as {[key: string]: [number, any][]};
+ let traces = {
+ 'true': [[0, true] as [number, any]],
+ 'false': [[0, false] as [number, any]],
+ } as {[key: string]: [number, any][]};
for (const {simtime, state} of cleanPlantStates) {
for (const [key, value] of Object.entries(state)) {
// just append
@@ -49,8 +52,6 @@ export async function checkProperty(plant: Plant, property:
}
}
- console.log({cleanPlantStates, traces});
-
try {
const response = await fetch(endpoint, {
method: "POST",
diff --git a/src/App/TopPanel/Icons.tsx b/src/App/TopPanel/Icons.tsx
index 97ae50c..0d308e8 100644
--- a/src/App/TopPanel/Icons.tsx
+++ b/src/App/TopPanel/Icons.tsx
@@ -18,12 +18,19 @@ export function PseudoStateIcon(props: {}) {
${w - 1} ${h / 2},
${w / 2} ${h - 1},
${1} ${h / 2},
- `} fill="white" stroke="black" strokeWidth={1.2} />
+ `}
+ style={{
+ fill: 'var(--and-state-bg-color',
+ stroke: 'var(--rountangle-stroke-color',
+ }} strokeWidth={1.2} />
;
}
export function HistoryIcon(props: { kind: "shallow" | "deep"; }) {
const w = 20, h = 20;
const text = props.kind === "shallow" ? "H" : "H*";
- return {text} ;
+ return {text} ;
}
diff --git a/src/App/TopPanel/InsertModes.tsx b/src/App/TopPanel/InsertModes.tsx
index fe1c411..c2c9d65 100644
--- a/src/App/TopPanel/InsertModes.tsx
+++ b/src/App/TopPanel/InsertModes.tsx
@@ -3,6 +3,7 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
+import { useShortcuts } from "@/hooks/useShortcuts";
export type InsertMode = "and" | "or" | "pseudo" | "shallow" | "deep" | "transition" | "text";
@@ -18,45 +19,14 @@ const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
export const InsertModes = memo(function InsertModes({showKeys, insertMode, setInsertMode}: {showKeys: boolean, insertMode: InsertMode, setInsertMode: Dispatch>}) {
- const onKeyDown = useCallback((e: KeyboardEvent) => {
- // @ts-ignore
- if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
-
- if (!e.ctrlKey) {
- if (e.key === "a") {
- e.preventDefault();
- setInsertMode("and");
- }
- if (e.key === "o") {
- e.preventDefault();
- setInsertMode("or");
- }
- if (e.key === "p") {
- e.preventDefault();
- setInsertMode("pseudo");
- }
- if (e.key === "t") {
- e.preventDefault();
- setInsertMode("transition");
- }
- if (e.key === "x") {
- e.preventDefault();
- setInsertMode("text");
- }
- if (e.key === "h") {
- e.preventDefault();
- setInsertMode(oldMode => {
- if (oldMode === "shallow") return "deep";
- return "shallow";
- })
- }
- }
- }, [setInsertMode]);
-
- useEffect(() => {
- window.addEventListener("keydown", onKeyDown);
- () => window.removeEventListener("keydown", onKeyDown);
- }, [onKeyDown]);
+ useShortcuts([
+ {keys: ["a"], action: () => setInsertMode("and")},
+ {keys: ["o"], action: () => setInsertMode("or")},
+ {keys: ["p"], action: () => setInsertMode("pseudo")},
+ {keys: ["t"], action: () => setInsertMode("transition")},
+ {keys: ["x"], action: () => setInsertMode("text")},
+ {keys: ["h"], action: () => setInsertMode(mode => mode === "shallow" ? "deep" : "shallow")},
+ ]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) =>
diff --git a/src/App/TopPanel/SpeedControl.tsx b/src/App/TopPanel/SpeedControl.tsx
index 9e936af..6926390 100644
--- a/src/App/TopPanel/SpeedControl.tsx
+++ b/src/App/TopPanel/SpeedControl.tsx
@@ -2,6 +2,9 @@ import { Dispatch, memo, SetStateAction, useCallback, useEffect } from "react";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { setRealtime, TimeMode } from "@/statecharts/time";
+import SpeedIcon from '@mui/icons-material/Speed';
+import { useShortcuts } from "@/hooks/useShortcuts";
+
export const SpeedControl = memo(function SpeedControl({showKeys, timescale, setTimescale, setTime}: {showKeys: boolean, timescale: number, setTimescale: Dispatch>, setTime: Dispatch>}) {
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
@@ -29,29 +32,14 @@ export const SpeedControl = memo(function SpeedControl({showKeys, timescale, set
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
}, [onTimeScaleChange, timescale]);
- const onKeyDown = useCallback((e: KeyboardEvent) => {
- // @ts-ignore
- if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
- if (!e.ctrlKey) {
- if (e.key === "s") {
- e.preventDefault();
- onSlower();
- }
- if (e.key === "f") {
- e.preventDefault();
- onFaster();
- }
- }
- }, [onSlower, onFaster])
-
- useEffect(() => {
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [onKeyDown])
+ useShortcuts([
+ {keys: ["s"], action: onSlower},
+ {keys: ["f"], action: onFaster},
+ ]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>
- speed
+
S}>
÷2
diff --git a/src/App/TopPanel/TopPanel.tsx b/src/App/TopPanel/TopPanel.tsx
index 73260c9..32f8209 100644
--- a/src/App/TopPanel/TopPanel.tsx
+++ b/src/App/TopPanel/TopPanel.tsx
@@ -1,14 +1,22 @@
-import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react";
+import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
-import { InsertMode } from "./InsertModes";
import { About } from "../Modals/About";
-import { EditHistory, TraceState } from "../App";
+import { AppState, EditHistory, LightMode } from "../App";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { UndoRedoButtons } from "./UndoRedoButtons";
import { ZoomButtons } from "./ZoomButtons";
import { formatTime } from "../../util/util";
+import DarkModeIcon from '@mui/icons-material/DarkMode';
+import LightModeIcon from '@mui/icons-material/LightMode';
+import BrightnessAutoIcon from '@mui/icons-material/BrightnessAuto';
+
+import SpeedIcon from '@mui/icons-material/Speed';
+import AccessTimeIcon from '@mui/icons-material/AccessTime';
+import FindInPageIcon from '@mui/icons-material/FindInPage';
+import FindInPageOutlinedIcon from '@mui/icons-material/FindInPageOutlined';
+
import AccessAlarmIcon from '@mui/icons-material/AccessAlarm';
import CachedIcon from '@mui/icons-material/Cached';
import InfoOutlineIcon from '@mui/icons-material/InfoOutline';
@@ -18,13 +26,20 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import SkipNextIcon from '@mui/icons-material/SkipNext';
import StopIcon from '@mui/icons-material/Stop';
import { InsertModes } from "./InsertModes";
-import { usePersistentState } from "@/App/persistent_state";
+import { usePersistentState } from "@/hooks/usePersistentState";
import { RotateButtons } from "./RotateButtons";
import { SpeedControl } from "./SpeedControl";
+import { TraceState } from "../hooks/useSimulator";
+import { FindReplace } from "../BottomPanel/FindReplace";
+import { VisualEditorState } from "../VisualEditor/VisualEditor";
+import { Setters } from "../makePartialSetter";
+import { TwoStateButton } from "../Components/TwoStateButton";
+import { useShortcuts } from "@/hooks/useShortcuts";
export type TopPanelProps = {
trace: TraceState | null,
time: TimeMode,
+
setTime: Dispatch>,
onUndo: () => void,
onRedo: () => void,
@@ -32,32 +47,41 @@ export type TopPanelProps = {
onInit: () => void,
onClear: () => void,
onBack: () => void,
- insertMode: InsertMode,
- setInsertMode: Dispatch>,
+
+ // lightMode: LightMode,
+ // setLightMode: Dispatch>,
+ // insertMode: InsertMode,
+ // setInsertMode: Dispatch>,
setModal: Dispatch>,
- zoom: number,
- setZoom: Dispatch>,
- showKeys: boolean,
- setShowKeys: Dispatch>,
+ // zoom: number,
+ // setZoom: Dispatch>,
+ // showKeys: boolean,
+ // setShowKeys: Dispatch>,
editHistory: EditHistory,
-}
+ setEditorState: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
+} & AppState & Setters
const ShortCutShowKeys = ~ ;
-export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}: TopPanelProps) {
- const [displayTime, setDisplayTime] = useState("0.000");
+function toggle(booleanSetter: Dispatch<(state: boolean) => boolean>) {
+ return () => booleanSetter(x => !x);
+}
+
+export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory, showFindReplace, setShowFindReplace, setEditorState}: TopPanelProps) {
+ const [displayTime, setDisplayTime] = useState(0);
const [timescale, setTimescale] = usePersistentState("timescale", 1);
const config = trace && trace.trace[trace.idx];
- const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
-
const updateDisplayedTime = useCallback(() => {
const now = Math.round(performance.now());
const timeMs = getSimTime(time, now);
- setDisplayTime(formatTime(timeMs));
+ setDisplayTime((timeMs));
}, [time, setDisplayTime]);
+ const formattedDisplayTime = useMemo(() => formatTime(displayTime), [displayTime]);
+ const lastSimTime = config?.simtime || 0;
+
useEffect(() => {
// This has no effect on statechart execution. In between events, the statechart is doing nothing. However, by updating the displayed time, we give the illusion of continuous progress.
const interval = setInterval(() => {
@@ -98,53 +122,35 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
}
}, [nextTimedTransition, setTime]);
- useEffect(() => {
- const onKeyDown = (e: KeyboardEvent) => {
- // don't capture keyboard events when focused on an input element:
- // @ts-ignore
- if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
+ useShortcuts([
+ {keys: ["`"], action: toggle(setShowKeys)},
+ {keys: ["Ctrl", "Shift", "F"], action: toggle(setShowFindReplace)},
+ {keys: ["i"], action: onInit},
+ {keys: ["c"], action: onClear},
+ {keys: ["Tab"], action: config && onSkip || onInit},
+ {keys: ["Backspace"], action: onBack},
+ {keys: ["Shift", "Tab"], action: onBack},
+ {keys: [" "], action: () => config && onChangePaused(time.kind !== "paused", Math.round(performance.now()))},
+ ]);
- if (!e.ctrlKey) {
- if (e.key === " ") {
- e.preventDefault();
- if (config) {
- onChangePaused(time.kind !== "paused", Math.round(performance.now()));
- }
- };
- if (e.key === "i") {
- e.preventDefault();
- onInit();
- }
- if (e.key === "c") {
- e.preventDefault();
- onClear();
- }
- if (e.key === "Tab") {
- if (config === null) {
- onInit();
- }
- else {
- onSkip();
- }
- e.preventDefault();
- }
- if (e.key === "`") {
- e.preventDefault();
- setShowKeys(show => !show);
- }
- if (e.key === "Backspace") {
- e.preventDefault();
- onBack();
- }
- }
- };
- window.addEventListener("keydown", onKeyDown);
- return () => {
- window.removeEventListener("keydown", onKeyDown);
- };
- }, [config, time, onInit, onChangePaused, setShowKeys, onSkip, onBack, onClear]);
+ const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return
+
+ {/* light / dark mode
+
+ setLightMode("light")}>
+
+
+ setLightMode("auto")}>
+
+
+ setLightMode("dark")}>
+
+
+
+
*/}
+
{/* shortcuts / about */}
@@ -172,11 +178,26 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
+ {/* rotate */}
+ {/* find, replace */}
+
+ Ctrl +Shift +F >}>
+ setShowFindReplace(x => !x)}
+ >
+
+
+
+
+
+
{/* execution */}
@@ -206,15 +227,20 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
{/* time, next */}
+
diff --git a/src/App/TopPanel/UndoRedoButtons.tsx b/src/App/TopPanel/UndoRedoButtons.tsx
index 0a9d0e5..c03a71d 100644
--- a/src/App/TopPanel/UndoRedoButtons.tsx
+++ b/src/App/TopPanel/UndoRedoButtons.tsx
@@ -3,27 +3,14 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
+import { useShortcuts } from "@/hooks/useShortcuts";
export const UndoRedoButtons = memo(function UndoRedoButtons({showKeys, onUndo, onRedo, historyLength, futureLength}: {showKeys: boolean, onUndo: () => void, onRedo: () => void, historyLength: number, futureLength: number}) {
- const onKeyDown = useCallback((e: KeyboardEvent) => {
- if (e.ctrlKey) {
- // ctrl is down
- if (e.key === "z") {
- e.preventDefault();
- onUndo();
- }
- if (e.key === "Z") {
- e.preventDefault();
- onRedo();
- }
- }
- }, [onUndo, onRedo]);
-
- useEffect(() => {
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [onKeyDown]);
+ useShortcuts([
+ {keys: ["Ctrl", "z"], action: onUndo},
+ {keys: ["Ctrl", "Shift", "Z"], action: onRedo},
+ ])
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>
diff --git a/src/App/TopPanel/ZoomButtons.tsx b/src/App/TopPanel/ZoomButtons.tsx
index 8db4a8c..718c311 100644
--- a/src/App/TopPanel/ZoomButtons.tsx
+++ b/src/App/TopPanel/ZoomButtons.tsx
@@ -4,12 +4,20 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
+import { useShortcuts } from "@/hooks/useShortcuts";
const shortcutZoomIn = <>
Ctrl +
- >;
const shortcutZoomOut = <>
Ctrl +
+ >;
export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}: {showKeys: boolean, zoom: number, setZoom: Dispatch
>}) {
+ useShortcuts([
+ {keys: ["Ctrl", "+"], action: onZoomIn}, // plus on numerical keypad
+ {keys: ["Ctrl", "Shift", "+"], action: onZoomIn}, // plus on normal keyboard requires Shift key
+ {keys: ["Ctrl", "="], action: onZoomIn}, // most browsers also bind this shortcut so it would be confusing if we also did not override it
+ {keys: ["Ctrl", "-"], action: onZoomOut},
+ ]);
+
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
function onZoomIn() {
@@ -19,27 +27,6 @@ export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}:
setZoom(zoom => Math.max(zoom / ZOOM_STEP, ZOOM_MIN));
}
- useEffect(() => {
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.ctrlKey) {
- if (e.key === "+") {
- e.preventDefault();
- e.stopPropagation();
- onZoomIn();
- }
- if (e.key === "-") {
- e.preventDefault();
- e.stopPropagation();
- onZoomOut();
- }
- }
- };
- window.addEventListener("keydown", onKeyDown);
- return () => {
- window.removeEventListener("keydown", onKeyDown);
- };
- }, []);
-
return <>
diff --git a/src/App/VisualEditor/ArrowSVG.tsx b/src/App/VisualEditor/ArrowSVG.tsx
index 9b12a3a..d09128d 100644
--- a/src/App/VisualEditor/ArrowSVG.tsx
+++ b/src/App/VisualEditor/ArrowSVG.tsx
@@ -2,7 +2,7 @@ import { memo } from "react";
import { Arrow, ArrowPart } from "../../statecharts/concrete_syntax";
import { ArcDirection, euclideanDistance } from "../../util/geometry";
import { CORNER_HELPER_RADIUS } from "../parameters";
-import { arraysEqual } from "@/util/util";
+import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart[]; error: string; highlight: boolean; fired: boolean; arc: ArcDirection; initialMarker: boolean }) {
@@ -81,7 +81,7 @@ export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart
;
}, (prevProps, nextProps) => {
- return prevProps.arrow === nextProps.arrow
+ return jsonDeepEqual(prevProps.arrow, nextProps.arrow)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& prevProps.highlight === nextProps.highlight
&& prevProps.error === nextProps.error
diff --git a/src/App/VisualEditor/DiamondSVG.tsx b/src/App/VisualEditor/DiamondSVG.tsx
index da7b289..fe8ef8e 100644
--- a/src/App/VisualEditor/DiamondSVG.tsx
+++ b/src/App/VisualEditor/DiamondSVG.tsx
@@ -3,7 +3,7 @@ import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { Vec2D } from "../../util/geometry";
import { RectHelper } from "./RectHelpers";
import { memo } from "react";
-import { arraysEqual } from "@/util/util";
+import { arraysEqual, jsonDeepEqual } from "@/util/util";
export const DiamondShape = memo(function DiamondShape(props: {size: Vec2D, extraAttrs: object}) {
const minSize = rountangleMinSize(props.size);
@@ -14,7 +14,8 @@ export const DiamondShape = memo(function DiamondShape(props: {size: Vec2D, extr
${minSize.x/2} ${minSize.y},
${0} ${minSize.y/2}
`}
- fill="white"
+ style={{fill: 'var(--and-state-bg-color', stroke: 'var(--rountangle-stroke-color)'}}
+ // fill="white"
stroke="black"
strokeWidth={2}
{...props.extraAttrs}
@@ -41,7 +42,7 @@ export const DiamondSVG = memo(function DiamondSVG(props: { diamond: Diamond; se
;
}, (prevProps, nextProps) => {
- return prevProps.diamond === nextProps.diamond
+ return jsonDeepEqual(prevProps.diamond, nextProps.diamond)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error
diff --git a/src/App/VisualEditor/HistorySVG.tsx b/src/App/VisualEditor/HistorySVG.tsx
index f0fccb4..565e9ac 100644
--- a/src/App/VisualEditor/HistorySVG.tsx
+++ b/src/App/VisualEditor/HistorySVG.tsx
@@ -9,8 +9,10 @@ export const HistorySVG = memo(function HistorySVG(props: {uid: string, topLeft:
cx={props.topLeft.x+HISTORY_RADIUS}
cy={props.topLeft.y+HISTORY_RADIUS}
r={HISTORY_RADIUS}
- fill="white"
- stroke="black"
+ style={{
+ fill: 'var(--and-state-bg-color)',
+ stroke: 'var(--rountangle-stroke-color)'
+ }}
strokeWidth={2}
data-uid={props.uid}
data-parts="history"
@@ -20,6 +22,7 @@ export const HistorySVG = memo(function HistorySVG(props: {uid: string, topLeft:
y={props.topLeft.y+HISTORY_RADIUS+5}
textAnchor="middle"
fontWeight={500}
+ style={{fill: 'var(--rountangle-stroke-color)'}}
>{text}
;
}, (prevProps, nextProps) => {
- return prevProps.rountangle === nextProps.rountangle
+ return jsonDeepEqual(prevProps.rountangle, nextProps.rountangle)
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error
diff --git a/src/App/VisualEditor/TextSVG.tsx b/src/App/VisualEditor/TextSVG.tsx
index 8a80aba..9c6bfdf 100644
--- a/src/App/VisualEditor/TextSVG.tsx
+++ b/src/App/VisualEditor/TextSVG.tsx
@@ -2,6 +2,7 @@ import { TextDialog } from "@/App/Modals/TextDialog";
import { TraceableError } from "../../statecharts/parser";
import {Text} from "../../statecharts/concrete_syntax";
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
+import { jsonDeepEqual } from "@/util/util";
export const TextSVG = memo(function TextSVG(props: {text: Text, error: TraceableError|undefined, selected: boolean, highlight: boolean, onEdit: (text: Text, newText: string) => void, setModal: Dispatch>}) {
const commonProps = {
@@ -44,4 +45,11 @@ export const TextSVG = memo(function TextSVG(props: {text: Text, error: Traceabl
{textNode}
{props.text.text}
;
+}, (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
});
diff --git a/src/App/VisualEditor/VisualEditor.css b/src/App/VisualEditor/VisualEditor.css
index 19801a4..4153853 100644
--- a/src/App/VisualEditor/VisualEditor.css
+++ b/src/App/VisualEditor/VisualEditor.css
@@ -1,6 +1,6 @@
.svgCanvas {
cursor: crosshair;
- background-color: #eee;
+ background-color: var(--or-state-bg-color);
}
.svgCanvas.dragging {
@@ -19,16 +19,15 @@
/* rectangle drawn while a selection is being made */
.selecting {
- fill: blue;
- fill-opacity: 0.2;
+ fill: var(--light-accent-color);
stroke-width: 1px;
- stroke:black;
+ stroke: var(--accent-border-color);
stroke-dasharray: 7 6;
}
.rountangle {
- fill: white;
- stroke: black;
+ fill: var(--and-state-bg-color);
+ stroke: var(--rountangle-stroke-color);
stroke-width: 2px;
}
@@ -39,9 +38,10 @@
stroke: var(--error-color);
}
.rountangle.active {
- stroke: rgb(205, 133, 0);
+ stroke: var(--active-state-border-color);
/* stroke: none; */
- fill:rgb(255, 240, 214);
+ /* fill:rgb(255, 240, 214); */
+ fill: var(--active-state-bg-color);
/* filter: drop-shadow( 2px 2px 2px rgba(124, 37, 10, 0.729)); */
}
@@ -54,8 +54,7 @@ line.helper {
stroke-width: 16px;
}
line.helper:hover:not(:active) {
- stroke: blue;
- stroke-opacity: 0.2;
+ stroke: var(--light-accent-color);
cursor: grab;
}
@@ -65,8 +64,7 @@ path.helper {
stroke-width: 16px;
}
path.helper:hover:not(:active) {
- stroke: blue;
- stroke-opacity: 0.2;
+ stroke: var(--light-accent-color);
cursor: grab;
}
@@ -74,23 +72,22 @@ circle.helper {
fill: rgba(0, 0, 0, 0);
}
circle.helper:hover:not(:active) {
- fill: blue;
- fill-opacity: 0.2;
+ fill: var(--light-accent-color);
cursor: grab;
}
.rountangle.or {
stroke-dasharray: 7 6;
- fill: #eee;
+ fill: var(--or-state-bg-color);
}
.arrow {
fill: none;
- stroke: black;
+ stroke: var(--rountangle-stroke-color);
stroke-width: 2px;
}
.arrow.selected {
- stroke: blue;
+ stroke: var(--accent-border-color);
stroke-width: 3px;
}
@@ -110,34 +107,29 @@ circle.helper:hover:not(:active) {
}
line.selected, circle.selected {
- fill: rgba(0, 0, 255, 0.2);
- /* stroke-dasharray: 7 6; */
- stroke: blue;
+ fill: var(--light-accent-color);
+ stroke: var(--accent-border-color);
stroke-width: 4px;
}
.draggableText.selected, .draggableText.selected:hover {
- fill: blue;
+ fill: var(--accent-border-color);
font-weight: 600;
}
-.draggableText:hover:not(:active) {
- /* fill: blue; */
- /* cursor: grab; */
-}
text.helper {
fill: rgba(0,0,0,0);
stroke: rgba(0,0,0,0);
stroke-width: 6px;
}
text.helper:hover {
- stroke: blue;
- stroke-opacity: 0.2;
+ stroke: var(--light-accent-color);
cursor: grab;
}
.draggableText, .draggableText.highlight {
paint-order: stroke;
- stroke: white;
+ fill: var(--text-color);
+ stroke: var(--background-color);
stroke-width: 4px;
stroke-linecap: butt;
stroke-linejoin: miter;
@@ -146,12 +138,16 @@ text.helper:hover {
}
.draggableText.highlight:not(.selected) {
- fill: green;
+ fill: var(--associated-color);
font-weight: 600;
}
+.draggableText.selected {
+ fill: var(--accent-border-color);
+}
+
.highlight:not(.selected):not(text) {
- stroke: green;
+ stroke: var(--associated-color);
stroke-width: 3px;
fill: none;
}
@@ -160,19 +156,19 @@ text.helper:hover {
stroke: var(--error-color);
}
.arrow.fired {
- stroke: rgb(160 0 168);
+ stroke: var(--fired-transition-color);
stroke-width: 3px;
animation: blinkTransition 1s;
}
@keyframes blinkTransition {
0% {
- stroke: rgb(255, 128, 9);
+ stroke: var(--firing-transition-color);
stroke-width: 6px;
- filter: drop-shadow(0 0 5px rgba(255, 128, 9, 1));
+ filter: drop-shadow(0 0 5px var(--firing-transition-color));
}
100% {
- stroke: rgb(160 0 168);
+ stroke: var(--fired-transition-color);
}
}
@@ -191,5 +187,5 @@ g:hover > .errorHover {
}
text.uid {
- fill: lightgrey;
+ fill: var(--separator-color);
}
\ No newline at end of file
diff --git a/src/App/VisualEditor/VisualEditor.tsx b/src/App/VisualEditor/VisualEditor.tsx
index 4505dc6..0a0b2d6 100644
--- a/src/App/VisualEditor/VisualEditor.tsx
+++ b/src/App/VisualEditor/VisualEditor.tsx
@@ -1,31 +1,20 @@
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
-import { TraceState } from "@/App/App";
-import { InsertMode } from "../TopPanel/InsertModes";
import { Mode } from "@/statecharts/runtime_types";
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
-import { Arrow, ArrowPart, Diamond, History, RectSide, Rountangle, Text } from "../../statecharts/concrete_syntax";
+import { ArrowPart, ConcreteSyntax, Diamond, RectSide, Rountangle, Text } from "../../statecharts/concrete_syntax";
import { Connections } from "../../statecharts/detect_connections";
import { TraceableError } from "../../statecharts/parser";
import { ArcDirection, arcDirection } from "../../util/geometry";
+import { InsertMode } from "../TopPanel/InsertModes";
import { ArrowSVG } from "./ArrowSVG";
import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG";
import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG";
-import { useCopyPaste } from "./useCopyPaste";
-
import "./VisualEditor.css";
-import { useMouse } from "./useMouse";
-import { Selecting } from "./Selection";
-
-export type ConcreteSyntax = {
- rountangles: Rountangle[];
- texts: Text[];
- arrows: Arrow[];
- diamonds: Diamond[];
- history: History[];
-};
+import { useCopyPaste } from "./hooks/useCopyPaste";
+import { useMouse } from "./hooks/useMouse";
export type VisualEditorState = ConcreteSyntax & {
nextID: number;
@@ -33,21 +22,19 @@ export type VisualEditorState = ConcreteSyntax & {
};
export type RountangleSelectable = {
- // kind: "rountangle";
- parts: RectSide[];
+ part: RectSide;
uid: string;
}
type ArrowSelectable = {
- // kind: "arrow";
- parts: ArrowPart[];
+ part: ArrowPart;
uid: string;
}
type TextSelectable = {
- parts: ["text"];
+ part: "text";
uid: string;
}
type HistorySelectable = {
- parts: ["history"];
+ part: "history";
uid: string;
}
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
@@ -56,22 +43,26 @@ export type Selection = Selectable[];
type VisualEditorProps = {
state: VisualEditorState,
- setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
+ commitState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
+ replaceState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
conns: Connections,
syntaxErrors: TraceableError[],
- trace: TraceState | null,
insertMode: InsertMode,
highlightActive: Set,
highlightTransitions: string[],
setModal: Dispatch>,
- makeCheckPoint: () => void;
zoom: number;
};
-export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
+export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom}: VisualEditorProps) {
+
+ // While dragging, the editor is in a temporary state (a state that is not committed to the edit history). If the temporary state is not null, then this state will be what you see.
+ // const [temporaryState, setTemporaryState] = useState(null);
+
+ // const state = temporaryState || committedState;
// uid's of selected rountangles
- const selection = state.selection || [];
+ const selection = state.selection;
const refSVG = useRef(null);
@@ -87,12 +78,15 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
})
});
})
- }, [trace && trace.idx]);
+ }, [highlightTransitions]);
- const {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection);
+ const {onCopy, onPaste, onCut} = useCopyPaste(state, commitState, selection);
- const {onMouseDown, selectionRect} = useMouse(makeCheckPoint, insertMode, zoom, refSVG, state, setState, deleteSelection);
+ const {onMouseDown, selectionRect} = useMouse(insertMode, zoom, refSVG,
+ state,
+ commitState,
+ replaceState);
// for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected.
@@ -115,12 +109,10 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
for (const textUid of texts) {
textsToHighlight[textUid] = true;
}
- for (const part of selected.parts) {
- const arrows = conns.side2ArrowMap.get(selected.uid + '/' + part) || [];
- if (arrows) {
- for (const [arrowPart, arrowUid] of arrows) {
- arrowsToHighlight[arrowUid] = true;
- }
+ const arrows = conns.side2ArrowMap.get(selected.uid + '/' + selected.part) || [];
+ if (arrows) {
+ for (const [arrowPart, arrowUid] of arrows) {
+ arrowsToHighlight[arrowUid] = true;
}
}
const arrow2 = conns.text2ArrowMap.get(selected.uid);
@@ -144,13 +136,13 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
const onEditText = useCallback((text: Text, newText: string) => {
if (newText === "") {
// delete text node
- setState(state => ({
+ commitState(state => ({
...state,
texts: state.texts.filter(t => t.uid !== text.uid),
}));
}
else {
- setState(state => ({
+ commitState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === text.uid) {
@@ -165,17 +157,14 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
}),
}));
}
- }, [setState]);
-
- // @ts-ignore
- const active = trace && trace.trace[trace.idx].mode || new Set();
+ }, [commitState]);
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
const size = 4000*zoom;
return e.preventDefault()}
ref={refSVG}
@@ -234,7 +223,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
return a.uid === arrow.uid)?.parts as ArrowPart[] || []}
+ selected={selection.filter(a => a.uid === arrow.uid).map(({part})=> part as ArrowPart)}
error={errors
.filter(({shapeUid}) => shapeUid === arrow.uid)
.map(({message}) => message).join(', ')}
@@ -257,7 +246,7 @@ const Rountangles = memo(function Rountangles({rountangles, selection, sidesToHi
return r.uid === rountangle.uid)?.parts as RectSide[] || []}
+ selected={selection.filter(r => r.uid === rountangle.uid).map(({part}) => part as RectSide)}
highlight={[...(sidesToHighlight[rountangle.uid] || []), ...(rountanglesToHighlight[rountangle.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors
.filter(({shapeUid}) => shapeUid === rountangle.uid)
@@ -278,7 +267,7 @@ const Diamonds = memo(function Diamonds({diamonds, selection, sidesToHighlight,
r.uid === diamond.uid)?.parts as RectSide[] || []}
+ selected={selection.filter(r => r.uid === diamond.uid).map(({part})=>part as RectSide)}
highlight={[...(sidesToHighlight[diamond.uid] || []), ...(rountanglesToHighlight[diamond.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors
.filter(({shapeUid}) => shapeUid === diamond.uid)
@@ -299,7 +288,7 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
key={txt.uid}
error={errors.find(({shapeUid}) => txt.uid === shapeUid)}
text={txt}
- selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)}
+ selected={Boolean(selection.filter(s => s.uid === txt.uid).length)}
highlight={textsToHighlight.hasOwnProperty(txt.uid)}
onEdit={onEditText}
setModal={setModal}
diff --git a/src/App/VisualEditor/useCopyPaste.ts b/src/App/VisualEditor/hooks/useCopyPaste.ts
similarity index 85%
rename from src/App/VisualEditor/useCopyPaste.ts
rename to src/App/VisualEditor/hooks/useCopyPaste.ts
index e560cc0..7ac9a2d 100644
--- a/src/App/VisualEditor/useCopyPaste.ts
+++ b/src/App/VisualEditor/hooks/useCopyPaste.ts
@@ -1,18 +1,19 @@
import { Arrow, Diamond, Rountangle, Text, History } from "@/statecharts/concrete_syntax";
import { ClipboardEvent, Dispatch, SetStateAction, useCallback, useEffect } from "react";
-import { Selection, VisualEditorState } from "./VisualEditor";
+import { Selection, VisualEditorState } from "../VisualEditor";
import { addV2D } from "@/util/geometry";
+import { useShortcuts } from "@/hooks/useShortcuts";
// const offset = {x: 40, y: 40};
const offset = {x: 0, y: 0};
-export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorState, setState: Dispatch<(v:VisualEditorState) => VisualEditorState>, selection: Selection) {
+export function useCopyPaste(state: VisualEditorState, commitState: Dispatch<(v:VisualEditorState) => VisualEditorState>, selection: Selection) {
const onPaste = useCallback((e: ClipboardEvent) => {
const data = e.clipboardData?.getData("text/plain");
if (data) {
try {
const parsed = JSON.parse(data);
- setState(state => {
+ commitState(state => {
try {
let nextID = state.nextID;
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
@@ -49,7 +50,6 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
];
- makeCheckPoint();
return {
...state,
rountangles: [...state.rountangles, ...copiedRountangles],
@@ -72,7 +72,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
}
e.preventDefault();
}
- }, [setState]);
+ }, [commitState]);
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
const uidsToCopy = new Set(selection.map(shape => shape.uid));
@@ -106,7 +106,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
}, [state, selection]);
const deleteSelection = useCallback(() => {
- setState(state => ({
+ commitState(state => ({
...state,
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
@@ -115,23 +115,11 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [],
}));
- }, [setState]);
+ }, [commitState]);
- const onKeyDown = (e: KeyboardEvent) => {
- // @ts-ignore
- if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
- if (e.key === "Delete") {
- // delete selection
- makeCheckPoint();
- deleteSelection();
- e.preventDefault();
- }
- }
-
- useEffect(() => {
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- })
+ useShortcuts([
+ {keys: ["Delete"], action: deleteSelection},
+ ])
return {onCopy, onPaste, onCut, deleteSelection};
}
\ No newline at end of file
diff --git a/src/App/VisualEditor/useMouse.tsx b/src/App/VisualEditor/hooks/useMouse.tsx
similarity index 50%
rename from src/App/VisualEditor/useMouse.tsx
rename to src/App/VisualEditor/hooks/useMouse.tsx
index 9ef94e5..a89b08b 100644
--- a/src/App/VisualEditor/useMouse.tsx
+++ b/src/App/VisualEditor/hooks/useMouse.tsx
@@ -2,20 +2,34 @@ import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { addV2D, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "@/util/geometry";
import { getBBoxInSvgCoords } from "@/util/svg_helper";
import { Dispatch, useCallback, useEffect, useState } from "react";
-import { MIN_ROUNTANGLE_SIZE } from "../parameters";
-import { InsertMode } from "../TopPanel/InsertModes";
-import { Selecting, SelectingState } from "./Selection";
-import { Selection, VisualEditorState } from "./VisualEditor";
+import { MIN_ROUNTANGLE_SIZE } from "../../parameters";
+import { InsertMode } from "../../TopPanel/InsertModes";
+import { Selecting, SelectingState } from "../Selection";
+import { Selection, VisualEditorState } from "../VisualEditor";
+import { useShortcuts } from "@/hooks/useShortcuts";
-export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoom: number, refSVG: {current: SVGSVGElement|null}, state: VisualEditorState, setState: Dispatch<(v: VisualEditorState) => VisualEditorState>, deleteSelection: () => void) {
+export function useMouse(
+ insertMode: InsertMode,
+ zoom: number,
+ refSVG: {current: SVGSVGElement|null},
+ state: VisualEditorState,
+ commitState: Dispatch<(v: VisualEditorState) => VisualEditorState>,
+ replaceState: Dispatch<(v: VisualEditorState) => VisualEditorState>)
+{
const [dragging, setDragging] = useState(false);
+ const [shiftOrCtrlPressed, setShiftOrCtrlPressed] = useState(false);
// not null while the user is making a selection
const [selectingState, setSelectingState] = useState(null);
const selection = state.selection;
- const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
- setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
+ const commitSelection = useCallback((cb: (oldSelection: Selection) => Selection) => {
+ commitState(oldState => ({...oldState, selection: cb(oldState.selection)}));
+ },[commitState]);
+
+ const replaceSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
+ replaceState(oldState => ({...oldState, selection: cb(oldState.selection)})),[replaceState]);
+
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
const bbox = refSVG.current!.getBoundingClientRect();
@@ -28,9 +42,8 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
const currentPointer = getCurrentPointer(e);
if (e.button === 2) {
- makeCheckPoint();
- // ignore selection, middle mouse button always inserts
- setState(state => {
+ // ignore selection, right mouse button always inserts
+ commitState(state => {
const newID = state.nextID.toString();
if (insertMode === "and" || insertMode === "or") {
// insert rountangle
@@ -43,7 +56,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
kind: insertMode,
}],
nextID: state.nextID+1,
- selection: [{uid: newID, parts: ["bottom", "right"]}],
+ selection: [{uid: newID, part: "bottom"}, {uid: newID, part: "right"}],
};
}
else if (insertMode === "pseudo") {
@@ -55,7 +68,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
size: MIN_ROUNTANGLE_SIZE,
}],
nextID: state.nextID+1,
- selection: [{uid: newID, parts: ["bottom", "right"]}],
+ selection: [{uid: newID, part: "bottom"}, {uid: newID, part: "right"}],
};
}
else if (insertMode === "shallow" || insertMode === "deep") {
@@ -67,7 +80,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
topLeft: currentPointer,
}],
nextID: state.nextID+1,
- selection: [{uid: newID, parts: ["history"]}],
+ selection: [{uid: newID, part: "history"}],
}
}
else if (insertMode === "transition") {
@@ -79,7 +92,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
end: currentPointer,
}],
nextID: state.nextID+1,
- selection: [{uid: newID, parts: ["end"]}],
+ selection: [{uid: newID, part: "end"}],
}
}
else if (insertMode === "text") {
@@ -91,7 +104,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
topLeft: currentPointer,
}],
nextID: state.nextID+1,
- selection: [{uid: newID, parts: ["text"]}],
+ selection: [{uid: newID, part: "text"}],
}
}
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
@@ -100,82 +113,106 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
return;
}
+ let appendTo: Selection;
+ if (shiftOrCtrlPressed) {
+ appendTo = selection;
+ }
+ else {
+ appendTo = [];
+ }
+
+ const startMakingSelection = () => {
+ setDragging(false);
+ setSelectingState({
+ topLeft: currentPointer,
+ size: {x: 0, y: 0},
+ });
+ commitSelection(_ => appendTo);
+ }
+
if (e.button === 0) {
- // left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape.
+ // left mouse button
const uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
if (uid && parts.length > 0) {
- makeCheckPoint();
-
- // if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on
+ // mouse hovers over a shape or part of a shape
let allPartsInSelection = true;
for (const part of parts) {
- if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) {
+ if (!(selection.some(s => (s.uid === uid) && (s.part === part)))) {
allPartsInSelection = false;
break;
}
}
if (!allPartsInSelection) {
+ // the part is not in existing selection
if (e.target.classList.contains("helper")) {
- setSelection(() => [{uid, parts}] as Selection);
+ // it's only a helper
+ // -> update selection by the part and start dragging it
+ commitSelection(() => [
+ ...appendTo,
+ ...parts.map(part => ({uid, part})) as Selection,
+ ]);
+ setDragging(true);
}
else {
- setDragging(false);
- setSelectingState({
- topLeft: currentPointer,
- size: {x: 0, y: 0},
- });
- setSelection(() => []);
- return;
+ // it's an actual shape
+ // (we treat shapes differently from helpers because in a big hierarchical model it is nearly impossible to click anywhere without clicking inside a shape)
+ startMakingSelection();
}
}
-
- // start dragging
- setDragging(true);
- return;
+ else {
+ // the part is in existing selection
+ // -> just start dragging
+ commitSelection(s => s); // <-- but also create an undo-checkpoint!
+ setDragging(true);
+ }
+ }
+ else {
+ // mouse is not on any shape
+ startMakingSelection();
}
}
-
- // otherwise, just start making a selection
- setDragging(false);
- setSelectingState({
- topLeft: currentPointer,
- size: {x: 0, y: 0},
- });
- setSelection(() => []);
- }, [getCurrentPointer, makeCheckPoint, insertMode, selection]);
+ else {
+ // any other mouse button (e.g., middle mouse button)
+ // -> just start making a selection
+ startMakingSelection();
+ }
+ }, [commitState, commitSelection, getCurrentPointer, insertMode, selection, shiftOrCtrlPressed]);
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
const currentPointer = getCurrentPointer(e);
if (dragging) {
- // const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
+ // we're moving / resizing
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
- setState(state => ({
+ const getParts = (uid: string) => {
+ return selection.filter(s => s.uid === uid).map(s => s.part);
+ }
+ replaceState(state => ({
...state,
rountangles: state.rountangles.map(r => {
- const parts = state.selection.find(selected => selected.uid === r.uid)?.parts || [];
- if (parts.length === 0) {
+ const selectedParts = getParts(r.uid);
+ if (selectedParts.length === 0) {
return r;
}
return {
...r,
- ...transformRect(r, parts, pointerDelta),
+ ...transformRect(r, selectedParts, pointerDelta),
};
})
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
diamonds: state.diamonds.map(d => {
- const parts = state.selection.find(selected => selected.uid === d.uid)?.parts || [];
- if (parts.length === 0) {
+ const selectedParts = getParts(d.uid);
+ if (selectedParts.length === 0) {
return d;
}
return {
...d,
- ...transformRect(d, parts, pointerDelta),
+ ...transformRect(d, selectedParts, pointerDelta),
}
}),
history: state.history.map(h => {
- const parts = state.selection.find(selected => selected.uid === h.uid)?.parts || [];
- if (parts.length === 0) {
+ const selectedParts = getParts(h.uid);
+ if (selectedParts.length === 0) {
return h;
}
return {
@@ -184,18 +221,18 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
}
}),
arrows: state.arrows.map(a => {
- const parts = state.selection.find(selected => selected.uid === a.uid)?.parts || [];
- if (parts.length === 0) {
+ const selectedParts = getParts(a.uid);
+ if (selectedParts.length === 0) {
return a;
}
return {
...a,
- ...transformLine(a, parts, pointerDelta),
+ ...transformLine(a, selectedParts, pointerDelta),
}
}),
texts: state.texts.map(t => {
- const parts = state.selection.find(selected => selected.uid === t.uid)?.parts || [];
- if (parts.length === 0) {
+ const selectedParts = getParts(t.uid);
+ if (selectedParts.length === 0) {
return t;
}
return {
@@ -207,6 +244,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
setDragging(true);
}
else if (selectingState) {
+ // we're making a selection
setSelectingState(ss => {
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
return {
@@ -215,13 +253,15 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
};
});
}
- }, [getCurrentPointer, selectingState, dragging]);
+ }, [replaceState, getCurrentPointer, selectingState, setSelectingState, selection, dragging]);
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
if (dragging) {
+ // we were moving / resizing
setDragging(false);
+
// do not persist sizes smaller than 40x40
- setState(state => {
+ replaceState(state => {
return {
...state,
rountangles: state.rountangles.map(r => ({
@@ -236,20 +276,24 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
});
}
if (selectingState) {
+ // we were making a selection
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
+ // it was only a click (mouse didn't move)
+ // -> select the clicked part(s)
+ // (btw, this is only here to allow selecting rountangles by clicking inside them, all other shapes can be selected entirely by their 'helpers')
const uid = e.target?.dataset.uid;
if (uid) {
- const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
+ const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="") || [];
if (uid) {
- setSelection(() => [{
- uid,
- parts,
- }]);
+ replaceSelection(oldSelection => [
+ ...oldSelection,
+ ...parts.map((part: string) => ({uid, part})),
+ ]);
}
}
}
else {
- // we were making a selection
+ // complete selection
const normalizedSS = normalizeRect(selectingState);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
@@ -261,83 +305,74 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
return isEntirelyWithin(scaledBBox, normalizedSS);
}).filter(el => !el.classList.contains("corner"));
- const uidToParts = new Map();
- for (const shape of shapesInSelection) {
- const uid = shape.dataset.uid;
- if (uid) {
- const parts: Set = uidToParts.get(uid) || new Set();
- for (const part of shape.dataset.parts?.split(' ') || []) {
- parts.add(part);
+ // @ts-ignore
+ replaceSelection(oldSelection => {
+ const newSelection = [...oldSelection];
+ for (const shape of shapesInSelection) {
+ const uid = shape.dataset.uid;
+ if (uid) {
+ const parts = shape.dataset.parts?.split(' ') || [];
+ for (const part of parts) {
+ if (newSelection.some(({uid: oldUid, part: oldPart}) =>
+ uid === oldUid && part === oldPart)) {
+ }
+ else {
+ // @ts-ignore
+ newSelection.push({uid, part});
+ }
+ }
}
- uidToParts.set(uid, parts);
}
- }
- setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
- uid,
- parts: [...parts],
- })));
+ return newSelection;
+ })
}
}
setSelectingState(null); // no longer making a selection
- }, [dragging, selectingState, refSVG.current]);
+ }, [replaceState, replaceSelection, dragging, selectingState, setSelectingState, refSVG.current]);
- const onKeyDown = useCallback((e: KeyboardEvent) => {
- // don't capture keyboard events when focused on an input element:
- // @ts-ignore
- if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
+ const trackShiftKey = useCallback((e: KeyboardEvent) => {
+ setShiftOrCtrlPressed(e.shiftKey || e.ctrlKey);
+ }, []);
- if (e.key === "o") {
- // selected states become OR-states
- setState(state => ({
- ...state,
- rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
- }));
- }
- if (e.key === "a") {
- // selected states become AND-states
- setState(state => ({
- ...state,
- rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
- }));
- }
- // if (e.key === "p") {
- // // selected states become pseudo-states
- // setSelection(selection => {
- // setState(state => ({
- // ...state,
- // rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
- // }));
- // return selection;
- // });
- // }
- if (e.ctrlKey) {
- if (e.key === "a") {
- e.preventDefault();
- setDragging(false);
- setState(state => ({
- ...state,
- // @ts-ignore
- selection: [
- ...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
- ...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
- ...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
- ...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
- ...state.history.map(h => ({uid: h.uid, parts: ["history"]})),
- ]
- }))
- }
- }
- }, [makeCheckPoint, deleteSelection, setState, setDragging]);
+ const onSelectAll = useCallback(() => {
+ setDragging(false);
+ commitState(state => ({
+ ...state,
+ // @ts-ignore
+ selection: [
+ ...state.rountangles.flatMap(r => ["left", "top", "right", "bottom"].map(part => ({uid: r.uid, part}))),
+ ...state.diamonds.flatMap(d => ["left", "top", "right", "bottom"].map(part => ({uid: d.uid, part}))),
+ ...state.arrows.flatMap(a => ["start", "end"].map(part => ({uid: a.uid, part}))),
+ ...state.texts.map(t => ({uid: t.uid, part: "text"})),
+ ...state.history.map(h => ({uid: h.uid, part: "history"})),
+ ],
+ }));
+ }, [commitState, setDragging]);
+
+ const convertSelection = useCallback((kind: "or"|"and") => {
+ commitState(state => ({
+ ...state,
+ rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind}) : r),
+ }));
+ }, [commitState]);
+
+ useShortcuts([
+ {keys: ["o"], action: useCallback(() => convertSelection("or"), [convertSelection])},
+ {keys: ["a"], action: useCallback(() => convertSelection("and"), [convertSelection])},
+ {keys: ["Ctrl", "a"], action: onSelectAll},
+ ]);
useEffect(() => {
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("keydown", trackShiftKey);
+ window.addEventListener("keyup", trackShiftKey);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
- window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("keydown", trackShiftKey);
+ window.removeEventListener("keyup", trackShiftKey);
};
}, [selectingState, dragging]);
diff --git a/src/App/useEditor.ts b/src/App/hooks/useEditor.ts
similarity index 51%
rename from src/App/useEditor.ts
rename to src/App/hooks/useEditor.ts
index e1ca293..6028ef3 100644
--- a/src/App/useEditor.ts
+++ b/src/App/hooks/useEditor.ts
@@ -1,8 +1,9 @@
import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
-import { HISTORY_RADIUS } from "./parameters";
+import { HISTORY_RADIUS } from "../parameters";
import { Dispatch, SetStateAction, useCallback, useEffect } from "react";
-import { EditHistory } from "./App";
-import { VisualEditorState } from "./VisualEditor/VisualEditor";
+import { EditHistory } from "../App";
+import { jsonDeepEqual } from "@/util/util";
+import { VisualEditorState } from "../VisualEditor/VisualEditor";
export function useEditor(setEditHistory: Dispatch>) {
useEffect(() => {
@@ -12,13 +13,27 @@ export function useEditor(setEditHistory: Dispatch {
- setEditHistory(historyState => historyState && ({
- ...historyState,
- history: [...historyState.history, historyState.current],
- future: [],
- }));
+ const commitState = useCallback((callback: (oldState: VisualEditorState) => VisualEditorState) => {
+ setEditHistory(historyState => {
+ if (historyState === null) return null; // no change
+ const newEditorState = callback(historyState.current);
+ return {
+ current: newEditorState,
+ history: [...historyState.history, historyState.current],
+ future: [],
+ }
+ // }
+ });
+ }, [setEditHistory]);
+ const replaceState = useCallback((callback: (oldState: VisualEditorState) => VisualEditorState) => {
+ setEditHistory(historyState => {
+ if (historyState === null) return null; // no change
+ const newEditorState = callback(historyState.current);
+ return {
+ ...historyState,
+ current: newEditorState,
+ };
+ });
}, [setEditHistory]);
const onUndo = useCallback(() => {
setEditHistory(historyState => {
@@ -47,62 +62,54 @@ export function useEditor(setEditHistory: Dispatch {
- makeCheckPoint();
- setEditHistory(historyState => {
- if (historyState === null) return null;
-
- const selection = historyState.current.selection;
-
+ commitState(editorState => {
+ const selection = editorState.selection;
if (selection.length === 0) {
- return historyState;
+ return editorState;
}
// determine bounding box... in a convoluted manner
let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity;
-
function addPointToBBox({x,y}: Vec2D) {
minX = Math.max(minX, x);
minY = Math.max(minY, y);
maxX = Math.min(maxX, x);
maxY = Math.min(maxY, y);
}
-
- for (const rt of historyState.current.rountangles) {
+ for (const rt of editorState.rountangles) {
if (selection.some(s => s.uid === rt.uid)) {
addPointToBBox(rt.topLeft);
addPointToBBox(addV2D(rt.topLeft, rt.size));
}
}
- for (const d of historyState.current.diamonds) {
+ for (const d of editorState.diamonds) {
if (selection.some(s => s.uid === d.uid)) {
addPointToBBox(d.topLeft);
addPointToBBox(addV2D(d.topLeft, d.size));
}
}
- for (const arr of historyState.current.arrows) {
+ for (const arr of editorState.arrows) {
if (selection.some(s => s.uid === arr.uid)) {
addPointToBBox(arr.start);
addPointToBBox(arr.end);
}
}
- for (const txt of historyState.current.texts) {
+ for (const txt of editorState.texts) {
if (selection.some(s => s.uid === txt.uid)) {
addPointToBBox(txt.topLeft);
}
}
const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS};
- for (const h of historyState.current.history) {
+ for (const h of editorState.history) {
if (selection.some(s => s.uid === h.uid)) {
addPointToBBox(h.topLeft);
addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2)));
}
}
-
const center: Vec2D = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
};
-
const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => {
if (selection.some(s => s.uid === shape.uid)) {
return cb(shape);
@@ -111,56 +118,51 @@ export function useEditor(setEditHistory: Dispatch mapIfSelected(rt, rt => {
- return {
- ...rt,
- ...(direction === "ccw"
- ? rotateRect90CCW(rt, center)
- : rotateRect90CW(rt, center)),
- }
- })),
- arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
- return {
- ...arr,
- ...(direction === "ccw"
- ? rotateLine90CCW(arr, center)
- : rotateLine90CW(arr, center)),
- };
- })),
- diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
- return {
- ...d,
- ...(direction === "ccw"
- ? rotateRect90CCW(d, center)
- : rotateRect90CW(d, center)),
- };
- })),
- texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
- return {
- ...txt,
- topLeft: (direction === "ccw"
- ? rotatePoint90CCW(txt.topLeft, center)
- : rotatePoint90CW(txt.topLeft, center)),
- };
- })),
- history: historyState.current.history.map(h => mapIfSelected(h, h => {
- return {
- ...h,
- topLeft: (direction === "ccw"
- ? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
- : subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
- ),
- };
- })),
- },
- }
- })
+ ...editorState,
+ rountangles: editorState.rountangles.map(rt => mapIfSelected(rt, rt => {
+ return {
+ ...rt,
+ ...(direction === "ccw"
+ ? rotateRect90CCW(rt, center)
+ : rotateRect90CW(rt, center)),
+ }
+ })),
+ arrows: editorState.arrows.map(arr => mapIfSelected(arr, arr => {
+ return {
+ ...arr,
+ ...(direction === "ccw"
+ ? rotateLine90CCW(arr, center)
+ : rotateLine90CW(arr, center)),
+ };
+ })),
+ diamonds: editorState.diamonds.map(d => mapIfSelected(d, d => {
+ return {
+ ...d,
+ ...(direction === "ccw"
+ ? rotateRect90CCW(d, center)
+ : rotateRect90CW(d, center)),
+ };
+ })),
+ texts: editorState.texts.map(txt => mapIfSelected(txt, txt => {
+ return {
+ ...txt,
+ topLeft: (direction === "ccw"
+ ? rotatePoint90CCW(txt.topLeft, center)
+ : rotatePoint90CW(txt.topLeft, center)),
+ };
+ })),
+ history: editorState.history.map(h => mapIfSelected(h, h => {
+ return {
+ ...h,
+ topLeft: (direction === "ccw"
+ ? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
+ : subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
+ ),
+ };
+ })),
+ };
+ });
}, [setEditHistory]);
-
- return {makeCheckPoint, onUndo, onRedo, onRotate};
+ return {commitState, replaceState, onUndo, onRedo, onRotate};
}
\ No newline at end of file
diff --git a/src/App/hooks/useSimulator.ts b/src/App/hooks/useSimulator.ts
new file mode 100644
index 0000000..fa07dd0
--- /dev/null
+++ b/src/App/hooks/useSimulator.ts
@@ -0,0 +1,224 @@
+import { Statechart } from "@/statecharts/abstract_syntax";
+import { RuntimeError } from "@/statecharts/interpreter";
+import { BigStep, RaisedEvent } from "@/statecharts/runtime_types";
+import { Conns, coupledExecution, statechartExecution } from "@/statecharts/timed_reactive";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Plant } from "../Plant/Plant";
+import { getSimTime, getWallClkDelay, TimeMode } from "@/statecharts/time";
+import { UniversalPlantState } from "../plants";
+
+type CoupledState = {
+ sc: BigStep,
+ plant: BigStep,
+};
+
+export type TraceItemError = {
+ cause: BigStepCause, // event name, or
+ 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, plantConns: Conns, onStep: () => void) {
+ const [time, setTime] = useState({kind: "paused", simtime: 0});
+ const [trace, setTrace] = useState(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};
+}
diff --git a/src/App/makePartialSetter.ts b/src/App/makePartialSetter.ts
new file mode 100644
index 0000000..d3d5109
--- /dev/null
+++ b/src/App/makePartialSetter.ts
@@ -0,0 +1,36 @@
+import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
+
+export function makePartialSetter(fullSetter: Dispatch>, key: K): Dispatch> {
+ 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 = {
+ [K in keyof T as `set${Capitalize>}`]: Dispatch>;
+}
+
+export function makeAllSetters(
+ fullSetter: Dispatch>,
+ keys: (keyof T)[],
+): Setters {
+ // @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]);
+}
diff --git a/src/App/plants.ts b/src/App/plants.ts
new file mode 100644
index 0000000..84157a9
--- /dev/null
+++ b/src/App/plants.ts
@@ -0,0 +1,14 @@
+import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
+import { dummyPlant } from "./Plant/Dummy/Dummy";
+import { microwavePlant } from "./Plant/Microwave/Microwave";
+import { Plant } from "./Plant/Plant";
+import { trafficLightPlant } from "./Plant/TrafficLight/TrafficLight";
+
+export type UniversalPlantState = {[property: string]: boolean|number};
+
+export const plants: [string, Plant][] = [
+ ["dummy", dummyPlant],
+ ["microwave", microwavePlant as unknown as Plant],
+ ["digital watch", digitalWatchPlant as unknown as Plant],
+ ["traffic light", trafficLightPlant as unknown as Plant],
+];
diff --git a/src/App/useSimulator.ts b/src/App/useSimulator.ts
deleted file mode 100644
index 1f75028..0000000
--- a/src/App/useSimulator.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function useSimulator() {
-
-}
\ No newline at end of file
diff --git a/src/App/useUrlHashState.ts b/src/App/useUrlHashState.ts
deleted file mode 100644
index 3c9dd16..0000000
--- a/src/App/useUrlHashState.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { Dispatch, SetStateAction, useEffect, useState } from "react";
-import { BigStepCause, EditHistory } from "./App";
-import { VisualEditorState } from "./VisualEditor/VisualEditor";
-import { emptyState } from "@/statecharts/concrete_syntax";
-import { InsertMode } from "./TopPanel/InsertModes";
-import { Conns } from "@/statecharts/timed_reactive";
-
-export function useUrlHashState(editorState: VisualEditorState | null, setEditHistory: Dispatch>) {
-
- // 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({});
- const [showKeys, setShowKeys] = useState(true);
- const [zoom, setZoom] = useState(1);
- const [insertMode, setInsertMode] = useState("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([]);
- const [savedTraces, setSavedTraces] = useState<[string, BigStepCause[]][]>([]);
- const [activeProperty, setActiveProperty] = useState(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,
- }
-}
\ No newline at end of file
diff --git a/src/frontend.tsx b/src/frontend.tsx
index e95c329..1e00a46 100644
--- a/src/frontend.tsx
+++ b/src/frontend.tsx
@@ -11,9 +11,9 @@ import { App } from "./App/App";
const elem = document.getElementById("root")!;
const app = (
-
+ //
-
+ //
);
if (import.meta.hot) {
diff --git a/src/App/useAudioContext.ts b/src/hooks/useAudioContext.ts
similarity index 100%
rename from src/App/useAudioContext.ts
rename to src/hooks/useAudioContext.ts
diff --git a/src/hooks/useDetectChange.ts b/src/hooks/useDetectChange.ts
new file mode 100644
index 0000000..3ef07c4
--- /dev/null
+++ b/src/hooks/useDetectChange.ts
@@ -0,0 +1,8 @@
+import { useEffect } from "react";
+
+// useful for debugging
+export function useDetectChange(expr: any, name: string) {
+ useEffect(() => {
+ console.log(name, 'changed to:', expr);
+ }, [expr]);
+}
diff --git a/src/App/persistent_state.ts b/src/hooks/usePersistentState.ts
similarity index 100%
rename from src/App/persistent_state.ts
rename to src/hooks/usePersistentState.ts
diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts
new file mode 100644
index 0000000..cafb6db
--- /dev/null
+++ b/src/hooks/useShortcuts.ts
@@ -0,0 +1,30 @@
+import { useEffect } from "react";
+
+export function useShortcuts(spec: {keys: string[], action: () => void}[], ignoreInputs = true) {
+ // I don't know if this is efficient, but I decided to just register one event listener for every shortcut, rather than generating one big event listener for all shortcuts.
+ // The benefit is that we don't have to memoize anything: useEffect will only be called if the action updated, and React allows calling useEffect for every item in a list as long as the list doesn't change.
+ for (const {keys, action} of spec) {
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (ignoreInputs) {
+ // @ts-ignore: don't steal keyboard events while the user is typing in a text box, etc.
+ if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
+ }
+
+ if (e.ctrlKey !== keys.includes("Ctrl")) return;
+ if (e.shiftKey !== keys.includes("Shift")) return;
+ if (!keys.includes(e.key)) return;
+ const remainingKeys = keys.filter(key => key !== "Ctrl" && key !== "Shift" && key !== e.key);
+ if (remainingKeys.length !== 0) {
+ console.warn("impossible shortcut sequence:", keys.join(' + '));
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ action();
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [action]);
+ }
+}
diff --git a/src/hooks/useUrlHashState.ts b/src/hooks/useUrlHashState.ts
new file mode 100644
index 0000000..d442552
--- /dev/null
+++ b/src/hooks/useUrlHashState.ts
@@ -0,0 +1,56 @@
+import { useEffect, useLayoutEffect } from "react";
+
+// persist state in URL hash
+export function useUrlHashState(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;
+}
diff --git a/src/index.css b/src/index.css
index a9a5d3e..78b28ca 100644
--- a/src/index.css
+++ b/src/index.css
@@ -5,9 +5,69 @@ html, body {
font-size: 10pt;
}
-body {
- /* --error-color: darkred; */
- --error-color: rgb(163, 0, 0);
+:root {
+ color-scheme: light dark;
+
+ --background-color: light-dark(white, rgb(31, 33, 36));
+ --text-color: light-dark(black, white);
+ --error-color: light-dark(rgb(163, 0, 0), rgb(255, 82, 82));
+ --error-bg-color: light-dark(lightpink, rgb(75, 0, 0));
+ --error-hover-bg-color: light-dark(rgb(102, 0, 0), rgb(238, 153, 153));
+ --light-accent-color: light-dark(rgba(0,0,255,0.2), rgba(78, 186, 248, 0.377));
+ --accent-border-color: light-dark(blue, rgb(64, 185, 255));
+ --accent-opaque-color: light-dark(#ccccff, #305b73);
+ --separator-color: light-dark(lightgrey, rgb(58, 58, 58));
+ --inactive-bg-color: light-dark(#f7f7f7, rgb(29, 29, 29));
+ --inactive-fg-color: light-dark(grey, rgb(70, 70, 70));
+ --button-bg-color: light-dark(#fcfcfc, rgb(44, 50, 63));
+ --textbox-bg-color: light-dark(white, rgb(36, 41, 40));
+ --modal-backdrop-color: light-dark(rgba(200,200,200,0.7), rgba(23, 22, 32, 0.849));
+ --status-inactive-color: light-dark(grey, grey);
+ --status-ok-color: light-dark(forestgreen, forestgreen);
+ --or-state-bg-color: light-dark(#eee, #000000);
+ --and-state-bg-color: light-dark(white, rgb(46, 46, 46));
+ --rountangle-stroke-color: light-dark(black, #d4d4d4);
+ --active-state-bg-color: light-dark(rgb(255, 240, 214), rgb(53, 37, 18));
+ --active-state-border-color: light-dark(rgb(205, 133, 0), rgb(235, 124, 21));
+ --fired-transition-color: light-dark(rgb(160, 0, 168), rgb(160, 0, 168));
+ --firing-transition-color: light-dark(rgba(255, 128, 9, 1), rgba(255, 128, 9, 1));
+ --associated-color: light-dark(green, rgb(186, 245, 119));
+ --greeter-bg-color: light-dark(rgb(255, 249, 235), rgb(24, 40, 70));
+ /* --bottom-panel-bg-color: light-dark(rgb(219, 219, 219), rgb(31, 33, 36)); */
+ --summary-hover-bg-color: light-dark(#eee, #2e2f35);
+ --internal-event-bg-color: light-dark(rgb(255, 218, 252), rgb(99, 27, 94));
+ --input-event-bg-color: light-dark(rgb(224, 247, 209), rgb(59, 95, 37));
+ --input-event-hover-bg-color: light-dark(rgb(195, 224, 176), rgb(59, 88, 40));
+ --input-event-active-bg-color: light-dark(rgb(176, 204, 158), rgb(77, 117, 53));
+ --output-event-bg-color: light-dark(rgb(230, 249, 255), rgb(28, 83, 104));
+
+ background-color: var(--background-color);
+ color: var(--text-color);
+}
+
+input {
+ background-color: var(--textbox-bg-color);
+ border: 1px solid var(--separator-color);
+}
+
+button {
+ background-color: var(--button-bg-color);
+ border: 1px var(--separator-color) solid;
+}
+
+button:not(:disabled):hover {
+ background-color: var(--light-accent-color);
+}
+
+button:disabled {
+ background-color: var(--inactive-bg-color);
+ color: var(--inactive-fg-color);
+}
+
+button.active {
+ border: solid var(--accent-border-color) 1px;
+ background-color: var(--light-accent-color);
+ color: var(--text-color);
}
div#root {
@@ -27,17 +87,18 @@ kbd {
box-shadow: inset 0 -1.5px 0 #aaa;
vertical-align: middle;
user-select: none;
+ color: black;
}
kbd:active { transform: translateY(1px); }
input {
/* border: solid blue 2px; */
- accent-color: rgba(0,0,255,0.2);
+ accent-color: var(--accent-opaque-color);
}
::selection {
- background-color: rgba(0,0,255,0.2);
+ background-color: var(--light-accent-color);
}
label {
diff --git a/src/statecharts/concrete_syntax.ts b/src/statecharts/concrete_syntax.ts
index 85aae51..c74b9b9 100644
--- a/src/statecharts/concrete_syntax.ts
+++ b/src/statecharts/concrete_syntax.ts
@@ -28,6 +28,14 @@ export type History = {
topLeft: Vec2D;
};
+export type ConcreteSyntax = {
+ rountangles: Rountangle[];
+ texts: Text[];
+ arrows: Arrow[];
+ diamonds: Diamond[];
+ history: History[];
+};
+
// independently moveable parts of our shapes:
export type RectSide = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end";
diff --git a/src/statecharts/detect_connections.ts b/src/statecharts/detect_connections.ts
index eefc02e..d5fc0f9 100644
--- a/src/statecharts/detect_connections.ts
+++ b/src/statecharts/detect_connections.ts
@@ -1,4 +1,5 @@
-import { ConcreteSyntax, VisualEditorState } from "@/App/VisualEditor/VisualEditor";
+import { VisualEditorState } from "@/App/VisualEditor/VisualEditor";
+import { ConcreteSyntax } from "./concrete_syntax";
import { findNearestArrow, findNearestHistory, findNearestSide, findRountangle, RectSide } from "./concrete_syntax";
export type Connections = {
diff --git a/src/statecharts/interpreter.ts b/src/statecharts/interpreter.ts
index 2520187..deabf61 100644
--- a/src/statecharts/interpreter.ts
+++ b/src/statecharts/interpreter.ts
@@ -358,7 +358,7 @@ export function handleInternalEvents(simtime: number, statechart: Statechart, {i
const [nextEvent, ...remainingEvents] = internalEvents;
({internalEvents, ...rest} = fairStep(simtime,
{kind: "input", ...nextEvent}, // internal event becomes input event
- statechart, statechart.root, { arenasFired: [], internalEvents: remainingEvents, ...rest}));
+ statechart, statechart.root, { ...rest, arenasFired: [], internalEvents: remainingEvents, }));
}
return rest;
}
diff --git a/src/statecharts/parser.ts b/src/statecharts/parser.ts
index 43b9bb7..fbef792 100644
--- a/src/statecharts/parser.ts
+++ b/src/statecharts/parser.ts
@@ -5,7 +5,7 @@ import { Action, EventTrigger, Expression, ParsedText } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser";
import { Connections } from "./detect_connections";
import { HISTORY_RADIUS } from "../App/parameters";
-import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
+import { ConcreteSyntax } from "./concrete_syntax";
import { memoize } from "@/util/util";
export type TraceableError = {
diff --git a/src/util/util.ts b/src/util/util.ts
index c105ae6..4213a8b 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -24,6 +24,24 @@ export function memoize(fn: (i: InType) => OutType) {
}
}
+// author: ChatGPT
+export function jsonDeepEqual(a: any, b: any) {
+ if (a === b) return true;
+ if (a && b && typeof a === "object" && typeof b === "object") {
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+
+ const keysA = Object.keys(a);
+ const keysB = Object.keys(b);
+ if (keysA.length !== keysB.length) return false;
+
+ for (const key of keysA) {
+ if (!jsonDeepEqual(a[key], b[key])) return false;
+ }
+ return true;
+ }
+ return false;
+}
+
// compare arrays by value
export function arraysEqual(a: T[], b: T[], cmp: (a: T, b: T) => boolean = (a,b)=>a===b): boolean {
if (a === b)
diff --git a/teaching/mosis25/assignment.html b/teaching/mosis25/assignment.html
new file mode 100644
index 0000000..8f623c4
--- /dev/null
+++ b/teaching/mosis25/assignment.html
@@ -0,0 +1,376 @@
+
+
+
+Practical stuff
+
+ Due Date: 23 November 2025, before 23:59 (Blackboard's clock).
+ Team Size: 2 (pair design/programming)!
+ Note that as of the 2017-2018 Academic Year, each International student should team up with "local"
+ (i.e., whose Bachelor degree was obtained at the University of Antwerp).
+ Assignment overview:
+
+ Review the exercises
+ Implement Statechart
+ Write report
+
+
+
+ Submitting your solution:
+
+
+ Only one member of each team submits a full solution. Either a ZIP-archive if you have multiple files, or just a PDF containing your report.
+
+
+ The other team member must submits a single (plain text or HTML) file containing only the names of both team members. This will allow us to put in grades for both team members in BlackBoard.
+
+
+
+
+ Submission Medium:
+ BlackBoard .
+
+
+ Contact / TA:
+ Joeri Exelmans .
+
+
+
+Goals
+The goal of this assignment is to familiarize yourself with Statechart modeling, simulation, debugging and,
+ to a lesser extent, automatically checking requirements -specified as temporal logic formulas over behaviour traces- on your solution.
+
+
+
+Getting Started
+We will use the brand-new Statechart editor, simulation and testing environment StateBuddy , created by yours truly.
+
+No need to install anything. StateBuddy runs in the browser.
+
+Note: StateBuddy will be updated from time to time, to fix bugs or add new features.
+To make sure you have the latest version, in StateBuddy, use the shortcut
Ctrl +
Shift +
R to refresh the page while clearing your browser cache.
+
+StateBuddy was tested in Firefox 144.0 and Chromium 141.0.7390.107.
+
+Problems? Please use the GitHub
issue tracker .
+
+
+Exercises
+Before we start working on this assignment, we will solve 5 small exercises.
+Each exercise shows you a small Statechart model, and asks a question about its behavior.
+If you can solve the exercises, you will have a good (enough) understanding of the precise semantics of StateBuddy.
+
+The exercises can be opened by clicking on their respective links:
+
+
+ nested timed transitions
+
+ parent-first
+
+ order of orthogonal regions
+
+ crossing orthogonal regions
+
+ internal events
+
+
+
+
+ To solve the exercises, you
must have a good understanding of the precise semantics of StateBuddy .
+ The semantics are as follows:
+
+ The execution of a Statechart is a sequence of Run-To-Completion (RTC) steps
+ An RTC-step can only triggered by:
+
+ An input event
+ A timer that elapses (actually, an elapsing timer generates an input event behind the scenes)
+
+ An RTC-step is instanteneous: it takes zero time.
+ In between RTC-steps, the Statechart is idle (it will not change its state), and time may pass.
+ An RTC-step consists of one or more fair-steps:
+
+ During the first fair-step, only the input event (or timer event) that triggered the RTC-step is active.
+ During a fair-step, orthogonal regions are visited in lexicographical order . For instance, if you have regions labeled A and B, then A will be visited before B.
+
+ Within one fair step, every (orthogonal / non-overlapping) region is allowed to fire at most one transition . A region will fire a transition only if it has an enabled transition (wrt. the currently active event, and the transition's guard condition).
+
+ When a transition fires: first, all the exit actions of all the exited states are executed (in order: child to parent), then the action of the transition itself, followed by the enter actions of the entered states (in order: parent to child)
+
+ In this example , when firing the transition from A to F, first the exit actions of A, B, and C are executed, then the actions of the transition itself, and finally the enter actions of D, E and F (in that order).
+
+
+ Any internal events that are raised (as a result of firing transitions), are added to the internal event (FIFO) queue.
+
+
+
+ When a fair-step has completed:
+
+ if the internal event (FIFO) queue is not empty, then a new fair-step starts. The next event is popped from the queue, and it becomes the new active event.
+ if the internal event queue is empty, then no more fair-steps are executed, and the RTC-step ends.
+
+
+
+ Non-determinism (e.g., multiple enabled outgoing transitions of the same state) results in a run-time error!
+
+
Example:
+ Consider the linked Statechart .
+ After initialization, the current states are: OrthogonalState, A, C, E.
+ Then, the Statechart remains idle until it receives an input event. Suppose at time T=5s, the input event e is received. This triggers the execution of an RTC step.
+ The RTC step starts with a fair-step, where regions r1 , r2 and r3 (in that order) are allowed to fire at most one transition each.
+ Only r1 has an enabled transition (because event e is active), so only r1 fires.
+ During the firing of that transition, the internal event f is raised, an appended to the internal event queue.
+ The fair-step ends, and one more fair-step is executed, because the internal event queue is not empty.
+ So again, r1 , r2 and r3 are allowed to fire at most one transition.
+ This time, the regions r2 and r3 will fire, because event f is active.
+ The second fair-step ends, and since the internal event queue is empty, the RTC step also ends.
+ Even though all transitions fired in a certain order, all of it happened at the same point in (simulated) time.
+ Now, the Statechart will again remain idle until another input event occurs.
+
+
+ Time r1 r2 r3
+ =0 Initialization enter A enter C enter E
+ >0 && <5s Idle
+ =5s RTC step (input=e ) Fair-step (event=e ) fire (exit A , raise f , enter B )
+ Fair-step (event=f ) fire (exit C , enter D) fire (exit E , enter F )
+ =5s End of RTC step
+
+
+
+ Please remember that these precise semantics are specific to StateBuddy, although they are very similar to YAKINDU / Itemis Create. Other Statechart tools (e.g., STATEMATE, Rhapsody, StateFlow) have different semantics.
+
+
+Introduction to Assignment
+
+You will use the Statecharts formalism to model the controller of a classic digital watch (before smart watches existed).
+
+
+
+
+
+
+All user input happens through 4 buttons (one in each corner).
+The watch can display 6 numbers, in the form HH:MM:SS when displaying the current time, or the time of the alarm, or in the form MM:SS:HS (HS means hectoseconds) when displaying the chronometer.
+The watch has a background light that can be on or off.
+The watch can make a beep-sound.
+
The time can be edited.
+An alarm can be turned on or off. The alarm time can also be edited.
+The chronometer can be started, paused, and reset.
+
+
+Interfaces
+You will implement the plant (= digital watch) controller as a Statechart. The controller only talks to the plant via input-events and output-events. In StateBuddy, you can also interactively raise input events directly into the controller statechart (Debugger UI). Finally, the plant also has its own UI, which sends input events to the plant.
+For the curious student: Yes, the (simulated) plant is also implemented as a (rather big) Statechart .
+
+
+ Overview of our simulated system-under-study.
+
+
+The plant can send the following events to the controller:
+
+
+ Controller input event(s) Received when...
+
+
+ topLeftPressed, topRightPressed, bottomLeftPressed, bottomRightPressed
+ one of the 4 buttons is pressed
+
+
+ topLeftReleased, topRightReleased, bottomLeftReleased, bottomRightReleased
+ one of the 4 buttons is released
+
+
+ alarm
+ the alarm should go off
+
+
+
+
+The controller can send the following events to the plant:
+
+
+ Controller output event(s) Effect
+
+
+ lightOn, lightOff
+ turns on / off the background light
+
+
+ beep
+ make a beep sound for 10ms
+
+
+ incTime
+ increment the watch's time by one second
+
+
+ incChrono
+ increment the watch's chrono by 1/100 second
+
+
+ resetChrono
+ sets the chronometer back to 00:00:00
+
+
+ displayTime
+ puts the watch into a mode where it displays the current time
+
+
+ displayChrono
+ puts the watch into a mode where it displays the chronometer
+
+
+ displayAlarm
+ puts the watch into a mode where it displays the time of the alarm
+
+
+ setAlarm(boolean)
+ turns the alarm on (true) or off (false). if the alarm is on, and the plant detects that the current time is equal to the alarm time, then the plant will immediately send the 'alarm' event (explained above) to the controller.
+
+
+
+ beginEdit
+ puts the plant into 'edit mode'. if the plant was displaying the current time, you can now edit the current time. if the plant was dispalying the alarm time, then you can now edit the alarm time. After entering edit mode, the 'hours' part of the display will start blinking, indicating that the 'hours' can be edited.
+
+
+ endEdit
+ ends the 'edit mode'.
+
+
+ incSelection
+ when in 'edit mode', will increase the currently blinking part (i.e, hours, minutes or seconds) of the display by one
+
+
+ selectNext
+ when in 'edit mode', will select the next item (hours -> minutes -> seconds -> hours) to edit
+
+
+
+Behavioral Requirements
+
+ You may assume that initially, the plant is displaying the current time, the light is off, the alarm is off, the speaker is not beeping, and we are not in 'edit mode'. The chrono is zero and not running.
+
+ For as long as the top-right button is pressed, the light should be on, and after the top-right button is released, the light should remain on for 2 seconds.
+
+ When displaying the time, or displaying the chrono, pressing the top-left button toggles between time and chrono mode.
+
+ When in chrono mode, pressing the bottom-right button toggles the chrono between 'paused' and 'running'.
+ When in chrono mode, pressing the bottom-left button resets the chronometer to zero.
+
+ When the chrono is running, the chronometer value is incremented by 1/100 second 100 times per second. The chronometer remains running until it is paused, even if we leave the chrono mode.
+
+ The current time is incremented (ticks) by 1 second every second, even when we are not displaying the current time, except when we are editing the current time: then the time should not tick.
+
+ Pressing the bottom-left button when the time is being displayed will show the alarm time and toggle the alarm (on/off).
+ If then, the bottom-left button is held pressed for 2 seconds, we go into alarm edit mode.
+
+ Likewise, when displaying the current time, and pressing and holding the bottom-right button for 2 seconds, we go into time edit mode.
+
+ In edit mode, pressing the bottom-left button will immediately increment the current selected (blinking) numbers.
+ In edit mode, holding the bottom-left button has the additional effect incrementing the current selected numbers every 100ms.
+
+ In edit mode, pressing the bottom-right button will select the next numbers (hours -> minutes -> seconds -> hours).
+
+
+Starting point
+
+Use this link to the starting point for this assignment.
+
+Testing your solution
+To test your solution, initialize the execution, and interact with the plant UI. The execution can run in (scaled) real-time, with the ability to pause/resume.
+To gain more confidence that you correctly implemented the requirements, you can write Metric Temporal Logic (MTL) properties. An example of such a property is:
+
G ((topRightPressed -> lightOn) & ((topRightPressed & (X ~topRightPressed)) -> (G[0,2000] (lightOn))))
+meaning: "as long as the top-right button is pressed, the light should be on, and after the top-right button is released, the light should remain on for 2 seconds" AKA Requirement 1.
+
+
+Tip: ChatGPT is quite good at translating natural language to MTL properties!
+
+Note that none of these testing approaches are exhaustive (unlike model checking, which is exhaustive). Any property you write will only be checked on the current simulation trace.
+
+Report
+You are also required to write a small(*) (HTML or PDF) report.
+(*) I don't have time to read 100 pages!
+It must include the following:
+
+ The names and student IDs of the team members
+ The amount of time spent working on the project: Please be honest, this helps us estimate the workload for future assignments.
+ Your workflow : How did you work together? (pair programming / divided the work / ...) In what order did you implement different features? Encountered any difficulties?
+ A link to your solution (just copy the big URL including the hashtag when you're done).
+ If there are non-trivial parts in your solution that require explanation, then include this explanation either in the model itself (as comments (starting with '//')), or in your report, whatever you prefer.
+
+
+
+What is expected
+Your solution needs to be precisely correct: superficially correct behavior when interacting with the Plant UI is not enough : the timing-related requirements are exact .
+The assignment has been designed specifically to encourage use of as many Statechart features as possible:
+
+ composite states
+ orthogonal states
+ timed transitions
+ internal events
+ guard conditions
+ transition actions
+ enter/exit actions
+ (variables)
+ (history)
+
+Make sure you understand these features, and use them, where you think they are appropriate.
+To give you an indication of the complexity, my own solution consists of 19 AND-states, 10 OR-states, and 36 transitions.
+
+
+Additional resources
+
+
+
diff --git a/todo.txt b/todo.txt
index b9afec4..e0f1cd1 100644
--- a/todo.txt
+++ b/todo.txt
@@ -31,12 +31,16 @@
TODO
- bugs
- editing SC <-> Plant connections at runtime doesn't seem to work
+ (*) editing SC <-> Plant connections at runtime doesn't seem to work
+ (*) non-determinism error highlights only one of enabled transitions
- maybe support:
- explicit order of:
- outgoing transitions?
+- write documentation
+ although ideally the tool should explain itself...
+
- usability stuff:
- action language: add increment operations (++) and (--)
and (+=) and (-=)
@@ -48,11 +52,23 @@ TODO
- ability to hide statechart and only show the plant?
- hovering over event in side panel should highlight all occurrences of the event in the SC
- - hovering over error in bottom panel should highlight that rror in the SC
+ - rename events / variables
+ find/replace?
+
+ - hovering over error in bottom panel should highlight that error in the SC
- highlight selected shapes while making a selection
- highlight about-to-fire transitions
+
- integrate undo-history with browser history (back/forward buttons)
+ - ability to 'freeze' editor (e.g., to show plant SC)
+ - show insert mode also next to cursor
+ - plot plant signals
+
+ - show error when states partially overlap?
+ useful when accidentally pasting the same data multiple times
+ (otherwise, you can't see that you have multiple states)
+
- performance:
maybe try this for rendering the execution trace:
https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists
@@ -63,11 +79,6 @@ TODO
https://pub.dev/packages/ploeg_tree_layout
- local variable scopes
-for the assignment:
- *ALL* features
- add history (look at original Harel paper)
- add microwave oven
- add traffic light
-
Publish StateBuddy paper(s):
compare CS approach to other tools, not only YAKINDU
+z
\ No newline at end of file