260426.4 · 5d0b84b1b465a0abd6a27e893d37d5f68f8e1836
feat(wasm): Introduce ghostty-wasm as backing WASM terminal (fixes #700) (fixes #670) (fixes #698)
This gets us all the goodness (or most of it) of ghostty, and fixes a few outstanding problems. This works like xterm.js, just better.
reading declarations
Changes to webfiles/tcell.js. All files
@@ -1,4 +1,4 @@-// Copyright 2024 The TCell Authors+// Copyright 2026 The TCell Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License.@@ -13,271 +13,627 @@ // limitations under the License. const wasmFilePath = "main.wasm";-const term = document.getElementById("terminal");-var width = 80;-var height = 24;+const container = document.getElementById("terminal"); const beepAudio = new Audio("beep.wav");+const textDecoder = new TextDecoder();+const ghosttyWebURL = new URL("./ghostty-web/ghostty-web.js", import.meta.url);+const ghosttyWasmURL = new URL("./ghostty-web/ghostty-vt.wasm", import.meta.url); -var cx = -1;-var cy = -1;-var cursorClass = "cursor-blinking-block";-var cursorColor = "";+window.addEventListener("error", (event) => {+ showFatal(event.error ?? event.message);+});+window.addEventListener("unhandledrejection", (event) => {+ showFatal(event.reason);+});++const { FitAddon, Ghostty, Terminal } = await importGhosttyWeb();+const modes = {+ mouseButton: false,+ mouseDrag: false,+ mouseMotion: false,+ mouseSgr: false,+ focus: false,+ paste: false,+ resize: false,+ win32Input: false,+};+const pressedButtons = new Set();+const modifierState = {+ ShiftLeft: false,+ ShiftRight: false,+ ControlLeft: false,+ ControlRight: false,+ AltLeft: false,+ AltRight: false,+ MetaLeft: false,+ MetaRight: false,+};++const initialSize = configuredSize();+const ghostty = await Ghostty.load(ghosttyWasmURL.href);++const term = new Terminal({+ ghostty,+ cols: initialSize.cols,+ rows: initialSize.rows,+ cursorBlink: true,+ fontFamily: '"Menlo", "Andale Mono", "Courier New", monospace',+ fontSize: 14,+ theme: {+ background: "#000000",+ foreground: "#e5e5e5",+ },+});+const fit = new FitAddon();+term.loadAddon(fit);+term.open(container);+fitTerminal();++term.onData((data) => {+ if (modes.win32Input) {+ return;+ }+ globalThis.tcellRead?.(data);+});+term.onResize(() => {+ globalThis.tcellResize?.();+});+term.onBell(() => {+ beep();+});++globalThis.tcellWrite = (data) => {+ const text = data instanceof Uint8Array ? textDecoder.decode(data, { stream: true }) : data;+ handleHostOutput(text);+ term.write(text);+};++globalThis.tcellWindowSize = () => {+ const metrics = term.renderer?.getMetrics?.();+ return {+ cols: term.cols,+ rows: term.rows,+ pixelWidth: metrics ? Math.floor(metrics.width * term.cols) : 0,+ pixelHeight: metrics ? Math.floor(metrics.height * term.rows) : 0,+ };+}; -var content; // {data: row[height], dirty: bool}-// row = {data: element[width], previous: span}-// dirty/[previous being null] indicates if previous (or entire terminal) needs to be recalculated.-// dirty is true/null if terminal/previous need to be re-calculated/shown+globalThis.beep = beep; -function initialize() {- resize(width, height); // initialize content- show(); // then show the screen+function beep() {+ beepAudio.currentTime = 0;+ beepAudio.play()?.catch(() => {}); } -function resize(w, h) {- width = w;- height = h;- content = { data: new Array(height), dirty: true };- for (let i = 0; i < height; i++) {- content.data[i] = { data: new Array(width), previous: null };+async function importGhosttyWeb() {+ try {+ return await import(/* @vite-ignore */ ghosttyWebURL.href);+ } catch (error) {+ throw new Error(+ "Failed to load ./ghostty-web/ghostty-web.js. Copy webfiles/ghostty-web next to tcell.js.",+ { cause: error }+ ); }+} - clearScreen();+function showFatal(error) {+ const message = error instanceof Error ? error.message : String(error);+ const cause = error instanceof Error && error.cause instanceof Error ? `\n${error.cause.message}` : "";+ container.textContent = `Tcell WASM startup failed:\n${message}${cause}`;+ container.style.whiteSpace = "pre-wrap";+ container.style.color = "#ff6b6b";+ container.style.backgroundColor = "#111";+ container.style.padding = "1rem";+ console.error(error); } -function clearScreen(fg, bg) {- if (fg) {- term.style.color = intToHex(fg);- }- if (bg) {- term.style.backgroundColor = intToHex(bg);+function configuredSize() {+ return {+ cols: positiveInt(container.dataset.cols) ?? 80,+ rows: positiveInt(container.dataset.rows) ?? 24,+ };+}++function explicitSize() {+ return {+ cols: positiveInt(container.dataset.cols),+ rows: positiveInt(container.dataset.rows),+ };+}++function positiveInt(value) {+ const n = Number.parseInt(value ?? "", 10);+ return Number.isFinite(n) && n > 0 ? n : undefined;+}++function fitTerminal() {+ const size = explicitSize();+ if (size.cols != null && size.rows != null) {+ if (term.cols !== size.cols || term.rows !== size.rows) {+ term.resize(size.cols, size.rows);+ reportResize();+ }+ return; } - content.dirty = true;- for (let i = 0; i < height; i++) {- content.data[i].previous = null; // we set the row to be recalculated later- for (let j = 0; j < width; j++) {- content.data[i].data[j] = document.createTextNode(" "); // set the entire row to spaces.+ fit.fit();+ if (size.cols != null || size.rows != null) {+ const cols = size.cols ?? term.cols;+ const rows = size.rows ?? term.rows;+ if (term.cols !== cols || term.rows !== rows) {+ term.resize(cols, rows); } }+ reportResize(); } -function drawCell(x, y, s, fg, bg, attrs, us, uc) {- var span = document.createElement("span");- var use = false;+function reportResize() {+ globalThis.tcellResize?.();+ if (modes.resize) {+ sendInput(`\x1b[4;${term.rows};${term.cols}t`);+ }+}++const resizeObserver = new ResizeObserver(fitTerminal);+resizeObserver.observe(container);++window.addEventListener("resize", fitTerminal); - if ((attrs & (1 << 2)) != 0) {- // reverse video- var temp = bg;- bg = fg;- fg = temp;- use = true;+container.addEventListener("mousedown", (event) => {+ if (!mouseReportingEnabled()) {+ return; }- if (fg != -1) {- span.style.color = intToHex(fg);- use = true;+ captureTerminalEvent(event);+ focusTerminal();+ pressedButtons.add(event.button);+ sendMouse(event, event.button, false, true);+}, true);++container.addEventListener("mouseup", (event) => {+ if (!mouseReportingEnabled()) {+ return; }- if (bg != -1) {- span.style.backgroundColor = intToHex(bg);- use = true;+ captureTerminalEvent(event);+ sendMouse(event, event.button, false, false);+ pressedButtons.delete(event.button);+}, true);++container.addEventListener("mousemove", (event) => {+ if (!mouseReportingEnabled()) {+ return; }+ if (!modes.mouseMotion && !(modes.mouseDrag && pressedButtons.size > 0)) {+ return;+ }+ captureTerminalEvent(event);+ sendMouse(event, firstPressedButton(), true, true);+}, true); - // NB: these has to be updated if Attrs.go changes- if (attrs != 0) {- use = true;- if ((attrs & 1) != 0) {- span.classList.add("bold");- }- if ((attrs & (1 << 4)) != 0) {- span.classList.add("dim");- }- if ((attrs & (1 << 5)) != 0) {- span.classList.add("italic");- }- if ((attrs & (1 << 6)) != 0) {- span.classList.add("strikethrough");- }+container.addEventListener("contextmenu", (event) => {+ if (modes.mouseButton) {+ captureTerminalEvent(event); }- if (us != 0) {- use = true;- if (us == 1) {- span.classList.add("underline");- } else if (us == 2) {- span.classList.add("double_underline");- } else if (us == 3) {- span.classList.add("curly_underline");- } else if (us == 4) {- span.classList.add("dotted_underline");- } else if (us == 5) {- span.classList.add("dashed_underline");- }- if (uc != -1) {- span.style.textDecorationColor = intToHex(uc);- }+}, true);++container.addEventListener("auxclick", (event) => {+ if (modes.mouseButton) {+ captureTerminalEvent(event); }+}, true); - if ((attrs & (1 << 1)) != 0) {- var blink = document.createElement("span");- blink.classList.add("blink");- var textnode = document.createTextNode(s);- blink.appendChild(textnode);- span.appendChild(blink);- } else {- var textnode = document.createTextNode(s);- span.appendChild(textnode);+container.addEventListener("selectstart", (event) => {+ if (modes.mouseButton) {+ captureTerminalEvent(event); }+}, true); - content.dirty = true; // invalidate terminal- new cell- content.data[y].previous = null; // invalidate row- new row- content.data[y].data[x] = use ? span : textnode;-}+container.addEventListener(+ "wheel",+ handleWheel,+ { passive: false }+); -function show() {- if (!content.dirty) {- return; // no new draws; no need to update+window.addEventListener("wheel", (event) => {+ if (container.contains(event.target)) {+ handleWheel(event); }+}, { capture: true, passive: false }); - displayCursor();+container.addEventListener("focusin", () => {+ if (modes.focus) {+ sendInput("\x1b[I");+ }+}); - term.innerHTML = "";- content.data.forEach((row) => {- if (row.previous == null) {- row.previous = document.createElement("span");- row.data.forEach((c) => {- row.previous.appendChild(c);- });- row.previous.appendChild(document.createTextNode("\n"));- }- term.appendChild(row.previous);- });+container.addEventListener("focusout", () => {+ if (modes.focus) {+ sendInput("\x1b[O");+ }+}); - content.dirty = false;-}+window.addEventListener("focus", () => {+ if (modes.focus) {+ sendInput("\x1b[I");+ }+}); -function showCursor(x, y) {- content.dirty = true;+window.addEventListener("blur", () => {+ clearModifierState();+ pressedButtons.clear();+ if (modes.focus) {+ sendInput("\x1b[O");+ }+}); - if (!(cx < 0 || cy < 0)) {- // if original position is a valid cursor position- content.data[cy].previous = null;- if (content.data[cy].data[cx].classList) {- content.data[cy].data[cx].classList.remove(cursorClass);+container.addEventListener(+ "paste",+ (event) => {+ if (!modes.paste) {+ return; }+ event.preventDefault();+ event.stopPropagation();+ const text = event.clipboardData?.getData("text/plain") ?? "";+ sendInput(`\x1b[200~${text}\x1b[201~`);+ },+ true+);++window.addEventListener("keydown", (event) => {+ if (!modes.win32Input) {+ return; }+ captureTerminalEvent(event);+ updateModifierState(event, true);+ sendWinKey(event, true);+}, true); - cx = x;- cy = y;+window.addEventListener("keyup", (event) => {+ if (!modes.win32Input) {+ return;+ }+ captureTerminalEvent(event);+ updateModifierState(event, false);+ sendWinKey(event, false);+}, true);++function handleHostOutput(text) {+ replyToQueries(text);+ updateModes(text); } -function displayCursor() {- content.dirty = true;-- if (!(cx < 0 || cy < 0)) {- // if new position is a valid cursor position- content.data[cy].previous = null;-- if (!content.data[cy].data[cx].classList) {- var span = document.createElement("span");- span.appendChild(content.data[cy].data[cx]);- content.data[cy].data[cx] = span;+function replyToQueries(text) {+ if (text.includes("\x1b[c")) {+ // VT420-ish primary DA with color and clipboard capability.+ sendInput("\x1b[?64;22;52c");+ }+ if (text.includes("\x1b[>q")) {+ sendInput("\x1bP>|ghostty-web tcell\x1b\\");+ }+ const modeStates = {+ 1000: modes.mouseButton,+ 1002: modes.mouseDrag,+ 1003: modes.mouseMotion,+ 1004: modes.focus,+ 1006: modes.mouseSgr,+ 2004: modes.paste,+ 2048: modes.resize,+ 9001: modes.win32Input,+ };+ for (const [mode, enabled] of Object.entries(modeStates)) {+ if (text.includes(`\x1b[?${mode}$p`)) {+ sendInput(`\x1b[?${mode};${enabled ? 1 : 2}$y`); }+ }+ // Deliberately do not advertise Kitty/XTerm keyboard modes; the bridge uses+ // tcell's existing Win32 input parser for browser advanced-key reporting.+} - if (cursorColor != "") {- term.style.setProperty("--cursor-color", cursorColor);- } else {- term.style.setProperty("--cursor-color", "lightgrey");+function updateModes(text) {+ const re = /\x1b\[\?([0-9;]+)([hl])/g;+ let match;+ while ((match = re.exec(text)) !== null) {+ const enabled = match[2] === "h";+ for (const rawMode of match[1].split(";")) {+ setMode(Number(rawMode), enabled); }+ }+} - content.data[cy].data[cx].classList.add(cursorClass);+function setMode(mode, enabled) {+ switch (mode) {+ case 1000:+ modes.mouseButton = enabled;+ break;+ case 1002:+ modes.mouseDrag = enabled;+ break;+ case 1003:+ modes.mouseMotion = enabled;+ break;+ case 1006:+ modes.mouseSgr = enabled;+ break;+ case 1004:+ modes.focus = enabled;+ if (enabled) {+ sendInput(document.hasFocus() ? "\x1b[I" : "\x1b[O");+ }+ break;+ case 2004:+ modes.paste = enabled;+ break;+ case 2048:+ modes.resize = enabled;+ break;+ case 9001:+ modes.win32Input = enabled;+ break; } } -function setCursorStyle(newClass, newColor) {- if (newClass == cursorClass && newColor == cursorColor) {+function sendMouse(event, button, motion, down, wheel = false) {+ const pos = mouseCell(event);+ if (pos == null) {+ return;+ }+ let cb;+ if (wheel) {+ cb = 64 + button;+ } else {+ cb = buttonCode(button);+ if (motion) {+ cb += 32;+ }+ }+ if (event.shiftKey) {+ cb += 4;+ }+ if (event.altKey || event.metaKey) {+ cb += 8;+ }+ if (event.ctrlKey) {+ cb += 16;+ }+ if (modes.mouseSgr) {+ sendInput(`\x1b[<${cb};${pos.x};${pos.y}${down ? "M" : "m"}`); return; } - if (!(cx < 0 || cy < 0)) {- // mark cursor row as dirty; new class has been applied to (cx, cy)- content.dirty = true;- content.data[cy].previous = null;-- if (content.data[cy].data[cx].classList) {- content.data[cy].data[cx].classList.remove(cursorClass);+ // Legacy mouse reports are byte-oriented, not UTF-8 text. Use Uint8Array so+ // coordinates above 95 columns do not get corrupted by string encoding.+ if (!down) {+ cb = 3;+ if (event.shiftKey) {+ cb += 4;+ }+ if (event.altKey || event.metaKey) {+ cb += 8; }+ if (event.ctrlKey) {+ cb += 16;+ }+ }+ sendInput(new Uint8Array([+ 0x1b,+ 0x5b,+ 0x4d,+ cb + 32,+ Math.min(pos.x, 223) + 32,+ Math.min(pos.y, 223) + 32,+ ]));+} - // adding the new class will be dealt with when displayCursor() is called+function handleWheel(event) {+ if (!mouseReportingEnabled()) {+ return; }+ captureTerminalEvent(event);+ const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);+ const button = horizontal+ ? event.deltaX < 0+ ? 2+ : 3+ : event.deltaY < 0+ ? 0+ : 1;+ sendMouse(event, button, false, true, true);+} - cursorClass = newClass;- cursorColor = newColor;+function mouseCell(event) {+ const metrics = term.renderer?.getMetrics?.();+ const rect = container.getBoundingClientRect();+ const cellWidth = metrics?.width || rect.width / term.cols;+ const cellHeight = metrics?.height || rect.height / term.rows;+ if (!cellWidth || !cellHeight) {+ return null;+ }+ return {+ x: Math.max(1, Math.min(term.cols, Math.floor((event.clientX - rect.left) / cellWidth) + 1)),+ y: Math.max(1, Math.min(term.rows, Math.floor((event.clientY - rect.top) / cellHeight) + 1)),+ }; } -function beep() {- beepAudio.currentTime = 0;- beepAudio.play();+function buttonCode(button) {+ switch (button) {+ case 1:+ return 1;+ case 2:+ return 2;+ default:+ return 0;+ } } -function setTitle(title) {- document.title = title;+function firstPressedButton() {+ return pressedButtons.size === 0 ? 0 : pressedButtons.values().next().value; } -function intToHex(n) {- return "#" + n.toString(16).padStart(6, "0");+function mouseReportingEnabled() {+ return modes.mouseButton || modes.mouseDrag || modes.mouseMotion; } -initialize();+function sendInput(data) {+ globalThis.tcellRead?.(data);+} -let fontwidth = term.clientWidth / width;-let fontheight = term.clientHeight / height;+function sendWinKey(event, down) {+ const vk = virtualKey(event);+ const uc = unicodeChar(event);+ const cs = controlKeyState(event);+ sendInput(`\x1b[${vk};0;${uc};${down ? 1 : 0};${cs};1_`);+} -document.addEventListener("keydown", (e) => {- onKeyEvent(e.key, e.shiftKey, e.altKey, e.ctrlKey, e.metaKey);-});+function virtualKey(event) {+ const modifierVK = modifierVirtualKey(event);+ if (modifierVK != null) {+ return modifierVK;+ }+ if (event.keyCode) {+ return event.keyCode;+ }+ if (/^Key[A-Z]$/.test(event.code)) {+ return event.code.charCodeAt(3);+ }+ if (/^Digit[0-9]$/.test(event.code)) {+ return event.code.charCodeAt(5);+ }+ if (/^F([1-9]|1[0-9]|2[0-4])$/.test(event.key)) {+ return 0x70 + Number(event.key.slice(1)) - 1;+ }+ return keyToVK[event.key] ?? 0;+} -term.addEventListener("click", (e) => {- onMouseClick(- Math.min((e.offsetX / fontwidth) | 0, width - 1),- Math.min((e.offsetY / fontheight) | 0, height - 1),- e.which,- e.shiftKey,- e.altKey,- e.ctrlKey- );-});+function unicodeChar(event) {+ if (event.key.length === 1) {+ return event.key.codePointAt(0);+ }+ return 0;+} -term.addEventListener("mousemove", (e) => {- onMouseMove(- Math.min((e.offsetX / fontwidth) | 0, width - 1),- Math.min((e.offsetY / fontheight) | 0, height - 1),- e.which,- e.shiftKey,- e.altKey,- e.ctrlKey- );-});+function controlKeyState(event) {+ let state = 0;+ if (modifierState.ShiftLeft || modifierState.ShiftRight || event.shiftKey) {+ state |= 0x10;+ }+ if (modifierState.ControlLeft) {+ state |= 0x08;+ }+ if (modifierState.ControlRight) {+ state |= 0x04;+ }+ if (event.ctrlKey && !modifierState.ControlLeft && !modifierState.ControlRight) {+ state |= event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? 0x04 : 0x08;+ }+ if (modifierState.AltLeft) {+ state |= 0x02;+ }+ if (modifierState.AltRight) {+ state |= 0x01;+ }+ if (event.altKey && !modifierState.AltLeft && !modifierState.AltRight) {+ state |= event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? 0x01 : 0x02;+ }+ if (modifierState.MetaLeft) {+ state |= 0x200;+ }+ if (modifierState.MetaRight) {+ state |= 0x400;+ }+ if (event.metaKey && !modifierState.MetaLeft && !modifierState.MetaRight) {+ state |= event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? 0x400 : 0x200;+ }+ return state;+} -term.addEventListener("focus", (e) => {- onFocus(true);-});+function updateModifierState(event, down) {+ if (event.code in modifierState) {+ modifierState[event.code] = down;+ return;+ }+ switch (event.key) {+ case "Shift":+ modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "ShiftRight" : "ShiftLeft"] = down;+ break;+ case "Control":+ modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "ControlRight" : "ControlLeft"] = down;+ break;+ case "Alt":+ modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "AltRight" : "AltLeft"] = down;+ break;+ case "Meta":+ modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "MetaRight" : "MetaLeft"] = down;+ break;+ }+} -term.addEventListener("blur", (e) => {- onFocus(false);-});-term.tabIndex = 0;+function clearModifierState() {+ for (const key of Object.keys(modifierState)) {+ modifierState[key] = false;+ }+} -document.addEventListener("paste", (e) => {- e.preventDefault();- var text = (e.originalEvent || e).clipboardData.getData("text/plain");- onPaste(true);- for (let i = 0; i < text.length; i++) {- onKeyEvent(text.charAt(i), false, false, false, false);+function modifierVirtualKey(event) {+ switch (event.code) {+ case "ShiftLeft":+ return 0xa0;+ case "ShiftRight":+ return 0xa1;+ case "ControlLeft":+ return 0xa2;+ case "ControlRight":+ return 0xa3;+ case "AltLeft":+ return 0xa4;+ case "AltRight":+ return 0xa5;+ case "MetaLeft":+ return 0x5b;+ case "MetaRight":+ return 0x5c; }- onPaste(false);-});+ return null;+}++function captureTerminalEvent(event) {+ event.preventDefault();+ event.stopPropagation();+ event.stopImmediatePropagation();+}++function focusTerminal() {+ term.focus?.();+ container.focus?.();+}++const keyToVK = {+ Backspace: 0x08,+ Tab: 0x09,+ Enter: 0x0d,+ Pause: 0x13,+ Escape: 0x1b,+ PageUp: 0x21,+ PageDown: 0x22,+ End: 0x23,+ Home: 0x24,+ ArrowLeft: 0x25,+ ArrowUp: 0x26,+ ArrowRight: 0x27,+ ArrowDown: 0x28,+ PrintScreen: 0x2c,+ Insert: 0x2d,+ Delete: 0x2e,+ Help: 0x2f,+ Shift: 0x10,+ Control: 0x11,+ Alt: 0x12,+ Meta: 0x5b,+ CapsLock: 0x14,+}; const go = new Go();-WebAssembly.instantiateStreaming(fetch(wasmFilePath), go.importObject).then(- (result) => {- go.run(result.instance);- }+const result = await WebAssembly.instantiateStreaming(+ fetch(wasmFilePath),+ go.importObject );+go.run(result.instance);