Skip to main content

src/etui/backend/browser_ffi.mjs

// Browser (xterm.js) backend FFI for etui.
// Call setup(term) with an xterm.js Terminal instance BEFORE calling main().
// Key normalisation is identical to node_ffi.mjs so keys.match works the same.

import { Ok, Error } from "../../gleam.mjs";
import {
  KeyPress, Resize, Tick,
  MousePress, MouseRelease, MouseScroll,
  MouseLeft, MouseMiddle, MouseRight,
} from "../backend.mjs";

// ─── State ───────────────────────────────────────────────────────

let term = null;
let inputBuffer = [];
let inputResolvers = [];
let resizeQueue = [];
let escapeBuffer = null;
let escapeTimer = null;

// ─── Terminal injection ──────────────────────────────────────────
// Called by the host page before main().

export function setup(xtermTerminal) {
  term = xtermTerminal;
  term.onData(onData);
  term.onResize(({ cols, rows }) => {
    resizeQueue.push([cols, rows]);
    drainResolvers();
  });
}

// ─── Terminal control (no-ops in browser) ────────────────────────

export function enterRaw() {}   // xterm.js is always in "raw" mode

export function exitRaw() {
  if (escapeTimer !== null) {
    clearTimeout(escapeTimer);
    escapeTimer = null;
    escapeBuffer = null;
  }
}

export function writeStdout(s) {
  term?.write(s);
}

export function windowSize() {
  const cols = term?.cols ?? 80;
  const rows = term?.rows ?? 24;
  return new Ok([cols, rows]);
}

// ─── Input handling ──────────────────────────────────────────────
// xterm.js fires onData with the same raw byte sequences as a real terminal.

function onData(chunk) {
  if (escapeBuffer !== null) {
    clearTimeout(escapeTimer);
    escapeTimer = null;
    const combined = escapeBuffer + chunk;
    escapeBuffer = null;
    inputBuffer.push(combined);
  } else if (chunk === "\x1b") {
    escapeBuffer = chunk;
    escapeTimer = setTimeout(() => {
      escapeBuffer = null;
      escapeTimer = null;
      inputBuffer.push("\x1b");
      drainResolvers();
    }, 20);
    return;
  } else {
    inputBuffer.push(chunk);
  }
  drainResolvers();
}

function drainResolvers() {
  while (inputResolvers.length > 0 && (inputBuffer.length > 0 || resizeQueue.length > 0)) {
    inputResolvers.shift()(null);
  }
}

// ─── Poll ─────────────────────────────────────────────────────────

export async function pollInput(timeoutMs) {
  if (resizeQueue.length > 0) {
    const [cols, rows] = resizeQueue.shift();
    return new Ok(new Resize(cols, rows));
  }
  if (inputBuffer.length > 0) {
    return new Ok(parseChunk(inputBuffer.shift()));
  }
  const result = await Promise.race([
    new Promise((resolve) => inputResolvers.push(resolve)),
    new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
  ]);
  if (result === "timeout") return new Ok(new Tick());
  if (resizeQueue.length > 0) {
    const [cols, rows] = resizeQueue.shift();
    return new Ok(new Resize(cols, rows));
  }
  if (inputBuffer.length > 0) {
    return new Ok(parseChunk(inputBuffer.shift()));
  }
  return new Ok(new Tick());
}

// ─── Cleanup ──────────────────────────────────────────────────────

export function registerCleanup(cleanupFn) {
  window.addEventListener("beforeunload", () => {
    try { cleanupFn(); } catch (_) {}
  });
}

// ─── Key normalisation (mirrors erlang.gleam normalise_key) ───────

function normaliseKey(raw) {
  switch (raw) {
    case "\x1b[A": case "\x1bOA": return "up";
    case "\x1b[B": case "\x1bOB": return "down";
    case "\x1b[C": case "\x1bOC": return "right";
    case "\x1b[D": case "\x1bOD": return "left";
    case "\r": case "\n": return "enter";
    case "\x7f": case "\b": return "backspace";
    case "\x1b[3~": return "delete";
    case "\t": return "tab";
    case "\x1b[Z": return "backtab";
    case "\x1b": return "esc";
    case "\x1b[2~": return "insert";
    case "\x1b[5~": return "pageup";
    case "\x1b[6~": return "pagedown";
    case "\x1b[H": case "\x1bOH": case "\x1b[1~": return "home";
    case "\x1b[F": case "\x1bOF": case "\x1b[4~": return "end";
    case "\x1b[11~": case "\x1bOP": return "f1";
    case "\x1b[12~": case "\x1bOQ": return "f2";
    case "\x1b[13~": case "\x1bOR": return "f3";
    case "\x1b[14~": case "\x1bOS": return "f4";
    case "\x1b[15~": return "f5";
    case "\x1b[17~": return "f6";
    case "\x1b[18~": return "f7";
    case "\x1b[19~": return "f8";
    case "\x1b[20~": return "f9";
    case "\x1b[21~": return "f10";
    case "\x1b[23~": return "f11";
    case "\x1b[24~": return "f12";
    case "\x01": return "ctrl+a";
    case "\x02": return "ctrl+b";
    case "\x03": return "ctrl+c";
    case "\x04": return "ctrl+d";
    case "\x05": return "ctrl+e";
    case "\x06": return "ctrl+f";
    case "\x07": return "ctrl+g";
    case "\x0b": return "ctrl+k";
    case "\x0c": return "ctrl+l";
    case "\x0e": return "ctrl+n";
    case "\x0f": return "ctrl+o";
    case "\x10": return "ctrl+p";
    case "\x11": return "ctrl+q";
    case "\x12": return "ctrl+r";
    case "\x13": return "ctrl+s";
    case "\x14": return "ctrl+t";
    case "\x15": return "ctrl+u";
    case "\x16": return "ctrl+v";
    case "\x17": return "ctrl+w";
    case "\x18": return "ctrl+x";
    case "\x19": return "ctrl+y";
    case "\x1a": return "ctrl+z";
    default:
      if (raw.length === 2 && raw[0] === "\x1b") return "alt+" + raw[1];
      return raw;
  }
}

function parseChunk(chunk) {
  if (chunk.startsWith("\x1b[<")) {
    const mouse = parseSgrMouse(chunk.slice(3));
    if (mouse !== null) return mouse;
  }
  return new KeyPress(normaliseKey(chunk));
}

function parseSgrMouse(payload) {
  const isPress = payload.endsWith("M");
  const trimmed = payload.slice(0, -1);
  const parts = trimmed.split(";");
  if (parts.length !== 3) return null;
  const cb = parseInt(parts[0], 10);
  const cx = parseInt(parts[1], 10);
  const cy = parseInt(parts[2], 10);
  if (isNaN(cb) || isNaN(cx) || isNaN(cy)) return null;
  const x = cx - 1;
  const y = cy - 1;
  if (cb === 64) return new MouseScroll(x, y, true);
  if (cb === 65) return new MouseScroll(x, y, false);
  const btn = [new MouseLeft(), new MouseMiddle(), new MouseRight()][cb % 4] ?? new MouseLeft();
  return isPress ? new MousePress(x, y, btn) : new MouseRelease(x, y, btn);
}