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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user