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%)',
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+23 -6
View File
@@ -56,8 +56,19 @@ export interface CanvasProps {
readonly onMenu?: (target: Selection, at: Point) => void;
/** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */
readonly highlightedLink?: string | null;
/**
* The simulation: a tap on a device activates it (a computer's desktop),
* a long press opens its menu; nothing moves, nothing is selected, as in
* Java's simulation mode.
*/
readonly mode?: 'design' | 'simulation';
/** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */
readonly activeLinks?: ReadonlySet<string>;
readonly onActivate?: (id: string) => void;
}
const NO_LINKS: ReadonlySet<string> = new Set();
/** The label's width in Java's font, measured once per text. */
function useTextWidth(): (text: string) => number {
const cache = useRef(new Map<string, number>());
@@ -141,7 +152,7 @@ type Gesture =
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null },
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null, mode = 'design', activeLinks = NO_LINKS, onActivate },
handle,
) {
const t = useTranslate();
@@ -222,6 +233,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
[kinds, t],
);
const simulating = mode === 'simulation';
const pointers = useRef(new Map<number, Point>());
const gesture = useRef<Gesture | null>(null);
const longPress = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -258,7 +270,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else if (linkId !== undefined && linkId !== null) {
} else if (linkId !== undefined && linkId !== null && !simulating) {
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
@@ -302,7 +314,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step));
} else if (now.kind === 'device' && !now.longPressed) {
} else if (now.kind === 'device' && !now.longPressed && !simulating) {
if (tool === 'cable') {
const from = boxes.get(now.id)!.center;
const target = deviceAt(point);
@@ -328,7 +340,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
return;
}
if (now.kind === 'background') {
if (!now.moved) {
if (!now.moved && !simulating) {
onSelect?.(null);
if (cableFrom !== null) onCableStart?.(null);
}
@@ -339,6 +351,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
return;
}
if (now.longPressed) return;
if (simulating) {
if (!now.moved) onActivate?.(now.id);
return;
}
if (tool === 'cable') {
setBand(null);
const from = { id: now.id, side: now.side };
@@ -399,6 +415,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
ref={surface}
className="filius-canvas"
data-tool={tool}
data-mode={mode}
role="application"
aria-label={t('canvas.label')}
style={{ ...style, cursor: tool === 'cable' ? 'crosshair' : 'default' }}
@@ -421,7 +438,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (source === undefined || target === undefined) return null;
const d = cablePath(source.center, target.center);
const selected = selection?.kind === 'link' && selection.id === link.id;
const active = highlightedLink === link.id;
const active = simulating ? activeLinks.has(link.id) : highlightedLink === link.id;
return (
<g key={link.id}>
<path
@@ -463,7 +480,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
)}
</g>
</svg>
{menuButton === null || selection === null ? null : (
{menuButton === null || selection === null || simulating ? null : (
<button
type="button"
className="device-menu-button"
@@ -0,0 +1,141 @@
// A computer's desktop in the simulation, after Java's GUIDesktopWindow and
// GUIDesktopPanel: a window titled with the computer's label and address
// (the label alone where it is the address), Java's background, the
// software installation's icon first and then the installed programs in the
// order of Java's list, a grey taskbar with "home screen" in the middle and,
// on the right, the network icon — lit while the computer's cable carries a
// frame — that shows its network as it runs (NetworkInfoPanel). Java's panel
// has a "change" button under read-only fields, which writes back what they
// show; it is left out.
//
// On a wide screen the window lies over the surface and moves by its title
// bar, as Java's windows do; on a phone it fills the surface. The programs'
// windows follow (F4.6b on).
import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type * as Y from 'yjs';
import { desktopView } from '@mdg-filius/doc';
import type { NodeSettings } from '@mdg-filius/protocol';
import background from '../assets/desktop/hintergrundbild.png';
import networkOff from '../assets/desktop/netzwek_aus.png';
import networkOn from '../assets/desktop/netzwek_c.png';
import { ICON_URLS } from '../canvas/icons.js';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS } from './programs.js';
import './desktop.css';
export interface DesktopWindowProps {
readonly doc: Y.Doc;
readonly id: string;
/** The computer's network as the simulation runs it; undefined until the room has sent it. */
readonly settings: NodeSettings | undefined;
/** Whether its cable carried a frame at the last tick. */
readonly active: boolean;
/** The window's top left corner on the surface. */
readonly x: number;
readonly y: number;
/** Its place in the stack of desktops. */
readonly z: number;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
function useDesktopView(doc: Y.Doc, id: string) {
const [view, setView] = useState(() => desktopView(doc, id));
useEffect(() => {
setView(desktopView(doc, id));
const update = () => setView(desktopView(doc, id));
doc.on('update', update);
return () => doc.off('update', update);
}, [doc, id]);
return view;
}
export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFront, onClose }: DesktopWindowProps) {
const t = useTranslate();
const language = useLanguage();
const view = useDesktopView(doc, id);
const [info, setInfo] = useState(false);
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
if (view === undefined) return null;
const address = settings?.ipAddress ?? '';
const title = view.addressAsName ? view.label : `${view.label} - ${address}`;
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as Element).closest('button') !== null) return;
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (drag === null || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
drag.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
};
const onPointerUp = () => setDrag(null);
const rows: readonly [string, string][] = [
[t('network.ipAddress'), address],
[t('network.subnetMask'), settings?.subnetMask ?? ''],
[t('network.dnsServer'), settings?.dnsServer ?? ''],
[t('network.gateway'), settings?.defaultGateway ?? ''],
[t('network.macAddress'), settings?.macAddress ?? ''],
];
return (
<section className="desktop-window" role="dialog" aria-label={title} data-desktop={id} style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<img src={ICON_URLS[view.kind === 'notebook' ? 'laptop' : 'server']} alt="" />
<h2>{title}</h2>
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
</header>
<div className="desktop-screen" style={{ backgroundImage: `url(${background})` }}>
<ul className="desktop-icons">
<li>
{/* The programs' windows come with the next steps. */}
<button type="button" className="desktop-icon" aria-disabled="true" title={t('desktop.installation')}>
<img src={INSTALLATION_ICON} alt="" />
<span>{t('desktop.installation')}</span>
</button>
</li>
{desktopPrograms(view.programs, language).map((program) => (
<li key={program}>
<button type="button" className="desktop-icon" data-program={program} aria-disabled="true" title={t(`program.${program}`)}>
<img src={JAVA_PROGRAMS[program].icon} alt="" />
<span>{t(`program.${program}`)}</span>
</button>
</li>
))}
</ul>
{info ? (
// A press beside the panel closes it, as in Java.
<div className="desktop-info-layer" onPointerDown={(event) => event.target === event.currentTarget && setInfo(false)}>
<dl className="desktop-network" aria-label={t('desktop.network')}>
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
<footer className="desktop-taskbar">
<span />
<button type="button" className="desktop-home" onClick={() => setInfo(false)}>
{t('desktop.home')}
</button>
<button type="button" className="desktop-network-icon" data-active={active ? 'true' : undefined} aria-label={t('desktop.network')} aria-expanded={info} title={address} onClick={() => setInfo(!info)}>
<img src={active ? networkOn : networkOff} alt="" />
</button>
</footer>
</section>
);
}
+185
View File
@@ -0,0 +1,185 @@
/* A computer's desktop after Java's GUIDesktopPanel: 640 × 460 pixels below
* the title bar, a grey taskbar at the bottom (180, 180, 180; 44 pixels high
* for a finger, Java's has 30), icons of 120 × 96 with white names on Java's
* background. On a phone it fills the surface. Java's colours stay in both
* themes: the screen is the simulated computer's, not the editor's. */
.desktop-window {
position: absolute;
z-index: 5;
display: flex;
flex-direction: column;
width: 640px;
max-width: 100%;
height: calc(460px + 14px + 36px);
max-height: 100%;
box-sizing: border-box;
border: 1px solid var(--border-strong);
border-radius: 8px;
overflow: hidden;
background: var(--surface-card);
box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-color) 25%, transparent);
}
/* Below the mode buttons, which stay in reach. */
@media (max-width: 639px) {
.desktop-window {
inset: 72px 0 0 0 !important;
width: auto;
height: auto;
border-radius: 0;
}
}
.desktop-titlebar {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 0 0 8px;
background: var(--surface-raised);
border-bottom: 1px solid var(--border-default);
cursor: move;
touch-action: none;
user-select: none;
}
.desktop-titlebar img {
width: 24px;
height: 24px;
object-fit: contain;
}
.desktop-titlebar h2 {
flex: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-primary);
}
.desktop-close {
min-width: 44px;
min-height: 36px;
border: none;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 22px;
cursor: pointer;
}
.desktop-screen {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
background-size: cover;
background-position: top left;
}
.desktop-icons {
display: flex;
flex-wrap: wrap;
gap: 10px 5px;
margin: 0;
padding: 10px 5px;
list-style: none;
}
.desktop-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 120px;
height: 96px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: #ffffff;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.desktop-icon[aria-disabled='true'] {
cursor: default;
}
.desktop-icon:focus-visible {
outline: 2px solid #ffffff;
}
.desktop-icon img {
width: 64px;
height: 64px;
}
.desktop-info-layer {
position: absolute;
inset: 0;
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
.desktop-network {
margin: 0;
padding: 8px 12px;
border: 2px inset #c0c0c0;
background: #eeeeee;
color: #000000;
font: 13px Dialog, sans-serif;
}
.desktop-network div {
display: grid;
grid-template-columns: 110px 140px;
align-items: center;
min-height: 26px;
}
.desktop-network dt,
.desktop-network dd {
margin: 0;
}
.desktop-network dd {
padding: 2px 6px;
background: #ffffff;
border: 1px solid #b0b0b0;
font-variant-numeric: tabular-nums;
user-select: text;
}
.desktop-taskbar {
display: grid;
grid-template-columns: 52px 1fr 52px;
align-items: stretch;
height: 44px;
flex: none;
background: rgb(180 180 180);
}
.desktop-home,
.desktop-network-icon {
border: none;
background: transparent;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.desktop-home {
justify-self: center;
padding: 0 12px;
}
.desktop-network-icon img {
width: 18px;
height: 18px;
}
+56
View File
@@ -0,0 +1,56 @@
// The programs of a computer's desktop, after Java's list of them
// (config/Desktop_<language>.txt: name, class, window, icon), which also sets
// their order there — each language's own (the English list is alphabetical,
// the German one is not). The DHCP server has no window on the desktop; it is
// set up in the device sheet, as in Java.
import type { InstallableProgram } from '@mdg-filius/core';
import type { Language } from '../i18n/i18n.js';
import browser from '../assets/desktop/icon_browser.png';
import simpleClient from '../assets/desktop/icon_clientbaustein.png';
import dns from '../assets/desktop/icon_dns.png';
import emailClient from '../assets/desktop/icon_emailprogramm.png';
import emailServer from '../assets/desktop/icon_emailserver.png';
import fileExplorer from '../assets/desktop/icon_filebrowser.png';
import firewall from '../assets/desktop/icon_firewall.png';
import imageViewer from '../assets/desktop/icon_imageviewer.png';
import gnutella from '../assets/desktop/icon_peertopeer.png';
import echoServer from '../assets/desktop/icon_serverbaustein.png';
import installation from '../assets/desktop/icon_softwareinstallation.png';
import terminal from '../assets/desktop/icon_terminal.png';
import textEditor from '../assets/desktop/icon_texteditor.png';
import webServer from '../assets/desktop/icon_webserver.png';
export type DesktopProgram = Exclude<InstallableProgram, 'dhcpServer'>;
/** Each program's class in Java and its icon (gfx/desktop). */
export const JAVA_PROGRAMS: Readonly<Record<DesktopProgram, { readonly javaClass: string; readonly icon: string; readonly iconFile: string }>> = {
commandPrompt: { javaClass: 'filius.software.lokal.Terminal', icon: terminal, iconFile: 'icon_terminal.png' },
imageViewer: { javaClass: 'filius.software.lokal.ImageViewer', icon: imageViewer, iconFile: 'icon_imageviewer.png' },
fileExplorer: { javaClass: 'filius.software.lokal.FileExplorer', icon: fileExplorer, iconFile: 'icon_filebrowser.png' },
dnsServer: { javaClass: 'filius.software.dns.DNSServer', icon: dns, iconFile: 'icon_dns.png' },
emailClient: { javaClass: 'filius.software.email.EmailAnwendung', icon: emailClient, iconFile: 'icon_emailprogramm.png' },
emailServer: { javaClass: 'filius.software.email.EmailServer', icon: emailServer, iconFile: 'icon_emailserver.png' },
echoServer: { javaClass: 'filius.software.clientserver.ServerBaustein', icon: echoServer, iconFile: 'icon_serverbaustein.png' },
simpleClient: { javaClass: 'filius.software.clientserver.ClientBaustein', icon: simpleClient, iconFile: 'icon_clientbaustein.png' },
firewall: { javaClass: 'filius.software.firewall.Firewall', icon: firewall, iconFile: 'icon_firewall.png' },
gnutella: { javaClass: 'filius.software.dateiaustausch.PeerToPeerAnwendung', icon: gnutella, iconFile: 'icon_peertopeer.png' },
textEditor: { javaClass: 'filius.software.lokal.TextEditor', icon: textEditor, iconFile: 'icon_texteditor.png' },
webServer: { javaClass: 'filius.software.www.WebServer', icon: webServer, iconFile: 'icon_webserver.png' },
webBrowser: { javaClass: 'filius.software.www.WebBrowser', icon: browser, iconFile: 'icon_browser.png' },
};
/** The software installation's icon, always first on the desktop (GUIDesktopPanel.initInstaller). */
export const INSTALLATION_ICON = installation;
/** The order of Java's list in each language. */
export const DESKTOP_ORDER: Readonly<Record<Language, readonly DesktopProgram[]>> = {
de: ['commandPrompt', 'imageViewer', 'fileExplorer', 'dnsServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'firewall', 'gnutella', 'textEditor', 'webServer', 'webBrowser'],
en: ['commandPrompt', 'dnsServer', 'echoServer', 'emailServer', 'emailClient', 'fileExplorer', 'firewall', 'simpleClient', 'gnutella', 'imageViewer', 'textEditor', 'webBrowser', 'webServer'],
};
/** A computer's programs as its desktop shows them, in the language's order. */
export function desktopPrograms(installed: readonly InstallableProgram[], language: Language): DesktopProgram[] {
const have = new Set(installed);
return DESKTOP_ORDER[language].filter((program) => have.has(program));
}
+27
View File
@@ -22,6 +22,9 @@
"design.palette": "Komponenten",
"design.redo": "Wiederholen",
"design.undo": "Rückgängig",
"desktop.home": "Startbildschirm",
"desktop.installation": "Software-Installation",
"desktop.network": "Netzwerk",
"dev.example": "Beispiel",
"dev.newProject": "Neues Projekt",
"dev.openFile": "Datei öffnen …",
@@ -79,6 +82,29 @@
"menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen",
"menu.showDesktop": "Desktop anzeigen",
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
"mode.design": "Wechselt in den Entwurfsmodus",
"mode.simulation": "Wechselt in den Aktionsmodus",
"mode.speed": "Simulationsgeschwindigkeit anpassen",
"network.dnsServer": "DNS-Server:",
"network.gateway": "Gateway:",
"network.ipAddress": "IP-Adresse:",
"network.macAddress": "MAC-Adresse:",
"network.subnetMask": "Netzmaske:",
"program.commandPrompt": "Befehlszeile",
"program.dnsServer": "DNS-Server",
"program.echoServer": "Echo-Server",
"program.emailClient": "E-Mail-Programm",
"program.emailServer": "E-Mail-Server",
"program.fileExplorer": "Datei-Explorer",
"program.firewall": "Firewall",
"program.gnutella": "Gnutella",
"program.imageViewer": "Bildbetrachter",
"program.simpleClient": "Einfacher Client",
"program.textEditor": "Text-Editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"route.destinationNetwork": "Ziel",
"route.gateway": "Nächstes Gateway",
"route.interfaceIPAddress": "Über Schnittstelle",
@@ -127,6 +153,7 @@
"sheet.switchSsid": "Name WLAN (SSID)",
"sheet.title": "Einstellungen",
"sheet.wireless": "Drahtlos (WLAN)",
"sim.failed": "Die Simulation ist auf einen Fehler gestoßen und wurde beendet.",
"zoom.fit": "Alles zeigen",
"zoom.in": "Vergrößern",
"zoom.level": "{percent} %",
+27
View File
@@ -22,6 +22,9 @@
"design.palette": "Components",
"design.redo": "Redo",
"design.undo": "Undo",
"desktop.home": "Home Screen",
"desktop.installation": "Software Installation",
"desktop.network": "Network",
"dev.example": "Example",
"dev.newProject": "New project",
"dev.openFile": "Open file …",
@@ -79,6 +82,29 @@
"menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable",
"menu.showDesktop": "Show desktop",
"mode.currentSpeed": "Current Simulation Velocity",
"mode.design": "Change to Design Mode",
"mode.simulation": "Change to Simulation Mode",
"mode.speed": "Adjust Simulation Velocity",
"network.dnsServer": "DNS server:",
"network.gateway": "Gateway:",
"network.ipAddress": "IP address:",
"network.macAddress": "MAC address:",
"network.subnetMask": "Netmask:",
"program.commandPrompt": "Command Line",
"program.dnsServer": "DNS server",
"program.echoServer": "Echo server",
"program.emailClient": "Email program",
"program.emailServer": "Email server",
"program.fileExplorer": "File explorer",
"program.firewall": "Firewall",
"program.gnutella": "Gnutella",
"program.imageViewer": "Image viewer",
"program.simpleClient": "Generic client",
"program.textEditor": "Text editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"route.destinationNetwork": "Destination",
"route.gateway": "Next gateway",
"route.interfaceIPAddress": "NIC",
@@ -127,6 +153,7 @@
"sheet.switchSsid": "Wi-Fi Name (SSID)",
"sheet.title": "Settings",
"sheet.wireless": "Wireless (Wi-Fi)",
"sim.failed": "The simulation ran into an error and was stopped.",
"zoom.fit": "Show all",
"zoom.in": "Zoom in",
"zoom.level": "{percent} %",
+4
View File
@@ -24,6 +24,10 @@ export function LanguageProvider({ language, children }: { readonly language: La
return <LanguageContext.Provider value={language}>{children}</LanguageContext.Provider>;
}
export function useLanguage(): Language {
return useContext(LanguageContext);
}
export function useTranslate(): Translate {
const language = useContext(LanguageContext);
return (key, values) => translate(language, key, values);
+2 -2
View File
@@ -7,7 +7,7 @@
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import * as Y from 'yjs';
import { deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
import { desktopView, deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js';
@@ -62,7 +62,7 @@ function App() {
// The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard).
useEffect(() => {
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id) };
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id), desktop: (id: string) => desktopView(doc, id) };
}, [doc]);
const t = (key: string) => translate(language, key);
@@ -0,0 +1,44 @@
// Java's mode buttons (GUIMainMenu: design and simulation mode, with Java's
// icons) and, while the simulation runs, its speed slider with the per cent
// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
// a browser they bookmark and reload the page.
import type { CSSProperties } from 'react';
import type { SimMode } from '@mdg-filius/protocol';
import simulation from '../assets/modes/aktionsmodus.png';
import simulationOn from '../assets/modes/aktionsmodus_aktiv.png';
import design from '../assets/modes/entwurfsmodus.png';
import designOn from '../assets/modes/entwurfsmodus_aktiv.png';
import { useTranslate } from '../i18n/i18n.js';
const modeButton: CSSProperties = {
width: 52,
height: 52,
padding: 0,
border: '1px solid var(--border-default)',
borderRadius: 8,
background: 'var(--surface-raised)',
cursor: 'pointer',
};
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: SimMode; readonly speed: number; readonly onMode: (mode: SimMode) => void; readonly onSpeed: (value: number) => void }) {
const t = useTranslate();
return (
<>
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} onClick={() => onMode('design')}>
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
</button>
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} onClick={() => onMode('simulation')}>
<img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
</button>
{mode === 'simulation' ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', minHeight: 52, borderRadius: 8, background: 'var(--surface-raised)', border: '1px solid var(--border-default)' }}>
<input type="range" name="speed" min={1} max={100} value={speed} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
<span title={t('mode.currentSpeed')} style={{ minWidth: 40, fontVariantNumeric: 'tabular-nums' }}>
{speed}%
</span>
</label>
) : null}
</>
);
}
@@ -0,0 +1,105 @@
// The editor's side of the simulation room (plan §6): its mode and speed,
// the cables that carry frames, a computer's network as it runs. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
/** Makes the room of a document. */
export type SimHostFactory = (doc: Y.Doc) => SimHost;
/** The room in a worker of this page. */
export const localSimHost: SimHostFactory = (doc) => {
const worker = new SimWorker();
return new LocalSimHost(doc, worker, () => worker.terminate());
};
export interface Simulation {
readonly mode: SimMode;
/** Java's slider, 1 to 100 per cent. */
readonly speed: number;
/** The cables that carried a frame at the last tick. */
readonly activeLinks: ReadonlySet<string>;
/** Each watched computer's network as the simulation runs it. */
readonly nodes: ReadonlyMap<string, NodeSettings>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
start(): void;
stop(): void;
setSpeed(value: number): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
watch(topics: readonly Topic[]): () => void;
}
const NO_LINKS: ReadonlySet<string> = new Set();
export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimHost): Simulation {
const host = useRef<SimHost | null>(null);
const watched = useRef(new Map<Topic, number>());
const [mode, setMode] = useState<SimMode>('design');
const [speed, setSpeedState] = useState(100);
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false);
useEffect(() => {
const made = createHost(doc);
host.current = made;
const stop = made.onMessage((message) => {
switch (message.kind) {
case 'state':
setMode(message.mode);
setSpeedState(message.speed);
if (message.mode === 'design') setActiveLinks(NO_LINKS);
return;
case 'activity':
setActiveLinks(new Set(message.links));
return;
case 'node':
setNodes((before) => new Map(before).set(message.node, message.settings));
return;
case 'error':
if (message.code === 'engineFailed') setFailed(true);
return;
default:
return;
}
});
made.send({ v: 1, kind: 'sub', topics: ['activity', ...watched.current.keys()] });
return () => {
stop();
made.close();
host.current = null;
};
}, [doc, createHost]);
const start = useCallback(() => {
setFailed(false);
host.current?.send({ v: 1, kind: 'start' });
}, []);
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
const watch = useCallback((topics: readonly Topic[]) => {
const fresh = topics.filter((topic) => {
const count = watched.current.get(topic) ?? 0;
watched.current.set(topic, count + 1);
return count === 0;
});
if (fresh.length > 0) host.current?.send({ v: 1, kind: 'sub', topics: fresh });
return () => {
const gone = topics.filter((topic) => {
const count = (watched.current.get(topic) ?? 1) - 1;
if (count > 0) watched.current.set(topic, count);
else watched.current.delete(topic);
return count <= 0;
});
if (gone.length > 0) host.current?.send({ v: 1, kind: 'unsub', topics: gone });
};
}, []);
return { mode, speed, activeLinks, nodes, failed, start, stop, setSpeed, watch };
}