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>
@@ -4,7 +4,7 @@
|
||||
// so that it can run on every change while someone drags a device.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core';
|
||||
import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type InstallableProgram, type NodeKind } from '@mdg-filius/core';
|
||||
import { inOrder, ROOTS } from './projectDoc.js';
|
||||
|
||||
export interface NodeView {
|
||||
@@ -115,3 +115,28 @@ export function topologyView(doc: Y.Doc): TopologyView {
|
||||
docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id));
|
||||
return { nodes, links, docs };
|
||||
}
|
||||
|
||||
/** What a computer's desktop needs of the document (Java's GUIDesktopWindow, GUIDesktopPanel). */
|
||||
export interface DesktopView {
|
||||
readonly kind: 'pc' | 'notebook';
|
||||
/** Its label (Host.holeAnzeigeName). */
|
||||
readonly label: string;
|
||||
/** Whether the label is its address: Java's title then leaves the address out (isUseIPAsName). */
|
||||
readonly addressAsName: boolean;
|
||||
readonly programs: readonly InstallableProgram[];
|
||||
}
|
||||
|
||||
/** A PC's or notebook's desktop; undefined for any other device. */
|
||||
export function desktopView(doc: Y.Doc, id: string): DesktopView | undefined {
|
||||
const node = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id);
|
||||
const kind = node?.get('kind') as NodeKind | undefined;
|
||||
if (node === undefined || (kind !== 'pc' && kind !== 'notebook')) return undefined;
|
||||
const policy = node.get('labelPolicy') as HostLabelPolicy;
|
||||
return {
|
||||
kind,
|
||||
label: nodeLabel(node),
|
||||
addressAsName: policy === 'ipAddress' || policy === 'ipAndMAC',
|
||||
programs: [...(node.get('programs') as Y.Map<unknown>).keys()] as InstallableProgram[],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
import { stableGeneratedMacAddress } from '@mdg-filius/core';
|
||||
import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
|
||||
import { readProject, topologyView, writeProject } from '../src/index.js';
|
||||
import { addDevice, desktopView, readProject, setHostLabels, topologyView, writeProject } from '../src/index.js';
|
||||
|
||||
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
|
||||
const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) =>
|
||||
@@ -70,3 +70,38 @@ describe('the drawing of a document', () => {
|
||||
expect(switchLabel).toBe('Switch 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a computer's desktop", () => {
|
||||
it('names its programs and its label, and whether the label is its address, for computers only', () => {
|
||||
for (const file of files) {
|
||||
const doc = docOf(file);
|
||||
const project = readProject(doc);
|
||||
for (const node of project.graph.nodes) {
|
||||
const desktop = desktopView(doc, node.id);
|
||||
if (node.kind !== 'pc' && node.kind !== 'notebook') {
|
||||
expect(desktop, `${file} ${node.displayName}`).toBeUndefined();
|
||||
continue;
|
||||
}
|
||||
expect(new Set(desktop!.programs), `${file} ${node.displayName}`).toEqual(project.installedPrograms.get(node.id) ?? new Set());
|
||||
expect(desktop!.kind).toBe(node.kind);
|
||||
expect(desktop!.label).toBe(topologyView(doc).nodes.find((each) => each.id === node.id)!.label);
|
||||
expect(desktop!.addressAsName).toBe(node.hostLabelPolicy === 'ipAddress' || node.hostLabelPolicy === 'ipAndMAC');
|
||||
}
|
||||
}
|
||||
expect(desktopView(docOf(files[0]!), 'nobody')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes a label of address and MAC address for the address, as Java's title does (isUseIPAsName), the MAC address alone not", () => {
|
||||
const doc = new Y.Doc();
|
||||
let counter = 0;
|
||||
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
|
||||
expect(desktopView(doc, 'pc')).toMatchObject({ kind: 'pc', label: 'PC', addressAsName: false, programs: [] });
|
||||
setHostLabels(doc, 'pc', { ipAddress: true, macAddress: true }, 'me');
|
||||
expect(desktopView(doc, 'pc')!.addressAsName).toBe(true);
|
||||
setHostLabels(doc, 'pc', { ipAddress: false, macAddress: true }, 'me');
|
||||
expect(desktopView(doc, 'pc')!.addressAsName).toBe(false);
|
||||
setHostLabels(doc, 'pc', { ipAddress: true, macAddress: false }, 'me');
|
||||
expect(desktopView(doc, 'pc')).toMatchObject({ label: '192.168.0.10', addressAsName: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { build, preview, type PreviewServer } from 'vite';
|
||||
import type { DeviceSheet, TopologyView } from '@mdg-filius/doc';
|
||||
import type { DesktopView, DeviceSheet, TopologyView } from '@mdg-filius/doc';
|
||||
|
||||
const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
|
||||
|
||||
@@ -35,6 +35,11 @@ export function sheetOf(page: Page, id: string): Promise<DeviceSheet | undefined
|
||||
return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id);
|
||||
}
|
||||
|
||||
/** What a computer's desktop reads of the document. */
|
||||
export function desktopOf(page: Page, id: string): Promise<DesktopView | undefined> {
|
||||
return page.evaluate((each) => (window as unknown as { filiusDev: { desktop: (id: string) => DesktopView | undefined } }).filiusDev.desktop(each), id);
|
||||
}
|
||||
|
||||
/** A device's button as data-box says it (surface pixels). */
|
||||
export async function boxOf(page: Page, id: string): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const [x, y, width, height] = (await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'))!.split(' ').map(Number) as [number, number, number, number];
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// The simulation in Chromium (F4.6a): Java's mode buttons, the design locked
|
||||
// while it runs, the speed, the cables that carry frames, a computer's
|
||||
// desktop opened with a tap — Java's title, its programs in Java's order, its
|
||||
// network as it runs — and desktops kept over a return to the design.
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { desktopOf, drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = await serveEditor();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function deviceNamed(page: Page, name: string): Promise<string> {
|
||||
return (await drawing(page)).nodes.find((node) => node.name === name)!.id;
|
||||
}
|
||||
|
||||
async function tap(page: Page, id: string): Promise<void> {
|
||||
const at = await onDevice(page, id);
|
||||
await page.mouse.click(at.x, at.y);
|
||||
}
|
||||
|
||||
const simulationButton = (page: Page) => page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' });
|
||||
const designButton = (page: Page) => page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' });
|
||||
const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`);
|
||||
|
||||
/** Starts the simulation and waits until the room says it runs (it answers from its worker). */
|
||||
async function start(page: Page): Promise<void> {
|
||||
await simulationButton(page).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
}
|
||||
|
||||
test('locks the design while the simulation runs, as Java does, and gives it back', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const router = (await drawing(page)).nodes.find((node) => node.kind === 'router')!;
|
||||
await tap(page, router.id);
|
||||
await expect(page.getByRole('complementary', { name: 'Einstellungen' })).toBeVisible();
|
||||
await expect(designButton(page)).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await simulationButton(page).click();
|
||||
await expect(simulationButton(page)).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
await expect(page.getByRole('complementary', { name: 'Einstellungen' })).toHaveCount(0);
|
||||
await expect(page.locator('[data-palette]')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Rückgängig' })).toHaveCount(0);
|
||||
// Java's slider: 90 per cent at first (Verbindung's delay factor 10).
|
||||
const speed = page.locator('input[name="speed"]');
|
||||
await expect(speed).toHaveValue('90');
|
||||
await expect(page.getByTitle('Aktuelle Simulationsgeschwindigkeit')).toHaveText('90%');
|
||||
await speed.fill('40');
|
||||
await expect(page.getByTitle('Aktuelle Simulationsgeschwindigkeit')).toHaveText('40%');
|
||||
|
||||
// Nothing moves, nothing is chosen, a key removes nothing.
|
||||
const at = await onDevice(page, router.id);
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(at.x + 80, at.y + 60, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.keyboard.press('Delete');
|
||||
const still = (await drawing(page)).nodes.find((node) => node.id === router.id)!;
|
||||
expect([still.x, still.y]).toEqual([router.x, router.y]);
|
||||
await expect(page.locator('g.device[data-selected]')).toHaveCount(0);
|
||||
|
||||
await designButton(page).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
|
||||
await expect(page.locator('[data-palette]').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Rückgängig' })).toBeVisible();
|
||||
await expect(page.locator('input[name="speed"]')).toHaveCount(0);
|
||||
// The speed stays the room's for the next start.
|
||||
await simulationButton(page).click();
|
||||
await expect(page.locator('input[name="speed"]')).toHaveValue('40');
|
||||
});
|
||||
|
||||
test("lights a cable while it carries a frame, in Java's green", async ({ page }) => {
|
||||
// The notebooks ask for their addresses by DHCP as the simulation starts.
|
||||
await openEditor(page, server.base, 'einfaches_rechnernetz_komplett.fls');
|
||||
await simulationButton(page).click();
|
||||
await expect(page.locator('path[data-link][data-active="true"]').first()).toBeAttached({ timeout: 10_000 });
|
||||
await expect(page.locator('path[data-link][data-active="true"]').first()).toHaveCSS('stroke', 'rgb(0, 255, 64)');
|
||||
await designButton(page).click();
|
||||
await expect(page.locator('path[data-link][data-active]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opens a computer's desktop with a tap: Java's title, its programs in Java's order, its network as it runs", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const id = await deviceNamed(page, 'DNS-Server 2.10');
|
||||
expect((await desktopOf(page, id))!.programs).toHaveLength(3);
|
||||
await start(page);
|
||||
await tap(page, id);
|
||||
const window = desktop(page, id);
|
||||
await expect(window.getByRole('heading')).toHaveText('DNS-Server 2.10 - 141.99.2.10');
|
||||
await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Datei-Explorer', 'DNS-Server', 'Webserver']);
|
||||
await expect(window.getByRole('button', { name: 'Startbildschirm' })).toBeVisible();
|
||||
|
||||
await window.getByRole('button', { name: 'Netzwerk' }).click();
|
||||
const values = window.locator('.desktop-network dd');
|
||||
await expect(window.locator('.desktop-network dt')).toHaveText(['IP-Adresse:', 'Netzmaske:', 'DNS-Server:', 'Gateway:', 'MAC-Adresse:']);
|
||||
await expect(values.nth(0)).toHaveText('141.99.2.10');
|
||||
await expect(values.nth(4)).toHaveText(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
|
||||
// A press beside the panel closes it.
|
||||
await window.locator('.desktop-info-layer').click({ position: { x: 20, y: 20 } });
|
||||
await expect(window.locator('.desktop-network')).toHaveCount(0);
|
||||
|
||||
// A switch has no desktop; its long-press menu has nothing either.
|
||||
await tap(page, await deviceNamed(page, 'Switch 1'));
|
||||
await expect(page.locator('section[data-desktop]')).toHaveCount(1);
|
||||
await window.getByRole('button', { name: 'Schließen' }).click();
|
||||
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
|
||||
|
||||
// Java's menu: "show desktop".
|
||||
const at = await onDevice(page, await deviceNamed(page, 'Notebook 1.10'));
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(700);
|
||||
await page.mouse.up();
|
||||
await page.getByRole('menuitem', { name: 'Desktop anzeigen' }).click();
|
||||
await expect(page.locator('section[data-desktop] h2')).toHaveText('Notebook 1.10 - 141.99.1.10');
|
||||
await expect(page.locator('section[data-desktop] .desktop-icon span')).toHaveText(['Software-Installation', 'Webbrowser']);
|
||||
});
|
||||
|
||||
test('moves a desktop by its title bar, brings the one touched to the front, and keeps them over a return to the design', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const [first, second] = [await deviceNamed(page, 'Notebook 1.10'), await deviceNamed(page, 'Notebook 1.11')];
|
||||
await start(page);
|
||||
await tap(page, first);
|
||||
// Moved by its title bar into the half of the page the second notebook is not in.
|
||||
const at = await onDevice(page, second);
|
||||
const width = page.viewportSize()!.width;
|
||||
const bar = (await desktop(page, first).locator('.desktop-titlebar h2').boundingBox())!;
|
||||
const before = (await desktop(page, first).boundingBox())!;
|
||||
const step = { x: (at.x < width / 2 ? width - 650 : 0) - before.x, y: 30 };
|
||||
await page.mouse.move(bar.x + 10, bar.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bar.x + 10 + step.x, bar.y + 10 + step.y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
const after = (await desktop(page, first).boundingBox())!;
|
||||
expect([Math.round(after.x - before.x), Math.round(after.y - before.y)]).toEqual([Math.round(step.x), step.y]);
|
||||
|
||||
await tap(page, second);
|
||||
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
|
||||
const z = async (id: string) => Number(await desktop(page, id).evaluate((element) => getComputedStyle(element).zIndex));
|
||||
expect(await z(second)).toBeGreaterThan(await z(first));
|
||||
await desktop(page, first).locator('.desktop-titlebar h2').click();
|
||||
expect(await z(first)).toBeGreaterThan(await z(second));
|
||||
|
||||
await designButton(page).click();
|
||||
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
|
||||
await simulationButton(page).click();
|
||||
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
|
||||
expect(Math.round((await desktop(page, first).boundingBox())!.x)).toBe(Math.round(after.x));
|
||||
});
|
||||
|
||||
test('takes nothing back and keeps no design tool while the simulation runs', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const id = await deviceNamed(page, 'Notebook 1.10');
|
||||
const at = await onDevice(page, id);
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(at.x + 60, at.y + 40, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const moved = (await drawing(page)).nodes.find((node) => node.id === id)!;
|
||||
await page.locator('[data-palette="cable"]').click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-tool', 'cable');
|
||||
await start(page);
|
||||
await page.keyboard.press('Control+z');
|
||||
const still = (await drawing(page)).nodes.find((node) => node.id === id)!;
|
||||
expect([still.x, still.y]).toEqual([moved.x, moved.y]);
|
||||
await designButton(page).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-tool', 'select');
|
||||
// Back in the design, the step can be taken back.
|
||||
await page.getByRole('button', { name: 'Rückgängig' }).click();
|
||||
await expect.poll(async () => (await drawing(page)).nodes.find((node) => node.id === id)!.x).not.toBe(moved.x);
|
||||
});
|
||||
|
||||
test("titles a desktop with the address alone where the address is the computer's name, and lights its network icon while its cable carries a frame", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'einfaches_rechnernetz_komplett.fls');
|
||||
const id = await deviceNamed(page, 'Rechner 1');
|
||||
await tap(page, id);
|
||||
await page.getByRole('complementary', { name: 'Einstellungen' }).getByLabel('IP-Adresse als Name verwenden').check();
|
||||
await page.getByRole('complementary', { name: 'Einstellungen' }).getByRole('button', { name: 'Schließen' }).click();
|
||||
// The notebooks ask for their addresses by DHCP as the simulation starts; the switch passes their broadcasts on to Rechner 1.
|
||||
await start(page);
|
||||
await tap(page, id);
|
||||
const window = desktop(page, id);
|
||||
await expect(window.getByRole('heading')).toHaveText('192.168.0.11');
|
||||
await expect(window.locator('.desktop-network-icon[data-active="true"]')).toBeAttached({ timeout: 10_000 });
|
||||
await window.getByRole('button', { name: 'Netzwerk' }).click();
|
||||
await expect(window.locator('.desktop-network')).toBeVisible();
|
||||
await window.getByRole('button', { name: 'Startbildschirm' }).click();
|
||||
await expect(window.locator('.desktop-network')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("closes a desktop whose computer is removed while the design is shown", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const id = await deviceNamed(page, 'Notebook 1.10');
|
||||
await start(page);
|
||||
await tap(page, id);
|
||||
await designButton(page).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
|
||||
await tap(page, id);
|
||||
await page.keyboard.press('Delete');
|
||||
await simulationButton(page).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
|
||||
// The next desktop takes the first place: the gone one is not counted.
|
||||
const next = await deviceNamed(page, 'Notebook 1.11');
|
||||
await tap(page, next);
|
||||
const area = (await page.locator('.editor-area').boundingBox())!;
|
||||
const box = (await desktop(page, next).boundingBox())!;
|
||||
expect([Math.round(box.x - area.x), Math.round(box.y - area.y)]).toEqual([24, 76]);
|
||||
});
|
||||
|
||||
test.describe('on a phone', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
test('fills the surface with a desktop opened by a tap, below the mode buttons', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
await simulationButton(page).tap();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
await page.getByRole('button', { name: 'Alles zeigen' }).tap();
|
||||
const id = await deviceNamed(page, 'Notebook 1.10');
|
||||
const at = await onDevice(page, id);
|
||||
await page.touchscreen.tap(at.x, at.y);
|
||||
const window = desktop(page, id);
|
||||
await expect(window).toBeVisible();
|
||||
const box = (await window.boundingBox())!;
|
||||
const area = (await page.locator('.editor-area').boundingBox())!;
|
||||
const modes = (await simulationButton(page).boundingBox())!;
|
||||
expect(box.width).toBeCloseTo(area.width, 0);
|
||||
expect(box.y).toBeGreaterThanOrEqual(modes.y + modes.height);
|
||||
expect(box.y + box.height).toBeCloseTo(area.y + area.height, 0);
|
||||
const home = (await window.getByRole('button', { name: 'Startbildschirm' }).boundingBox())!;
|
||||
expect(home.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
|
||||
test('speaks English when asked', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls', '&lang=en');
|
||||
await page.getByRole('button', { name: 'Change to Simulation Mode' }).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
const id = await deviceNamed(page, 'DNS-Server 2.10');
|
||||
await tap(page, id);
|
||||
await expect(desktop(page, id).locator('.desktop-icon span')).toHaveText(['Software Installation', 'DNS server', 'File explorer', 'Webserver']);
|
||||
await expect(desktop(page, id).getByRole('button', { name: 'Home Screen' })).toBeVisible();
|
||||
});
|
||||
@@ -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%)',
|
||||
|
||||
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 600 B |
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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} %",
|
||||
|
||||
@@ -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} %",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -109,6 +109,16 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'connections.local': 'jvermittlungsrechnerkonfiguration_msg22',
|
||||
'connections.add': 'jvermittlungsrechnerkonfiguration_msg24',
|
||||
'connections.most': 'jvermittlungsrechnerkonfiguration_msg25',
|
||||
'mode.speed': 'guimainmemu_msg16',
|
||||
'mode.currentSpeed': 'guimainmemu_msg15',
|
||||
'menu.showDesktop': 'guievents_msg3',
|
||||
'desktop.home': 'desktoppanel_application',
|
||||
'desktop.installation': 'desktoppanel_msg1',
|
||||
'network.ipAddress': 'network_msg1',
|
||||
'network.subnetMask': 'network_msg4',
|
||||
'network.dnsServer': 'network_msg2',
|
||||
'network.gateway': 'network_msg3',
|
||||
'network.macAddress': 'network_msg9',
|
||||
};
|
||||
|
||||
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
|
||||
@@ -128,6 +138,9 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
|
||||
// Java's "\n" as line breaks; the last sentence of the German starts with a small letter there.
|
||||
'firewall.intro': ['jfirewalldialog_msg37', (java) => java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')],
|
||||
'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')],
|
||||
// Without Java's key (Strg+D, Strg+R): in a browser they bookmark and reload the page.
|
||||
'mode.design': ['guimainmemu_msg3', (java) => java.replace(/ \((Strg|Ctrl)\+D\)$/, '')],
|
||||
'mode.simulation': ['guimainmemu_msg4', (java) => java.replace(/ \((Strg|Ctrl)\+R\)$/, '')],
|
||||
// Java's lines without their leading space and the empty one; a step starts with a capital letter.
|
||||
'connections.hint': [
|
||||
'jvermittlungsrechnerkonfiguration_msg19',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// The desktop's programs against Java's lists (config/Desktop_<language>.txt):
|
||||
// the same programs, in each language's order, with Java's names and icons.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { desktopPrograms, DESKTOP_ORDER, JAVA_PROGRAMS } from '../src/desktop/programs.js';
|
||||
import { CATALOGS, type Language } from '../src/i18n/i18n.js';
|
||||
|
||||
const config = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/config');
|
||||
const FILES: Readonly<Record<Language, string>> = { de: 'Desktop_de_DE.txt', en: 'Desktop_en_GB.txt' };
|
||||
|
||||
describe("the desktop's programs", () => {
|
||||
it.skipIf(!existsSync(config))("are Java's, in the order of each language's list, with Java's names and icons", () => {
|
||||
for (const language of ['de', 'en'] as const) {
|
||||
const lines = readFileSync(join(config, FILES[language]), 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
|
||||
.map((line) => line.split(';'));
|
||||
expect(DESKTOP_ORDER[language].map((program) => JAVA_PROGRAMS[program].javaClass)).toEqual(lines.map((line) => line[1]));
|
||||
expect(DESKTOP_ORDER[language].map((program) => CATALOGS[language][`program.${program}`])).toEqual(lines.map((line) => line[0]));
|
||||
expect(DESKTOP_ORDER[language].map((program) => `gfx/desktop/${JAVA_PROGRAMS[program].iconFile}`)).toEqual(lines.map((line) => line[3]!.trim()));
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a computer's programs only, in the language's order, without the DHCP server", () => {
|
||||
expect(desktopPrograms(['webBrowser', 'commandPrompt', 'dhcpServer', 'imageViewer'], 'de')).toEqual(['commandPrompt', 'imageViewer', 'webBrowser']);
|
||||
expect(desktopPrograms(['webBrowser', 'commandPrompt', 'dhcpServer', 'imageViewer'], 'en')).toEqual(['commandPrompt', 'imageViewer', 'webBrowser']);
|
||||
expect(desktopPrograms(['webServer', 'webBrowser'], 'de')).toEqual(['webServer', 'webBrowser']);
|
||||
expect(desktopPrograms(['webServer', 'webBrowser'], 'en')).toEqual(['webBrowser', 'webServer']);
|
||||
expect(desktopPrograms([], 'de')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -15,9 +15,10 @@ export type AppKind = (typeof APP_KINDS)[number];
|
||||
/**
|
||||
* What a client subscribes to: which cables carry frames (`activity`), a
|
||||
* card's capture (`trace:<portId>`), a program's window
|
||||
* (`app:<nodeId>/<app>`).
|
||||
* (`app:<nodeId>/<app>`), a computer's network settings as it runs with them
|
||||
* (`node:<nodeId>`: its desktop's title and network info).
|
||||
*/
|
||||
export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}`;
|
||||
export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
|
||||
|
||||
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'input'] as const;
|
||||
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
|
||||
@@ -41,7 +42,7 @@ export type ClientMessage =
|
||||
readonly reqId: string;
|
||||
};
|
||||
|
||||
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'ack', 'error'] as const;
|
||||
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'node', 'ack', 'error'] as const;
|
||||
export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
|
||||
|
||||
export type SimMode = 'design' | 'simulation';
|
||||
@@ -66,9 +67,23 @@ export type HostMessage =
|
||||
| { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean }
|
||||
/** A program's new window lines from line `from` on; on subscribing, all (from 0). */
|
||||
| { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] }
|
||||
/**
|
||||
* A computer's first card as the simulation runs it (DHCP may change it):
|
||||
* on subscribing, when it changes, and none while the simulation never ran.
|
||||
*/
|
||||
| { readonly v: 1; readonly kind: 'node'; readonly node: string; readonly settings: NodeSettings }
|
||||
| { readonly v: 1; readonly kind: 'ack'; readonly reqId: string }
|
||||
| { readonly v: 1; readonly kind: 'error'; readonly code: ErrorCode; readonly reqId?: string; readonly detail?: string };
|
||||
|
||||
/** What Java's desktop shows of a computer's network (NetworkInfoPanel, the window's title). */
|
||||
export interface NodeSettings {
|
||||
readonly ipAddress: string;
|
||||
readonly subnetMask: string;
|
||||
readonly defaultGateway: string;
|
||||
readonly dnsServer: string;
|
||||
readonly macAddress: string;
|
||||
}
|
||||
|
||||
export type Parsed<T> = { readonly message: T } | { readonly error: { readonly code: ErrorCode; readonly detail: string } };
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
@@ -79,6 +94,7 @@ export function isTopic(value: unknown): value is Topic {
|
||||
if (value === 'activity') return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
if (value.startsWith('trace:')) return value.length > 'trace:'.length;
|
||||
if (value.startsWith('node:')) return /^node:[^/]+$/.test(value);
|
||||
const app = /^app:([^/]+)\/(.+)$/.exec(value);
|
||||
return app !== null && (APP_KINDS as readonly string[]).includes(app[2]!);
|
||||
}
|
||||
|
||||
@@ -35,15 +35,15 @@ describe("a client's messages", () => {
|
||||
});
|
||||
|
||||
it('knows its topics', () => {
|
||||
expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser'].every(isTopic)).toBe(true);
|
||||
expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:n', 7].some(isTopic)).toBe(false);
|
||||
expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser', 'node:n'].every(isTopic)).toBe(true);
|
||||
expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:', 'node:n/x', 'nodes:n', 7].some(isTopic)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a host's messages", () => {
|
||||
it('reads each kind, and names an unknown one', () => {
|
||||
for (const kind of HOST_MESSAGE_KINDS) expect('message' in parseHostMessage({ v: 1, kind })).toBe(true);
|
||||
expect(parseHostMessage({ v: 1, kind: 'node' })).toEqual({ error: { code: 'unknownKind', detail: 'kind node' } });
|
||||
expect(parseHostMessage({ v: 1, kind: 'nodes' })).toEqual({ error: { code: 'unknownKind', detail: 'kind nodes' } });
|
||||
expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// keep rising (plan §6.2).
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
|
||||
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
|
||||
import { docSnapshot } from '@mdg-filius/doc';
|
||||
import {
|
||||
parseClientMessage,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ClientMessage,
|
||||
type ErrorCode,
|
||||
type HostMessage,
|
||||
type NodeSettings,
|
||||
type SimMode,
|
||||
type Topic,
|
||||
type TraceFrame,
|
||||
@@ -47,6 +48,8 @@ interface Client {
|
||||
readonly traceSent: Map<Id, number>;
|
||||
/** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */
|
||||
readonly appSent: Map<string, number>;
|
||||
/** Per computer, the settings last sent (as text). */
|
||||
readonly nodeSent: Map<Id, string>;
|
||||
}
|
||||
|
||||
/** A program with a window: its lines. */
|
||||
@@ -79,7 +82,7 @@ export class SimulationRoom {
|
||||
/** A new client; it gets the room's state at once. */
|
||||
connect(send: (message: HostMessage) => void): number {
|
||||
const id = this.nextClient++;
|
||||
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map() });
|
||||
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map() });
|
||||
send(this.state());
|
||||
return id;
|
||||
}
|
||||
@@ -117,6 +120,7 @@ export class SimulationRoom {
|
||||
client.topics.add(topic);
|
||||
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
|
||||
if (topic.startsWith('app:')) client.appSent.delete(topic);
|
||||
if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length));
|
||||
}
|
||||
this.flush([client], true);
|
||||
return;
|
||||
@@ -260,6 +264,14 @@ export class SimulationRoom {
|
||||
for (const topic of client.topics) {
|
||||
if (topic === 'activity') {
|
||||
if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity }));
|
||||
} else if (topic.startsWith('node:')) {
|
||||
const node = topic.slice('node:'.length);
|
||||
const settings = this.nodeSettings(node);
|
||||
if (settings === undefined) continue;
|
||||
const text = JSON.stringify(settings);
|
||||
if (client.nodeSent.get(node) === text) continue;
|
||||
client.nodeSent.set(node, text);
|
||||
client.send(message({ kind: 'node', node, settings }));
|
||||
} else if (topic.startsWith('trace:')) {
|
||||
const portId = topic.slice('trace:'.length);
|
||||
const reset = !client.traceSent.has(portId);
|
||||
@@ -288,6 +300,26 @@ export class SimulationRoom {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A device's first card as the engine runs it (a computer's settings, which
|
||||
* DHCP may change); undefined for a device the simulation never had.
|
||||
*/
|
||||
private nodeSettings(id: Id): NodeSettings | undefined {
|
||||
const snapshot = this.engine.state.topologySnapshot;
|
||||
const node = snapshot.nodes.find((each) => each.id === id);
|
||||
if (node === undefined) return undefined;
|
||||
const port = node.ports[0];
|
||||
const device = snapshot.deviceConfigurations.get(id);
|
||||
const card = port === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, port.id));
|
||||
return {
|
||||
ipAddress: device?.ipAddress ?? card?.ipAddress ?? '',
|
||||
subnetMask: device?.subnetMask ?? card?.subnetMask ?? '',
|
||||
defaultGateway: device?.defaultGateway ?? '',
|
||||
dnsServer: device?.dnsServer ?? '',
|
||||
macAddress: port === undefined ? '' : (port.macAddress ?? stableGeneratedMacAddress(port.id)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The cables whose cards got frames since the last tick; sent when that changes. */
|
||||
private updateActivity(): void {
|
||||
const links = new Set<string>();
|
||||
|
||||
@@ -117,8 +117,8 @@ describe('the simulation in a worker of the page', () => {
|
||||
const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close());
|
||||
const got: HostMessage[] = [];
|
||||
host.onMessage((message) => got.push(message));
|
||||
channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'node', id: 'x' } });
|
||||
expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind node' });
|
||||
channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'nodes', id: 'x' } });
|
||||
expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nodes' });
|
||||
host.close();
|
||||
channel.port2.close();
|
||||
});
|
||||
|
||||
@@ -306,4 +306,28 @@ describe('a simulation room', () => {
|
||||
expect(ofKind(got, 'error')).toHaveLength(1);
|
||||
expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' });
|
||||
});
|
||||
|
||||
it("sends a computer's network as it runs, anew when DHCP changes it, and nothing before a start", () => {
|
||||
const { got, send, run, node } = setUp(exampleDoc('java-examples/einfaches_rechnernetz_komplett.fls'));
|
||||
const notebook = node('Notebook 1');
|
||||
const server = node('Server');
|
||||
send({ kind: 'sub', topics: [`node:${notebook.id}`, `node:${server.id}`] });
|
||||
expect(ofKind(got, 'node')).toEqual([]);
|
||||
send({ kind: 'start' });
|
||||
run(20_000);
|
||||
const sent = ofKind(got, 'node').filter((each) => each.node === notebook.id);
|
||||
expect(sent.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sent.at(-1)!.settings).toMatchObject({ subnetMask: '255.255.255.0', dnsServer: '192.168.0.100', macAddress: expect.stringMatching(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/) });
|
||||
expect(sent.at(-1)!.settings.ipAddress).toMatch(/^192\.168\.0\.\d+$/);
|
||||
// Sent when it changes, not every tick.
|
||||
expect(new Set(sent.map((each) => JSON.stringify(each.settings))).size).toBe(sent.length);
|
||||
expect(ofKind(got, 'node').filter((each) => each.node === server.id).map((each) => each.settings)).toEqual([
|
||||
{ ipAddress: '192.168.0.100', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '192.168.0.100', macAddress: expect.any(String) },
|
||||
]);
|
||||
// A client that subscribes again gets it again.
|
||||
const before = got.length;
|
||||
send({ kind: 'sub', topics: [`node:${server.id}`] });
|
||||
expect(ofKind(got.slice(before), 'node')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||