feat(editor): F4.6b the command line on a computer's desktop
A new oracle, TerminalWindowRun, types into Java's own command line window: the window writes each line after its prompt, takes a blank line, cls, reset and exit itself, keeps the lines for the arrow keys, and keeps its text, history and working directory over a stop and a start. The room now holds a command line window per computer for everyone (TerminalWindow), kept over stops; the protocol carries its lines and the one way to read them (terminalScreen); the core goes on in the working directory of the run before (continueIn). The desktop opens programs in Java's frame, and the command line works with keyboard and finger: grey on black, the prompt hidden while a command runs, history, chips for the commands, Ctrl+C. The room's failure is now tested in the browser. Fixed: the room handed the engine the wall clock with fractions of a millisecond, and a ping hung after its first timeout. The engine and the room count whole milliseconds, as Java's clock does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,26 +9,29 @@
|
||||
// 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).
|
||||
// bar, as Java's windows do; on a phone it fills the surface. A program's
|
||||
// icon opens its window over the desktop (ProgramFrame), the home button
|
||||
// leaves it; so far the command line has one (F4.6b), the others follow.
|
||||
|
||||
import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, 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 type { Simulation } from '../simulation/useSimulation.js';
|
||||
import { ProgramFrame } from './ProgramFrame.js';
|
||||
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js';
|
||||
import { TerminalPanel } from './TerminalPanel.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;
|
||||
/** The room: the computer's network as it runs, its programs' windows. */
|
||||
readonly simulation: Simulation;
|
||||
/** Whether its cable carried a frame at the last tick. */
|
||||
readonly active: boolean;
|
||||
/** The window's top left corner on the surface. */
|
||||
@@ -36,11 +39,17 @@ export interface DesktopWindowProps {
|
||||
readonly y: number;
|
||||
/** Its place in the stack of desktops. */
|
||||
readonly z: number;
|
||||
/** The program whose window is open on it, kept by the editor over a return to the design (Java's desktop keeps it). */
|
||||
readonly program: DesktopProgram | null;
|
||||
readonly onProgram: (program: DesktopProgram | null) => void;
|
||||
readonly onMove: (x: number, y: number) => void;
|
||||
readonly onFront: () => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** The programs whose windows are built so far. */
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt']);
|
||||
|
||||
/** 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));
|
||||
@@ -53,13 +62,20 @@ function useDesktopView(doc: Y.Doc, id: string) {
|
||||
return view;
|
||||
}
|
||||
|
||||
export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFront, onClose }: DesktopWindowProps) {
|
||||
export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, onMove, onFront, onClose }: DesktopWindowProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const view = useDesktopView(doc, id);
|
||||
const { watch } = simulation;
|
||||
useEffect(() => watch([`node:${id}`]), [watch, id]);
|
||||
const settings = simulation.nodes.get(id);
|
||||
const [info, setInfo] = useState(false);
|
||||
const home = useCallback(() => setOpen(null), [setOpen]);
|
||||
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
|
||||
if (view === undefined) return null;
|
||||
const programs = desktopPrograms(view.programs, language);
|
||||
// A program removed while its window was open: the desktop shows.
|
||||
const shown = open !== null && programs.includes(open) ? open : null;
|
||||
const address = settings?.ipAddress ?? '';
|
||||
const title = view.addressAsName ? view.label : `${view.label} - ${address}`;
|
||||
|
||||
@@ -98,21 +114,33 @@ export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFr
|
||||
<div className="desktop-screen" style={{ backgroundImage: `url(${background})` }}>
|
||||
<ul className="desktop-icons">
|
||||
<li>
|
||||
{/* The programs' windows come with the next steps. */}
|
||||
{/* The installation's window comes with F4.6c. */}
|
||||
<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) => (
|
||||
{programs.map((program) => (
|
||||
<li key={program}>
|
||||
<button type="button" className="desktop-icon" data-program={program} aria-disabled="true" title={t(`program.${program}`)}>
|
||||
<button
|
||||
type="button"
|
||||
className="desktop-icon"
|
||||
data-program={program}
|
||||
aria-disabled={WITH_WINDOW.has(program) ? undefined : 'true'}
|
||||
title={t(`program.${program}`)}
|
||||
onClick={() => WITH_WINDOW.has(program) && setOpen(program)}
|
||||
>
|
||||
<img src={JAVA_PROGRAMS[program].icon} alt="" />
|
||||
<span>{t(`program.${program}`)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{shown === null ? null : (
|
||||
<ProgramFrame icon={JAVA_PROGRAMS[shown].icon} title={t(`program.${shown}`)} program={shown}>
|
||||
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
|
||||
</ProgramFrame>
|
||||
)}
|
||||
{info ? (
|
||||
// A press beside the panel closes it, as in Java.
|
||||
<div className="desktop-info-layer" onPointerDown={(event) => event.target === event.currentTarget && setInfo(false)}>
|
||||
@@ -129,7 +157,14 @@ export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFr
|
||||
</div>
|
||||
<footer className="desktop-taskbar">
|
||||
<span />
|
||||
<button type="button" className="desktop-home" onClick={() => setInfo(false)}>
|
||||
<button
|
||||
type="button"
|
||||
className="desktop-home"
|
||||
onClick={() => {
|
||||
setInfo(false);
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
{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)}>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// A program's window on a computer's desktop, after Java's
|
||||
// GUIApplicationWindow: it fills the desktop above the taskbar, with Java's
|
||||
// dark grey title bar — the program's icon small on the left, its name in
|
||||
// the middle. Java's window has no close button: the taskbar's home button
|
||||
// leaves it, and the program keeps its window for the next time.
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function ProgramFrame({ icon, title, program, children }: { readonly icon: string; readonly title: string; readonly program: string; readonly children: ReactNode }) {
|
||||
return (
|
||||
<section className="program-window" aria-label={title} data-program-window={program}>
|
||||
<header className="program-titlebar">
|
||||
<img src={icon} alt="" />
|
||||
<h3>{title}</h3>
|
||||
<span />
|
||||
</header>
|
||||
<div className="program-content">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// A computer's command line, after Java's GUIApplicationTerminalWindow: grey
|
||||
// text on black, the prompt before the input line and hidden while a command
|
||||
// runs on, Enter hands the line over, the arrow keys go through the lines
|
||||
// entered, Ctrl+C ends a command. The window itself is the room's — the same
|
||||
// for everyone who opens it, kept over stops (TerminalWindow) — and so is
|
||||
// what it shows; this panel only types into it.
|
||||
//
|
||||
// For a finger, under the window: the commands as chips (a tap puts the
|
||||
// command into the input line), the arrow keys and Ctrl+C as buttons.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { historyLine, historyStep, terminalScreen, type TerminalLine } from '@mdg-filius/protocol';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Simulation } from '../simulation/useSimulation.js';
|
||||
|
||||
/** The commands offered as chips: the network's first, as a lesson needs them. */
|
||||
export const COMMAND_CHIPS = ['ipconfig', 'ping', 'route', 'nslookup', 'host', 'traceroute', 'arp', 'netstat', 'ls', 'cd', 'cat', 'help'] as const;
|
||||
|
||||
const NO_LINES: readonly TerminalLine[] = [];
|
||||
|
||||
export function TerminalPanel({ node, simulation, onExit }: { readonly node: string; readonly simulation: Simulation; readonly onExit: () => void }) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${node}/terminal` as const;
|
||||
const { watch, act } = simulation;
|
||||
useEffect(() => watch([topic]), [watch, topic]);
|
||||
const app = simulation.apps.get(topic);
|
||||
const lines = (app?.lines as readonly TerminalLine[] | undefined) ?? NO_LINES;
|
||||
const screen = terminalScreen(lines);
|
||||
const [input, setInput] = useState('');
|
||||
const [pointer, setPointer] = useState(-1);
|
||||
const field = useRef<HTMLInputElement>(null);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
|
||||
// exit closes the window — an exit typed now, by anyone; not one of the lines it had when it opened.
|
||||
const seen = useRef(lines.length);
|
||||
useEffect(() => {
|
||||
const from = Math.max(seen.current, app?.live ?? 0);
|
||||
seen.current = lines.length;
|
||||
if (lines.slice(from).some((line) => line.kind === 'exit')) onExit();
|
||||
}, [lines, app?.live, onExit]);
|
||||
|
||||
// The newest text in sight, as Java scrolls to the bottom.
|
||||
useLayoutEffect(() => {
|
||||
const element = scroller.current;
|
||||
if (element !== null) element.scrollTop = element.scrollHeight;
|
||||
}, [screen.text, screen.promptShown]);
|
||||
|
||||
useEffect(() => field.current?.focus(), []);
|
||||
|
||||
const enter = () => {
|
||||
act(node, 'terminal', 'enter', { line: input });
|
||||
setInput('');
|
||||
setPointer(-1);
|
||||
};
|
||||
const browse = (step: 'up' | 'down') => {
|
||||
const next = historyStep(pointer, step, screen.history.length);
|
||||
setPointer(next);
|
||||
setInput(historyLine(screen.history, next));
|
||||
};
|
||||
const interrupt = () => act(node, 'terminal', 'interrupt');
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
enter();
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
browse(event.key === 'ArrowUp' ? 'up' : 'down');
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && event.currentTarget.selectionStart === event.currentTarget.selectionEnd) {
|
||||
// Ctrl+C with nothing marked ends the command, as in Java; with text marked it copies.
|
||||
event.preventDefault();
|
||||
interrupt();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="terminal">
|
||||
<div className="terminal-screen" ref={scroller} onClick={() => field.current?.focus()}>
|
||||
<pre className="terminal-text">{screen.text}</pre>
|
||||
<div className="terminal-input">
|
||||
<span className="terminal-prompt" style={{ visibility: screen.promptShown ? 'visible' : 'hidden' }}>
|
||||
{screen.prompt}
|
||||
</span>
|
||||
<input
|
||||
ref={field}
|
||||
name="terminalInput"
|
||||
value={input}
|
||||
aria-label={t('terminal.input')}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
enterKeyHint="send"
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="terminal-keys" role="toolbar" aria-label={t('terminal.commands')}>
|
||||
{COMMAND_CHIPS.map((command) => (
|
||||
<button
|
||||
key={command}
|
||||
type="button"
|
||||
className="terminal-chip"
|
||||
onClick={() => {
|
||||
setInput(`${command} `);
|
||||
setPointer(-1);
|
||||
field.current?.focus();
|
||||
}}
|
||||
>
|
||||
{command}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="terminal-chip" aria-label={t('terminal.previous')} title={t('terminal.previous')} onClick={() => browse('up')}>
|
||||
↑
|
||||
</button>
|
||||
<button type="button" className="terminal-chip" aria-label={t('terminal.next')} title={t('terminal.next')} onClick={() => browse('down')}>
|
||||
↓
|
||||
</button>
|
||||
<button type="button" className="terminal-chip" aria-label={t('terminal.interrupt')} title={t('terminal.interrupt')} onClick={interrupt}>
|
||||
{t('terminal.ctrlC')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,3 +183,115 @@
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* A program's window over the desktop (GUIApplicationWindow): Java's dark
|
||||
* grey title bar of 30 pixels with the icon at 16. */
|
||||
.program-window {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.program-titlebar {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 1fr 30px;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
background: rgb(64 64 64);
|
||||
}
|
||||
|
||||
.program-titlebar img {
|
||||
justify-self: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.program-titlebar h3 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font: 13px Dialog, sans-serif;
|
||||
color: rgb(225 225 225);
|
||||
}
|
||||
|
||||
.program-content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The command line (GUIApplicationTerminalWindow): grey on black, the input
|
||||
* line right after the text in the same scrolling area. 16 pixels in the
|
||||
* input: Safari on the iPad zooms into smaller fields. */
|
||||
.terminal {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.terminal-screen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 5px 5px 5px;
|
||||
color: rgb(222 222 222);
|
||||
font: 14px ui-monospace, Menlo, Consolas, 'Courier New', monospace;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.terminal-text {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.terminal-input {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.terminal-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: rgb(222 222 222);
|
||||
caret-color: rgb(222 222 222);
|
||||
font: 16px ui-monospace, Menlo, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.terminal-keys {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
overflow-x: auto;
|
||||
padding: 6px;
|
||||
background: rgb(32 32 32);
|
||||
}
|
||||
|
||||
.terminal-chip {
|
||||
flex: none;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(90 90 90);
|
||||
border-radius: 8px;
|
||||
background: rgb(48 48 48);
|
||||
color: rgb(222 222 222);
|
||||
font: 14px ui-monospace, Menlo, Consolas, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user