Files
chess/web/chess-client-wasm/terminal.js
T
lixen 87dcb3e550
test / test (push) Canceled after 0s
v0.13.5 web ui updates
2026-10-15 22:02:30 -04:00

282 lines
11 KiB
JavaScript

// Runs the WASM client in #chess-terminal: its data-wasm-url names the binary,
// #chess-terminal-screen holds xterm, #chess-terminal-status (-state and
// -progress spans) reports loading. xterm with its fit and WebGL addons and
// wasm_exec.js load first. lixen.com wraps this same file in its own page.
(() => {
'use strict';
const host = document.getElementById('chess-terminal');
if (!host) return;
const screenEl = document.getElementById('chess-terminal-screen');
const status = document.getElementById('chess-terminal-status');
const stateEl = document.getElementById('chess-terminal-state');
const progress = document.getElementById('chess-terminal-progress');
const wasmURL = host.dataset.wasmUrl;
const setState = (text, isError) => {
stateEl.textContent = text;
progress.textContent = '';
status.hidden = !text && !isError;
status.classList.toggle('is-error', !!isError);
};
// A page script that should have removed .no-js may have been blocked; its
// no-JS message would then cover a working terminal.
document.documentElement.classList.remove('no-js');
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
setState('Terminal library failed to load. Reload the page to retry.', true);
return;
}
if (typeof Go === 'undefined' || !globalThis.fs) {
setState('WebAssembly support files failed to load. Reload the page to retry.', true);
return;
}
// The theme's colours are the host's CSS custom properties.
const css = getComputedStyle(host);
const token = (name, fallback) => css.getPropertyValue(name).trim() || fallback;
const term = new Terminal({
cursorBlink: true,
convertEol: true,
scrollback: 1000,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 14,
theme: {
background: token('--term-bg', '#1a1b26'),
foreground: token('--term-fg', '#a9b1d6'),
cursor: token('--term-cursor', '#a9b1d6'),
// xterm 5 silently ignores the old `selection` key.
selectionBackground: token('--term-selection', 'rgba(169,177,214,0.35)')
}
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(screenEl);
// WebGL must load after open(); the DOM renderer is the fallback.
try {
const webgl = new WebglAddon.WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
} catch (e) {
console.warn('xterm: WebGL renderer unavailable, using the DOM renderer', e);
}
// xterm captures Tab, so Escape, which the line editor ignores, is the
// keyboard way out of the terminal (WCAG 2.1.2).
term.attachCustomKeyEventHandler((e) => {
if (e.type === 'keydown' && e.key === 'Escape') {
term.blur();
return false;
}
return true;
});
// fit() floors rows against the cell height it knew at call time, which a
// zoom step can leave stale: drop a row while .xterm-screen (rows x cell
// height) paints taller than the box.
const availHeight = () => {
const cs = getComputedStyle(screenEl);
return screenEl.clientHeight
- parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
};
const snapRows = () => {
const scr = term.element && term.element.querySelector('.xterm-screen');
if (!scr || !term.rows) return;
const cell = scr.getBoundingClientRect().height / term.rows;
if (!cell) return;
const max = Math.max(1, Math.floor(availHeight() / cell));
if (term.rows > max) term.resize(term.cols, max);
};
const fitNow = (passes) => {
fitAddon.fit();
snapRows();
if (passes > 1) requestAnimationFrame(() => fitNow(passes - 1));
};
let fitTimer = 0;
const scheduleFit = () => {
clearTimeout(fitTimer);
fitTimer = setTimeout(() => fitNow(2), 50);
};
// A resolution query matches one ratio only, so re-arm on every change.
let dprQuery = null;
const onDprChange = () => { watchDpr(); scheduleFit(); };
const watchDpr = () => {
if (dprQuery) dprQuery.removeEventListener('change', onDprChange);
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
dprQuery.addEventListener('change', onDprChange);
};
fitNow(2);
new ResizeObserver(scheduleFit).observe(screenEl);
window.addEventListener('resize', scheduleFit);
watchDpr();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(scheduleFit);
screenEl.addEventListener('click', () => term.focus());
// Go's js/wasm routes os.Stdout and os.Stderr through fs.write, runtime
// prints (panics) through fs.writeSync and os.Stdin through fs.read: fds
// 0-2 go to xterm, the rest to wasm_exec.js's own implementations.
const fs = globalThis.fs;
const nativeWrite = fs.write;
const nativeWriteSync = fs.writeSync;
const nativeRead = fs.read;
const encoder = new TextEncoder();
// One streaming decoder per fd: a UTF-8 sequence split across two writes
// must not lend its tail to the other stream.
const decoders = { 1: new TextDecoder('utf-8'), 2: new TextDecoder('utf-8') };
const toTerminal = (fd, bytes) => term.write(decoders[fd].decode(bytes, { stream: true }));
fs.writeSync = function (fd, buf) {
if (fd === 1 || fd === 2) {
toTerminal(fd, buf);
return buf.length;
}
return nativeWriteSync.call(this, fd, buf);
};
fs.write = function (fd, buf, offset, length, position, callback) {
if (fd === 1 || fd === 2) {
toTerminal(fd, buf.subarray(offset, offset + length));
callback(null, length); // synchronous, exactly as wasm_exec.js does
return;
}
nativeWrite.call(this, fd, buf, offset, length, position, callback);
};
// Line editor. Input is taken only while Go blocks in read: keys buffered
// across a read would echo twice. Per character, so a pasted "e2e4\r"
// submits; a multi-line paste submits its first line and drops the rest,
// which Go's next read would echo over a screen the client has repainted.
let line = '';
let pendingRead = null;
let esc = 0; // 0 none, 1 saw ESC, 2 inside CSI/SS3
term.onData((data) => {
if (!pendingRead) return;
for (const ch of data) {
// Escape sequences (an arrow key) type nothing; CSI/SS3 end on 0x40-0x7E.
if (esc === 1) { esc = (ch === '[' || ch === 'O') ? 2 : 0; continue; }
if (esc === 2) { if (ch >= '@' && ch <= '~') esc = 0; continue; }
if (ch === '\x1b') { esc = 1; continue; }
if (ch === '\r') {
term.write('\r\n');
const resolve = pendingRead, value = line;
pendingRead = null; line = ''; esc = 0;
resolve(value);
return;
}
if (ch === '\x03') { // Ctrl-C
term.write('^C\r\n');
const resolve = pendingRead;
pendingRead = null; line = ''; esc = 0;
resolve('');
return;
}
if (ch === '\x7f' || ch === '\b') {
if (line.length > 0) { line = line.slice(0, -1); term.write('\b \b'); }
continue;
}
if (ch >= ' ' && ch <= '~') { line += ch; term.write(ch); }
}
});
// The bytes of a submitted line that did not fit the caller's buffer.
let residual = null;
fs.read = function (fd, buf, offset, length, position, callback) {
if (fd !== 0) {
nativeRead.call(this, fd, buf, offset, length, position, callback);
return;
}
const deliver = (bytes) => {
const n = Math.min(length, bytes.length);
buf.set(bytes.subarray(0, n), offset);
residual = n < bytes.length ? bytes.subarray(n) : null;
callback(null, n);
};
if (residual) {
// Resolving inside the fs.read call would re-enter the Go runtime
// through _resume() while it is still on the stack.
const pending = residual;
residual = null;
queueMicrotask(() => deliver(pending));
return;
}
new Promise((resolve) => { pendingRead = resolve; })
.then((value) => deliver(encoder.encode(value + '\n')));
};
const MB = 1048576;
// One unit on the pair: "2.1 / 10.3 MB".
const fmtBytes = (received, total) => total
? (received / MB).toFixed(1) + ' / ' + (total / MB).toFixed(1) + ' MB'
: (received / MB).toFixed(1) + ' MB';
(async () => {
const go = new Go();
let instance;
try {
const response = await fetch(wasmURL);
if (!response.ok) throw new Error('HTTP ' + response.status);
let bytes;
if (response.body) {
const reader = response.body.getReader();
const chunks = [];
let received = 0;
// The transferred length: compressed in transit, it is smaller
// than the decoded chunks, so it is dropped once they pass it.
let total = Number(response.headers.get('content-length')) || 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total && received > total) total = 0;
progress.textContent = fmtBytes(received, total);
}
bytes = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
} else {
bytes = new Uint8Array(await response.arrayBuffer());
}
setState('Compiling the client…');
const result = await WebAssembly.instantiate(bytes, go.importObject);
instance = result.instance;
} catch (err) {
console.error('chess terminal: WASM load failed', err);
setState('Could not load the client (' + err.message + '). Reload to retry.', true);
return;
}
setState('');
// The page is the terminal, opened on purpose; Escape is the way out.
term.focus();
go.run(instance).then(
// Unreached while the client restarts on exit (exit_wasm.go).
() => setState('Session ended. Reload the page to start a new one.'),
(err) => {
console.error('chess terminal: client crashed', err);
setState('Client crashed: ' + err.message, true);
}
);
})();
})();