errors in bottom panel are collapsible

This commit is contained in:
Joeri Exelmans 2025-10-21 10:51:17 +02:00
parent 297905a4af
commit 60a7d12857
6 changed files with 74 additions and 69 deletions

View file

@ -18,7 +18,11 @@ details:open {
details > summary:hover { details > summary:hover {
background-color: #eee; background-color: #eee;
cursor: pointer; cursor: default;
}
.errorStatus details > summary:hover {
background-color: rgb(102, 0, 0);
} }
.stateTree > * { .stateTree > * {
@ -35,9 +39,9 @@ details > summary:hover {
} }
/* if <details> has no children (besides the obvious <summary> child), then hide the marker */ /* if <details> has no children (besides the obvious <summary> child), then hide the marker */
details:not(:has(:not(summary))) > summary::marker { /* details:not(:has(:not(summary))) > summary::marker {
content: " "; content: " ";
} } */
.outputEvent { .outputEvent {
border: 1px black solid; border: 1px black solid;

View file

@ -19,6 +19,7 @@ import { getKeyHandler } from "./shortcut_handler";
import { BottomPanel } from "./BottomPanel"; import { BottomPanel } from "./BottomPanel";
import { emptyState } from "@/statecharts/concrete_syntax"; import { emptyState } from "@/statecharts/concrete_syntax";
import { usePersistentState } from "@/util/persistent_state"; import { usePersistentState } from "@/util/persistent_state";
import { PersistentDetails } from "./PersistentDetails";
type EditHistory = { type EditHistory = {
current: VisualEditorState, current: VisualEditorState,
@ -191,10 +192,6 @@ export function App() {
const highlightTransitions = (rtIdx === undefined) ? [] : rt[rtIdx].firedTransitions; const highlightTransitions = (rtIdx === undefined) ? [] : rt[rtIdx].firedTransitions;
const [showStateTree, setShowStateTree] = usePersistentState("showStateTree", true);
const [showInputEvents, setShowInputEvents] = usePersistentState("showInputEvents", true);
const [showOutputEvents, setShowOutputEvents] = usePersistentState("showOutputEvents", true);
return <> return <>
{/* Modal dialog */} {/* Modal dialog */}
@ -245,25 +242,22 @@ export function App() {
}}> }}>
<Stack sx={{height:'100%'}}> <Stack sx={{height:'100%'}}>
<Box className="onTop" sx={{flex: '0 0 content', backgroundColor: ''}}> <Box className="onTop" sx={{flex: '0 0 content', backgroundColor: ''}}>
<details open={showStateTree} <PersistentDetails localStorageKey="showStateTree" initiallyOpen={true}>
onToggle={e => setShowStateTree(e.newState === "open")}>
<summary>state tree</summary> <summary>state tree</summary>
<ul> <ul>
<ShowAST {...{...ast, rt: rt.at(rtIdx!), highlightActive}}/> <ShowAST {...{...ast, rt: rt.at(rtIdx!), highlightActive}}/>
</ul> </ul>
</details> </PersistentDetails>
<hr/> <hr/>
<details open={showInputEvents} <PersistentDetails localStorageKey="showInputEvents" initiallyOpen={true}>
onToggle={e => setShowInputEvents(e.newState === "open")}>
<summary>input events</summary> <summary>input events</summary>
<ShowInputEvents inputEvents={ast.inputEvents} onRaise={onRaise} disabled={rtIdx===undefined}/> <ShowInputEvents inputEvents={ast.inputEvents} onRaise={onRaise} disabled={rtIdx===undefined}/>
</details> </PersistentDetails>
<hr/> <hr/>
<details open={showOutputEvents} <PersistentDetails localStorageKey="showOutputEvents" initiallyOpen={true}>
onToggle={e => setShowOutputEvents(e.newState === "open")}>
<summary>output events</summary> <summary>output events</summary>
<ShowOutputEvents outputEvents={ast.outputEvents}/> <ShowOutputEvents outputEvents={ast.outputEvents}/>
</details> </PersistentDetails>
</Box> </Box>
<Box sx={{ <Box sx={{
flexGrow:1, flexGrow:1,

View file

@ -4,6 +4,8 @@ import { TraceableError } from "../statecharts/parser";
import "./BottomPanel.css"; import "./BottomPanel.css";
import head from "../head.svg" ; import head from "../head.svg" ;
import { usePersistentState } from "@/util/persistent_state";
import { PersistentDetails } from "./PersistentDetails";
export function BottomPanel(props: {errors: TraceableError[]}) { export function BottomPanel(props: {errors: TraceableError[]}) {
const [greeting, setGreeting] = useState(<><b><img src={head} style={{transform: "scaleX(-1)"}}/>&emsp;"Welcome to StateBuddy, buddy!"</b></>); const [greeting, setGreeting] = useState(<><b><img src={head} style={{transform: "scaleX(-1)"}}/>&emsp;"Welcome to StateBuddy, buddy!"</b></>);
@ -18,7 +20,14 @@ export function BottomPanel(props: {errors: TraceableError[]}) {
<>{greeting}</> <>{greeting}</>
{props.errors.length > 0 && {props.errors.length > 0 &&
<div className="errorStatus"> <div className="errorStatus">
{props.errors.length>0 && <>{props.errors.length} errors: {props.errors.map(({message})=>message).join(', ')}</>} <PersistentDetails initiallyOpen={false} localStorageKey="errorsExpanded">
</div>} <summary>{props.errors.length} errors</summary>
{props.errors.map(({message})=>
<div>
{message}
</div>)}
</PersistentDetails>
</div>
}
</div>; </div>;
} }

View file

@ -0,0 +1,15 @@
import { usePersistentState } from "@/util/persistent_state"
import { DetailsHTMLAttributes, PropsWithChildren } from "react";
type Props = {
localStorageKey: string,
initiallyOpen?: boolean,
} & DetailsHTMLAttributes<HTMLDetailsElement>;
// A <details> node that remembers whether it was open or closed by storing that state in localStorage.
export function PersistentDetails({localStorageKey, initiallyOpen, children, ...rest}: PropsWithChildren<Props>) {
const [open, setOpen] = usePersistentState(localStorageKey, initiallyOpen);
return <details open={open} onToggle={e => setOpen(e.newState === "open")} {...rest}>
{children}
</details>;
}

View file

@ -1,4 +1,4 @@
import { Dispatch, ReactElement, SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { ClipboardEvent, Dispatch, ReactElement, SetStateAction, useEffect, useMemo, useRef, useState } from "react";
import { Statechart } from "../statecharts/abstract_syntax"; import { Statechart } from "../statecharts/abstract_syntax";
import { Arrow, ArrowPart, Diamond, History, Rountangle, RountanglePart, Text } from "../statecharts/concrete_syntax"; import { Arrow, ArrowPart, Diamond, History, Rountangle, RountanglePart, Text } from "../statecharts/concrete_syntax";
@ -26,11 +26,6 @@ export type VisualEditorState = {
selection: Selection; selection: Selection;
}; };
type DraggingState = {
lastMousePos: Vec2D;
} | null; // null means: not dragging
type SelectingState = Rect2D | null; type SelectingState = Rect2D | null;
export type RountangleSelectable = { export type RountangleSelectable = {
@ -82,7 +77,7 @@ type VisualEditorProps = {
export function VisualEditor({state, setState, ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}: VisualEditorProps) { export function VisualEditor({state, setState, ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}: VisualEditorProps) {
const [dragging, setDragging] = useState<DraggingState>(null); const [dragging, setDragging] = useState(false);
// uid's of selected rountangles // uid's of selected rountangles
// const [selection, setSelection] = useState<Selection>([]); // const [selection, setSelection] = useState<Selection>([]);
@ -231,9 +226,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
} }
throw new Error("unreachable, mode=" + mode); // shut up typescript throw new Error("unreachable, mode=" + mode); // shut up typescript
}); });
setDragging({ setDragging(true);
lastMousePos: currentPointer,
});
return; return;
} }
@ -257,7 +250,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
setSelection(() => [{uid, parts}] as Selection); setSelection(() => [{uid, parts}] as Selection);
} }
else { else {
setDragging(null); setDragging(false);
setSelectingState({ setSelectingState({
topLeft: currentPointer, topLeft: currentPointer,
size: {x: 0, y: 0}, size: {x: 0, y: 0},
@ -268,15 +261,13 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
} }
// start dragging // start dragging
setDragging({ setDragging(true);
lastMousePos: currentPointer,
});
return; return;
} }
} }
// otherwise, just start making a selection // otherwise, just start making a selection
setDragging(null); setDragging(false);
setSelectingState({ setSelectingState({
topLeft: currentPointer, topLeft: currentPointer,
size: {x: 0, y: 0}, size: {x: 0, y: 0},
@ -343,7 +334,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
} }
}), }),
})); }));
setDragging({lastMousePos: currentPointer}); setDragging(true);
} }
else if (selectingState) { else if (selectingState) {
setSelectingState(ss => { setSelectingState(ss => {
@ -358,7 +349,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
const onMouseUp = (e: {target: any, pageX: number, pageY: number}) => { const onMouseUp = (e: {target: any, pageX: number, pageY: number}) => {
if (dragging) { if (dragging) {
setDragging(null); setDragging(false);
// do not persist sizes smaller than 40x40 // do not persist sizes smaller than 40x40
setState(state => { setState(state => {
return { return {
@ -416,14 +407,14 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
setSelectingState(null); // no longer making a selection setSelectingState(null); // no longer making a selection
}; };
function deleteShapes(selection: Selection) { function deleteSelection() {
setState(state => ({ setState(state => ({
...state, ...state,
rountangles: state.rountangles.filter(r => !selection.some(rs => rs.uid === r.uid)), rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
diamonds: state.diamonds.filter(d => !selection.some(ds => ds.uid === d.uid)), diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
history: state.history.filter(h => !selection.some(hs => hs.uid === h.uid)), history: state.history.filter(h => !state.selection.some(hs => hs.uid === h.uid)),
arrows: state.arrows.filter(a => !selection.some(as => as.uid === a.uid)), arrows: state.arrows.filter(a => !state.selection.some(as => as.uid === a.uid)),
texts: state.texts.filter(t => !selection.some(ts => ts.uid === t.uid)), texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [], selection: [],
})); }));
} }
@ -431,30 +422,22 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Delete") { if (e.key === "Delete") {
// delete selection // delete selection
if (selection.length > 0) { makeCheckPoint();
makeCheckPoint(); deleteSelection();
deleteShapes(selection);
}
} }
if (e.key === "o") { if (e.key === "o") {
// selected states become OR-states // selected states become OR-states
setSelection(selection => { setState(state => ({
setState(state => ({ ...state,
...state, rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r), }));
}));
return selection;
})
} }
if (e.key === "a") { if (e.key === "a") {
// selected states become AND-states // selected states become AND-states
setSelection(selection => { setState(state => ({
setState(state => ({ ...state,
...state, rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r), }));
}));
return selection;
});
} }
// if (e.key === "p") { // if (e.key === "p") {
// // selected states become pseudo-states // // selected states become pseudo-states
@ -469,13 +452,16 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
if (e.ctrlKey) { if (e.ctrlKey) {
if (e.key === "a") { if (e.key === "a") {
e.preventDefault(); e.preventDefault();
setDragging(null); setDragging(false);
// @ts-ignore setState(state => ({
setSelection([ ...state,
// @ts-ignore
selection: [
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})), ...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})), ...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})), ...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
]); ]
}))
} }
} }
}; };
@ -630,7 +616,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
function onCut(e: ClipboardEvent) { function onCut(e: ClipboardEvent) {
if (selection.length > 0) { if (selection.length > 0) {
copyInternal(selection, e); copyInternal(selection, e);
deleteShapes(selection); deleteSelection();
e.preventDefault(); e.preventDefault();
} }
} }
@ -666,16 +652,13 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
return <svg width="4000px" height="4000px" return <svg width="4000px" height="4000px"
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging!==null?" dragging":"")} className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}
ref={refSVG} ref={refSVG}
// @ts-ignore
onCopy={onCopy} onCopy={onCopy}
// @ts-ignore
onPaste={onPaste} onPaste={onPaste}
// @ts-ignore
onCut={onCut} onCut={onCut}
> >
<defs> <defs>

View file

@ -32,7 +32,7 @@ export type RountanglePart = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end"; export type ArrowPart = "start" | "end";
export const emptyState: VisualEditorState = { export const emptyState: VisualEditorState = {
rountangles: [], texts: [], arrows: [], diamonds: [], history: [], nextID: 0, rountangles: [], texts: [], arrows: [], diamonds: [], history: [], nextID: 0, selection: [],
}; };
// used to find which rountangle an arrow connects to (src/tgt) // used to find which rountangle an arrow connects to (src/tgt)