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