const DEBUG = false; const statusEl = document.getElementById("status"); const setStatus = (s) => (statusEl.textContent = s); const log = (...args) => { if (!DEBUG) return; if (logEl) { logEl.textContent += args .map((a) => (typeof a === "string" ? a : JSON.stringify(a))) .join(" ") + "\n"; } console.log(...args); }; // UiState below is ported from (github.com/MuNeNiCK/nvim-wasm) // but, extended with win_viewport tracking. class UiState { constructor(cols, rows) { this.defaultWidth = Math.max(1, cols || 80); this.defaultHeight = Math.max(1, rows || 24); this.defaultGrid = 1; this.activeGrid = this.defaultGrid; this.grids = new Map(); this.grids.set( this.defaultGrid, this.#createGrid(this.defaultWidth, this.defaultHeight), ); this.cursor = { grid: this.defaultGrid, row: 0, col: 0 }; this.mode = "-"; this.modeIdx = 0; this.cursorStyleEnabled = false; this.modeInfo = []; this.cursorHlId = 0; this.hls = new Map(); this.hls.set(0, { foreground: null, background: null, reverse: false }); this.windows = new Map(); } resize(gridId, width, height) { const grid = this.#ensureGrid(gridId); const w = Math.max(1, width || 0); const h = Math.max(1, height || 0); grid.width = w; grid.height = h; grid.cells = Array.from({ length: h }, () => this.#blankRow(w)); } clear(gridId) { const grid = this.#ensureGrid(gridId); grid.cells = Array.from({ length: grid.height }, () => this.#blankRow(grid.width), ); } destroy(gridId) { this.grids.delete(gridId); this.windows.delete(gridId); if (this.activeGrid === gridId) this.activeGrid = this.defaultGrid; } line(gridId, row, colStart, cells) { const grid = this.#ensureGrid(gridId); const rowIdx = row || 0; if (rowIdx < 0 || rowIdx >= grid.height) return; const rowCells = grid.cells[rowIdx] || (grid.cells[rowIdx] = this.#blankRow(grid.width)); let col = colStart || 0; let currentHl = 0; for (const cell of cells) { const text = cell[0]; const hlId = cell.length > 1 && cell[1] !== undefined ? cell[1] : currentHl; currentHl = hlId; const repeat = cell[2] || 1; for (let r = 0; r < repeat && col < grid.width; r += 1) { rowCells[col] = { ch: text || " ", hl: hlId }; col += 1; } } } scroll(gridId, top, bot, left, right, rows) { const grid = this.#ensureGrid(gridId); const height = bot - top; const width = right - left; const slice = []; for (let i = 0; i < height; i += 1) { const row = grid.cells[top + i] || this.#blankRow(grid.width); slice.push(row.slice(left, right)); } if (rows > 0) { for (let i = 0; i < height - rows; i += 1) { grid.cells[top + i].splice(left, width, ...slice[i + rows]); } for (let i = height - rows; i < height; i += 1) { grid.cells[top + i].splice(left, width, ...this.#blankRow(width)); } } else if (rows < 0) { for (let i = height - 1; i >= -rows; i -= 1) { grid.cells[top + i].splice(left, width, ...slice[i + rows]); } for (let i = 0; i < -rows; i += 1) { grid.cells[top + i].splice(left, width, ...this.#blankRow(width)); } } } setCursor(gridId, row, col) { this.activeGrid = gridId; this.#ensureGrid(gridId); this.cursor = { grid: gridId, row: row || 0, col: col || 0 }; } setMode(mode, modeIdx) { this.mode = mode || "-"; if (Number.isInteger(modeIdx)) this.modeIdx = modeIdx; this.#updateCursorHl(); } setModeInfo(cursorStyleEnabled, modeInfo) { this.cursorStyleEnabled = Boolean(cursorStyleEnabled); this.modeInfo = Array.isArray(modeInfo) ? modeInfo : []; this.#updateCursorHl(); } defineHl(id, rgbAttr = {}) { this.hls.set(id, { foreground: this.#toHex(rgbAttr.foreground), background: this.#toHex(rgbAttr.background), reverse: Boolean(rgbAttr.reverse), }); } setWindowViewport(gridId, winHandle, topline, botline) { this.windows.set(gridId, { winHandle, topline, botline }); } snapshot() { const grid = this.grids.get(this.activeGrid) || this.grids.get(this.defaultGrid); if (!grid) return { cells: [[{ ch: " ", hl: 0 }]], cursor: { row: 0, col: 0 }, mode: this.mode, hls: this.hls, }; const row = Math.min(Math.max(this.cursor.row, 0), grid.height - 1); const col = Math.min(Math.max(this.cursor.col, 0), grid.width - 1); return { cells: grid.cells.map((r) => r.map((c) => ({ ch: c?.ch ?? " ", hl: c?.hl ?? 0 })), ), cursor: { row, col }, cursorHlId: this.cursorHlId, mode: this.mode, hls: this.hls, }; } #ensureGrid(gridId) { if (!this.grids.has(gridId)) this.grids.set( gridId, this.#createGrid(this.defaultWidth, this.defaultHeight), ); return this.grids.get(gridId); } #createGrid(width, height) { return { width, height, cells: Array.from({ length: height }, () => this.#blankRow(width)), }; } #blankRow(width) { return Array.from({ length: width }, () => ({ ch: " ", hl: 0 })); } #updateCursorHl() { if (!this.cursorStyleEnabled) { this.cursorHlId = 0; return; } const info = this.modeInfo[this.modeIdx] || null; this.cursorHlId = info?.attr_id ?? 0; } #toHex(value) { if (value === undefined || value === null) return null; return `#${(value >>> 0).toString(16).padStart(6, "0").slice(-6)}`; } } function handleRedrawEvents(uiState, gridEl, modeEl, events) { for (const ev of events) { const [name, ...entries] = ev; switch (name) { case "grid_resize": for (const [grid, w, h] of entries) uiState.resize(grid, w, h); break; case "grid_clear": for (const [grid] of entries) uiState.clear(grid); break; case "grid_destroy": for (const [grid] of entries) uiState.destroy(grid); break; case "grid_line": for (const [grid, row, col, cells] of entries) uiState.line(grid, row, col, cells); break; case "grid_scroll": for (const [grid, top, bot, left, right, rows] of entries) uiState.scroll(grid, top, bot, left, right, rows); break; case "grid_cursor_goto": for (const [grid, row, col] of entries) uiState.setCursor(grid, row, col); break; case "win_viewport": for (const [grid, win, topline, botline] of entries) uiState.setWindowViewport(grid, win, topline, botline); break; case "mode_info_set": for (const [enabled, modeInfo] of entries) uiState.setModeInfo(enabled, modeInfo); break; case "mode_change": for (const [mode, idx] of entries) uiState.setMode(mode, idx); break; case "hl_attr_define": for (const [id, rgbAttr] of entries) uiState.defineHl(id, rgbAttr); break; case "flush": paintGrid(uiState, gridEl, modeEl); break; default: break; // mouse on/off, busy start/stop, etc. } } } function paintGrid(uiState, gridEl, modeEl) { const { cells, cursor, cursorHlId, mode, hls } = uiState.snapshot(); let html = ""; for (let r = 0; r < cells.length; r += 1) { for (let c = 0; c < cells[r].length; c += 1) { const cell = cells[r][c]; const isCursor = r === cursor.row && c === cursor.col; const hl = hls.get(isCursor ? cursorHlId : cell.hl) || {}; let style = ""; if (hl.reverse) { style = `color:${hl.background || "#050912"};background:${hl.foreground || "#dfe5f1"}`; } else { if (hl.foreground) style += `color:${hl.foreground};`; if (hl.background) style += `background:${hl.background};`; } const ch = escapeHtml(cell.ch || " "); if (isCursor) { html += `${ch}`; } else if (style) { html += `${ch}`; } else { html += ch; } } if (r < cells.length - 1) html += "\n"; } gridEl.innerHTML = html || " "; if (modeEl) modeEl.textContent = `mode: ${mode}`; } function escapeHtml(ch) { if (ch === "&") return "&"; if (ch === "<") return "<"; if (ch === ">") return ">"; return ch; } function translateKey(ev) { const isCtrl = ev.ctrlKey || ev.metaKey; const isAlt = ev.altKey; const named = { Backspace: "", Enter: "", Escape: "", Tab: "", ArrowUp: "", ArrowDown: "", ArrowLeft: "", ArrowRight: "", Delete: "", Home: "", End: "", PageUp: "", PageDown: "", }; if (named[ev.key]) return named[ev.key]; if (ev.key.length === 1) { let char = ev.shiftKey ? ev.key : ev.key.toLowerCase(); if (char === "<") char = ""; if (!isCtrl && !isAlt) return char; let mod = ""; if (isCtrl) mod += "C-"; if (isAlt) mod += "A-"; return `<${mod}${char}>`; } return null; } function main() { const transport = new WorkerTransport("nvim-worker.js", { cols: 120, rows: 40, }); // internal FS.write/stderr trace line the worker emits. transport.onStatus((text) => { log("[status]", text); if (/^(FS\.write|WRITE HOOK|\[stderr\])/.test(text)) return; setStatus(text); }); const nvim = new RpcClient(transport); window.nvim = nvim; // for console debugging const uiState = new UiState(120, 40); const gridEl = document.getElementById("grid"); const modeEl = document.getElementById("mode"); let apiInfoPromise = null; function ensureHandleTypesRegistered() { if (!apiInfoPromise) { apiInfoPromise = (async () => { const result = await nvim.request("nvim_get_api_info", []); log("RESPONSE nvim_get_api_info ->", result); const [channelId, metadata] = result; log("types metadata ->", metadata.types); registerNvimHandleTypes(metadata.types); setStatus("got nvim_get_api_info response, handle types registered"); return result; })(); } return apiInfoPromise; } // Auto-attach + visible failure status transport.onReady(async () => { const maxAttempts = 5; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { await ensureHandleTypesRegistered(); await nvim.request("nvim_ui_attach", [ 110, 40, { rgb: true, ext_linegrid: true }, ]); setStatus("UI attached"); return; } catch (e) { console.error(`auto-attach attempt ${attempt} failed`, e); log("auto-attach failed ->", e && e.message ? e.message : String(e)); if (attempt === maxAttempts) { setStatus("auto-attach failed after retries, see console (F12)"); } else { await new Promise((r) => setTimeout(r, 300 * attempt)); } } } }); nvim.on("redraw", (params) => handleRedrawEvents(uiState, gridEl, modeEl, params), ); gridEl.addEventListener("click", () => gridEl.focus()); gridEl.addEventListener("keydown", (ev) => { const keys = translateKey(ev); if (!keys) return; ev.preventDefault(); nvim .request("nvim_input", [keys]) .catch((e) => console.error("nvim_input failed", e)); }); } if (!crossOriginIsolated) { setStatus( "NOT cross-origin isolated -- SharedArrayBuffer unavailable. Serve with COOP/COEP headers.", ); } else { main(); }