feat(editor): F4.6a simulation mode and a computer's desktop

Java's mode buttons start and stop the room, now in a worker of the
editor's page (LocalSimHost), with Java's speed slider. While the
simulation runs the design is locked as in Java: no palette, no sheet,
nothing moves, no undo. Cables that carry a frame light up in Java's
green, and a tap on a computer opens its desktop: Java's title, the
software installation first, then the installed programs in the order
of Java's list for the language, a taskbar with the network icon and
the network as the simulation runs it. The room sends that over a new
topic, node:<id>. Desktops stay over a return to the design and show
again at the next start. The programs' windows follow from F4.6b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:41:41 +00:00
co-authored by Claude Opus 5.5
parent 6e97e3565d
commit dfd5ec03ce
44 changed files with 1190 additions and 41 deletions
+122 -20
View File
@@ -1,6 +1,10 @@
// The editor on a document: the palette, the surface, the tools of the design
// mode (F4.4b), the device sheet of the selected device (F4.5). The
// simulation follows (F4.6).
// mode (F4.4b), the device sheet of the selected device (F4.5), Java's mode
// buttons and the simulation (F4.6): while it runs, the design is locked as
// in Java — no palette, no sheet, nothing moves — the cables that carry a
// frame light up, and a tap on a computer opens its desktop. Desktops left
// open stay over a return to the design and show again at the next start,
// as Java's do (GUIContainer.closeDialogs, reopenDialogs).
//
// Every change goes through packages/doc's commands with this editor's own
// origin, so that its undo takes back what this person did and nothing of
@@ -15,7 +19,10 @@ import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow } from './desktop/DesktopWindow.js';
import { useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { useSimulation, type Simulation, type SimHostFactory } from './simulation/useSimulation.js';
/** The document's drawing, read anew after every change. */
export function useTopologyView(doc: Y.Doc): TopologyView {
@@ -66,9 +73,33 @@ const toolButton: CSSProperties = {
/** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
export function Editor({ doc }: { readonly doc: Y.Doc }) {
/** A desktop open on the surface: whose, where its window lies, and how far up (the last one touched on top). */
interface OpenDesktop {
readonly id: string;
readonly x: number;
readonly y: number;
readonly z: number;
}
/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */
const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => {
const top = Math.max(0, ...open.map((each) => each.z));
return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each));
};
/** A desktop that watches its computer's network while it is shown. */
function WatchedDesktop({ simulation, ...props }: Omit<Parameters<typeof DesktopWindow>[0], 'settings'> & { readonly simulation: Simulation }) {
const { watch } = simulation;
useEffect(() => watch([`node:${props.id}`]), [watch, props.id]);
return <DesktopWindow {...props} settings={simulation.nodes.get(props.id)} />;
}
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
const t = useTranslate();
const view = useTopologyView(doc);
const simulation = useSimulation(doc, createSimHost);
const simulating = simulation.mode === 'simulation';
const [desktops, setDesktops] = useState<readonly OpenDesktop[]>([]);
const origin = useMemo(() => ({ editor: crypto.randomUUID() }), []);
const { manager, canUndo, canRedo } = useUndo(doc, origin);
const canvas = useRef<CanvasHandle>(null);
@@ -88,6 +119,37 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
return () => clearTimeout(timer);
}, [message]);
// The simulation locks the design: what was chosen or begun is let go.
useEffect(() => {
if (!simulating) return;
setSelection(null);
setCableTool(false);
setCableFrom(null);
setMenu(null);
setRouterAt(null);
}, [simulating]);
useEffect(() => {
if (simulation.failed) setMessage(t('sim.failed'));
// The message is shown once per failure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [simulation.failed]);
// A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs).
useEffect(() => {
const computers = new Set(view.nodes.filter((node) => node.kind === 'pc' || node.kind === 'notebook').map((node) => node.id));
setDesktops((open) => (open.every((each) => computers.has(each.id)) ? open : open.filter((each) => computers.has(each.id))));
}, [view]);
/** Opens a computer's desktop, or brings it to the front. */
const openDesktop = useCallback((id: string) => {
setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on.
return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1 }];
});
}, []);
// What is selected may be gone (removed here or by someone else).
useEffect(() => {
if (selection?.kind === 'device' && !view.nodes.some((node) => node.id === selection.id)) setSelection(null);
@@ -137,6 +199,11 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
);
const menuItems = (target: Selection): MenuItem[] => {
if (simulating) {
// Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7).
const node = view.nodes.find((each) => each.id === target.id);
return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
}
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
const node = view.nodes.find((each) => each.id === target.id);
const cables = view.links.some((link) => link.sourceNode === target.id || link.targetNode === target.id);
@@ -161,7 +228,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
// The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (typing(event.target)) return;
if (typing(event.target) || simulating) return;
const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === 'z') {
event.preventDefault();
@@ -180,18 +247,20 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [manager, selection, menu, routerAt, remove]);
}, [manager, selection, menu, routerAt, remove, simulating]);
return (
<div style={{ display: 'flex', height: '100%' }}>
<Palette
cableTool={cableTool}
onCableTool={(on) => {
setCableTool(on);
setCableFrom(null);
}}
onDevice={onDevice}
/>
{simulating ? null : (
<Palette
cableTool={cableTool}
onCableTool={(on) => {
setCableTool(on);
setCableFrom(null);
}}
onDevice={onDevice}
/>
)}
<div className="editor-work">
<div className="editor-area">
<Canvas
@@ -206,19 +275,51 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
onCableStart={setCableFrom}
onCable={onCable}
highlightedLink={highlightedLink}
mode={simulation.mode}
activeLinks={simulation.activeLinks}
onActivate={(id) => {
const kind = view.nodes.find((node) => node.id === id)?.kind;
if (kind === 'pc' || kind === 'notebook') openDesktop(id);
}}
onMenu={(target, at) => {
if (simulating) {
if (menuItems(target).length > 0) setMenu({ target, at });
return;
}
setSelection(target);
setMenu({ target, at });
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
</button>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, zIndex: 50, display: 'flex', gap: 6, alignItems: 'center' }}>
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start() : simulation.stop())} onSpeed={simulation.setSpeed} />
{simulating ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
</button>
</>
)}
</div>
{simulating
? desktops.map((desktop) => (
<WatchedDesktop
key={desktop.id}
simulation={simulation}
doc={doc}
id={desktop.id}
active={view.links.some((link) => (link.sourceNode === desktop.id || link.targetNode === desktop.id) && simulation.activeLinks.has(link.id))}
x={desktop.x}
y={desktop.y}
z={desktop.z}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
))
: null}
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
<RouterDialog
@@ -233,6 +334,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
role="alert"
style={{
position: 'absolute',
zIndex: 60,
left: '50%',
bottom: 72,
transform: 'translateX(-50%)',