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
@@ -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));
}