7a57ef8c
feat: web shell — canvas wall, 1x1 passive tiles, zoomed input/IME/scrollback
a73x 2026-08-13 10:40
Commit message
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -249,11 +249,36 @@ fn dialLoop( | |||
| 249 | /// One accepted connection, start to finish (M-web Task 7). Static | 249 | /// One accepted connection, start to finish (M-web Task 7). Static |
| 250 | /// requests loop for keep-alive; a WebSocket upgrade consumes the | 250 | /// requests loop for keep-alive; a WebSocket upgrade consumes the |
| 251 | /// connection into a tile pump and never returns to HTTP. | 251 | /// connection into a tile pump and never returns to HTTP. |
| 252 | /// `/tiles`: the runtime half the embedded page cannot know — the tile | ||
| 253 | /// labels, in index order, as a JSON array. Escaping covers the two | ||
| 254 | /// bytes JSON cannot carry raw in a string plus control chars; labels | ||
| 255 | /// are argv (hosts, paths), not hostile input, but a path with a quote | ||
| 256 | /// in it must not break the page. | ||
| 257 | pub fn tilesJson(alloc: std.mem.Allocator, labels: []const []const u8) ![]u8 { | ||
| 258 | var out: std.ArrayList(u8) = .empty; | ||
| 259 | errdefer out.deinit(alloc); | ||
| 260 | try out.append(alloc, '['); | ||
| 261 | for (labels, 0..) |label, i| { | ||
| 262 | if (i > 0) try out.append(alloc, ','); | ||
| 263 | try out.append(alloc, '"'); | ||
| 264 | for (label) |c| switch (c) { | ||
| 265 | '"' => try out.appendSlice(alloc, "\\\""), | ||
| 266 | '\\' => try out.appendSlice(alloc, "\\\\"), | ||
| 267 | 0x00...0x1f => try out.print(alloc, "\\u{x:0>4}", .{c}), | ||
| 268 | else => try out.append(alloc, c), | ||
| 269 | }; | ||
| 270 | try out.append(alloc, '"'); | ||
| 271 | } | ||
| 272 | try out.append(alloc, ']'); | ||
| 273 | return out.toOwnedSlice(alloc); | ||
| 274 | } | ||
| 275 | |||
| 252 | pub fn serveConn( | 276 | pub fn serveConn( |
| 253 | alloc: std.mem.Allocator, | 277 | alloc: std.mem.Allocator, |
| 254 | stream: std.net.Stream, | 278 | stream: std.net.Stream, |
| 255 | port: u16, | 279 | port: u16, |
| 256 | targets: []const client.Target, | 280 | targets: []const client.Target, |
| 281 | labels: []const []const u8, | ||
| 257 | assets: Assets, | 282 | assets: Assets, |
| 258 | ) void { | 283 | ) void { |
| 259 | defer stream.close(); | 284 | defer stream.close(); |
| @@ -297,6 +322,18 @@ pub fn serveConn( | |||
| 297 | return; | 322 | return; |
| 298 | } | 323 | } |
| 299 | 324 | ||
| 325 | if (std.mem.eql(u8, path, "/tiles")) { | ||
| 326 | const json = tilesJson(alloc, labels) catch return; | ||
| 327 | defer alloc.free(json); | ||
| 328 | req.respond(json, .{ | ||
| 329 | .extra_headers = &.{ | ||
| 330 | .{ .name = "content-type", .value = "application/json" }, | ||
| 331 | .{ .name = "cache-control", .value = "no-cache" }, | ||
| 332 | }, | ||
| 333 | }) catch return; | ||
| 334 | continue; | ||
| 335 | } | ||
| 336 | |||
| 300 | if (route(assets, path)) |asset| { | 337 | if (route(assets, path)) |asset| { |
| 301 | req.respond(asset.body, .{ | 338 | req.respond(asset.body, .{ |
| 302 | .extra_headers = &.{ | 339 | .extra_headers = &.{ |
src/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -225,7 +225,7 @@ pub fn main() !u8 { | |||
| 225 | while (true) { | 225 | while (true) { |
| 226 | const conn = listener.accept() catch continue; | 226 | const conn = listener.accept() catch continue; |
| 227 | const th = std.Thread.spawn(.{}, webhub.serveConn, .{ | 227 | const th = std.Thread.spawn(.{}, webhub.serveConn, .{ |
| 228 | alloc, conn.stream, parsed.port, targets.items, assets, | 228 | alloc, conn.stream, parsed.port, targets.items, labels.items, assets, |
| 229 | }) catch { | 229 | }) catch { |
| 230 | conn.stream.close(); | 230 | conn.stream.close(); |
| 231 | continue; | 231 | continue; |
web/index.html
| Old | New | ||
|---|---|---|---|
| @@ -1,17 +1,59 @@ | |||
| 1 | <!doctype html> | 1 | <!doctype html> |
| 2 | <!-- muxweb: the wall of devices. Skeleton (M-web Task 5); the shell | 2 | <!-- muxweb: the wall of devices (M-web Task 8). Served from memory by |
| 3 | proper lands in Task 8. --> | 3 | muxweb; everything dynamic lives in mux.js; the replica itself is |
| 4 | mux_core.wasm — the same ghostty-vt the daemon runs. --> | ||
| 4 | <html lang="en"> | 5 | <html lang="en"> |
| 5 | <head> | 6 | <head> |
| 6 | <meta charset="utf-8"> | 7 | <meta charset="utf-8"> |
| 7 | <title>mux</title> | 8 | <title>mux</title> |
| 8 | <style> | 9 | <style> |
| 9 | body { margin: 0; background: #111; color: #ccc; font: 14px monospace; } | 10 | :root { --bg: #0d0f12; --chrome: #1a1d23; --fg: #c8ccd4; --dim: #6b7280; } |
| 10 | #wall { display: grid; gap: 8px; padding: 8px; } | 11 | * { box-sizing: border-box; } |
| 12 | body { | ||
| 13 | margin: 0; background: var(--bg); color: var(--fg); | ||
| 14 | font: 13px/1.4 ui-monospace, monospace; | ||
| 15 | } | ||
| 16 | #wall { | ||
| 17 | display: grid; gap: 10px; padding: 10px; | ||
| 18 | grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); | ||
| 19 | } | ||
| 20 | .tile { | ||
| 21 | background: var(--chrome); border: 1px solid #262a33; border-radius: 4px; | ||
| 22 | overflow: hidden; cursor: pointer; display: flex; flex-direction: column; | ||
| 23 | } | ||
| 24 | .tile header { | ||
| 25 | display: flex; justify-content: space-between; align-items: center; | ||
| 26 | padding: 4px 8px; font-size: 12px; color: var(--dim); | ||
| 27 | border-bottom: 1px solid #262a33; user-select: none; | ||
| 28 | } | ||
| 29 | .tile header .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 30 | .badge { padding: 0 6px; border-radius: 3px; font-size: 11px; } | ||
| 31 | .badge.connecting, .badge.reconnecting { background: #4a3b12; color: #e8c35a; } | ||
| 32 | .badge.up { background: #16351f; color: #6fce8a; } | ||
| 33 | .badge.gone, .badge.exited, .badge.full { background: #3d1a1a; color: #e07a7a; } | ||
| 34 | .badge.scroll { background: #1a2c3d; color: #6ab0e0; } | ||
| 35 | .tile canvas { display: block; width: 100%; height: auto; background: #000; } | ||
| 36 | /* The zoomed tile: same element, promoted. 1:1 pixels, keys go here. */ | ||
| 37 | .tile.zoomed { | ||
| 38 | position: fixed; inset: 0; z-index: 10; border-radius: 0; cursor: default; | ||
| 39 | border: none; | ||
| 40 | } | ||
| 41 | .tile.zoomed canvas { width: auto; height: auto; margin: 0 auto; } | ||
| 42 | #shade { | ||
| 43 | display: none; position: fixed; inset: 0; z-index: 5; background: #000a; | ||
| 44 | } | ||
| 45 | #shade.on { display: block; } | ||
| 46 | /* IME target: focusable, invisible, never display:none (that kills IME). */ | ||
| 47 | #ime { | ||
| 48 | position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; | ||
| 49 | opacity: 0; border: 0; padding: 0; | ||
| 50 | } | ||
| 11 | </style> | 51 | </style> |
| 12 | </head> | 52 | </head> |
| 13 | <body> | 53 | <body> |
| 54 | <div id="shade"></div> | ||
| 14 | <div id="wall"></div> | 55 | <div id="wall"></div> |
| 56 | <input id="ime" autocomplete="off" autocapitalize="off" spellcheck="false"> | ||
| 15 | <script src="/mux.js"></script> | 57 | <script src="/mux.js"></script> |
| 16 | </body> | 58 | </body> |
| 17 | </html> | 59 | </html> |
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,404 @@ | |||
| 1 | // muxweb glue skeleton (M-web Task 5). The renderer, input, and tile | 1 | // muxweb shell (M-web Task 8). Thin by construction: every decision that |
| 2 | // logic land in Task 8. Discipline stated once, here, for everything | 2 | // can live in Zig lives in mux_core.wasm; this file is glue — WebSocket |
| 3 | // that follows: NEVER cache a view of wasm memory — every call can grow | 3 | // pump, canvas painter, event translation. |
| 4 | // linear memory and growth detaches every ArrayBuffer view. Re-read | 4 | // |
| 5 | // exports.memory.buffer after each call. | 5 | // THE ONE DISCIPLINE: never cache a view of wasm memory. Any call can |
| 6 | // grow linear memory, and growth detaches every ArrayBuffer view. Every | ||
| 7 | // access below re-reads exports.memory.buffer. (Spike-measured: caching | ||
| 8 | // gives silent garbage or a TypeError, and not immediately.) | ||
| 6 | 'use strict'; | 9 | 'use strict'; |
| 7 | console.log('muxweb: shell skeleton'); | 10 | |
| 11 | // Wire bytes (protocol.zig MsgType / webhub.zig envelope). | ||
| 12 | const MSG = { | ||
| 13 | attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05, | ||
| 14 | snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87, | ||
| 15 | pty_mode: 0x88, | ||
| 16 | }; | ||
| 17 | const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; | ||
| 18 | |||
| 19 | // keymap.Key by @intFromEnum (wasm_core.zig pins the table). | ||
| 20 | const KEY = { | ||
| 21 | char: 0, Enter: 1, Tab: 2, Backspace: 3, Escape: 4, | ||
| 22 | ArrowUp: 5, ArrowDown: 6, ArrowLeft: 7, ArrowRight: 8, | ||
| 23 | Home: 9, End: 10, Insert: 11, Delete: 12, PageUp: 13, PageDown: 14, | ||
| 24 | F1: 15, F2: 16, F3: 17, F4: 18, F5: 19, F6: 20, F7: 21, F8: 22, | ||
| 25 | F9: 23, F10: 24, F11: 25, F12: 26, | ||
| 26 | }; | ||
| 27 | |||
| 28 | // Pastes chunk at 32 KiB so no browser→hub message approaches the hub's | ||
| 29 | // 64 KiB inbound bound (webhub.zig ws_buffer_len). | ||
| 30 | const PASTE_CHUNK = 32 * 1024; | ||
| 31 | |||
| 32 | const FONT = '14px ui-monospace, monospace'; | ||
| 33 | const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000'; | ||
| 34 | |||
| 35 | // The xterm 256 palette, computed once. | ||
| 36 | const PALETTE = (() => { | ||
| 37 | const base = [ | ||
| 38 | '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', | ||
| 39 | '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff', | ||
| 40 | ]; | ||
| 41 | const p = base.slice(); | ||
| 42 | const lv = [0, 95, 135, 175, 215, 255]; | ||
| 43 | for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++) | ||
| 44 | p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`); | ||
| 45 | for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); } | ||
| 46 | return p; | ||
| 47 | })(); | ||
| 48 | |||
| 49 | function colorOf(packed, dflt) { | ||
| 50 | const kind = packed >>> 24; | ||
| 51 | if (kind === 1) return PALETTE[packed & 0xff]; | ||
| 52 | if (kind === 2) return `rgb(${(packed >> 16) & 0xff},${(packed >> 8) & 0xff},${packed & 0xff})`; | ||
| 53 | return dflt; | ||
| 54 | } | ||
| 55 | |||
| 56 | // One cell's font metrics, measured once against the real font. | ||
| 57 | const METRICS = (() => { | ||
| 58 | const c = document.createElement('canvas').getContext('2d'); | ||
| 59 | c.font = FONT; | ||
| 60 | const m = c.measureText('M'); | ||
| 61 | const w = Math.ceil(m.width); | ||
| 62 | const h = Math.ceil((m.fontBoundingBoxAscent || 11) + (m.fontBoundingBoxDescent || 3)); | ||
| 63 | return { w, h, ascent: Math.ceil(m.fontBoundingBoxAscent || 11) }; | ||
| 64 | })(); | ||
| 65 | |||
| 66 | let compiledCore = null; // one compile, one instance per tile | ||
| 67 | |||
| 68 | class Tile { | ||
| 69 | constructor(idx, label, wallEl) { | ||
| 70 | this.idx = idx; | ||
| 71 | this.label = label; | ||
| 72 | this.zoomed = false; | ||
| 73 | this.scrollPages = 0; | ||
| 74 | this.gotState = false; // JS mirror of the CLI's state_since_attach | ||
| 75 | |||
| 76 | this.el = document.createElement('div'); | ||
| 77 | this.el.className = 'tile'; | ||
| 78 | this.el.innerHTML = | ||
| 79 | `<header><span class="label"></span><span class="badge connecting">connecting</span></header>`; | ||
| 80 | this.el.querySelector('.label').textContent = `${idx}: ${label}`; | ||
| 81 | this.canvas = document.createElement('canvas'); | ||
| 82 | this.el.appendChild(this.canvas); | ||
| 83 | this.ctx = this.canvas.getContext('2d'); | ||
| 84 | wallEl.appendChild(this.el); | ||
| 85 | |||
| 86 | this.el.addEventListener('click', (ev) => { | ||
| 87 | if (!this.zoomed) { ev.stopPropagation(); zoom(this); } | ||
| 88 | }); | ||
| 89 | this.el.addEventListener('wheel', (ev) => { | ||
| 90 | if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); } | ||
| 91 | // Wall tiles don't scroll (spec): the event falls through to the page. | ||
| 92 | }, { passive: false }); | ||
| 93 | } | ||
| 94 | |||
| 95 | async start() { | ||
| 96 | const { instance } = await WebAssembly.instantiate(compiledCore, {}); | ||
| 97 | this.core = instance.exports; | ||
| 98 | if (this.core.mux_init(80, 24) !== 0) { this.setBadge('gone', 'init failed'); return; } | ||
| 99 | this.sizeCanvas(); | ||
| 100 | |||
| 101 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); | ||
| 102 | this.ws.binaryType = 'arraybuffer'; | ||
| 103 | this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data)); | ||
| 104 | this.ws.onclose = () => this.setBadge('gone', 'gone'); | ||
| 105 | this.ws.onerror = () => this.setBadge('gone', 'gone'); | ||
| 106 | } | ||
| 107 | |||
| 108 | // --- wasm memory access, always through fresh views --- | ||
| 109 | mem() { return new Uint8Array(this.core.memory.buffer); } | ||
| 110 | stage(bytes) { | ||
| 111 | if (bytes.length > this.core.mux_input_cap()) return false; | ||
| 112 | this.mem().set(bytes, this.core.mux_input_ptr()); | ||
| 113 | return true; | ||
| 114 | } | ||
| 115 | outBytes() { | ||
| 116 | const ptr = this.core.mux_output_ptr(), len = this.core.mux_output_len(); | ||
| 117 | return this.mem().slice(ptr, ptr + len); | ||
| 118 | } | ||
| 119 | |||
| 120 | // --- wire out --- | ||
| 121 | sendFrame(type, payload) { | ||
| 122 | if (this.ws.readyState !== WebSocket.OPEN) return; | ||
| 123 | const msg = new Uint8Array(6 + payload.length); | ||
| 124 | msg[0] = ENV_FRAME; | ||
| 125 | msg[1] = type; | ||
| 126 | new DataView(msg.buffer).setUint32(2, payload.length, true); | ||
| 127 | msg.set(payload, 6); | ||
| 128 | this.ws.send(msg); | ||
| 129 | } | ||
| 130 | sendAttach(fresh) { | ||
| 131 | // THE PASSIVITY CONTRACT (spec amendment 1): a wall tile attaches at | ||
| 132 | // 1x1. The daemon refuses the degenerate size, answers a unicast | ||
| 133 | // snapshot carrying the true grid, and the slot stays 0x0 forever — | ||
| 134 | // this tile can never move the shared session. Only the zoomed tile | ||
| 135 | // claims its real size. | ||
| 136 | const cols = this.zoomed ? this.zoomCols() : 1; | ||
| 137 | const rows = this.zoomed ? this.zoomRows() : 1; | ||
| 138 | const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); | ||
| 139 | if (n > 0) { this.gotState = false; this.sendFrame(MSG.attach, this.outBytes()); } | ||
| 140 | } | ||
| 141 | sendKey(keyId, cp, mods) { | ||
| 142 | const n = this.core.mux_key_encode(keyId, cp, mods); | ||
| 143 | if (n > 0) this.sendFrame(MSG.input, this.outBytes()); | ||
| 144 | } | ||
| 145 | sendPaste(text) { | ||
| 146 | const bytes = new TextEncoder().encode(text); | ||
| 147 | for (let off = 0; off < bytes.length; off += PASTE_CHUNK) { | ||
| 148 | const chunk = bytes.subarray(off, off + PASTE_CHUNK); | ||
| 149 | if (!this.stage(chunk)) return; | ||
| 150 | if (this.core.mux_paste_encode(chunk.length) > 0) | ||
| 151 | this.sendFrame(MSG.input, this.outBytes()); | ||
| 152 | } | ||
| 153 | } | ||
| 154 | sendResizeIfDiffers() { | ||
| 155 | // Only the zoomed tile, and only when its computed size actually | ||
| 156 | // differs from the session's grid (spec: passive wall, latest-wins | ||
| 157 | // respected). | ||
| 158 | if (!this.zoomed) return; | ||
| 159 | const cols = this.zoomCols(), rows = this.zoomRows(); | ||
| 160 | if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return; | ||
| 161 | const p = new Uint8Array(4); | ||
| 162 | const dv = new DataView(p.buffer); | ||
| 163 | dv.setUint16(0, cols, true); | ||
| 164 | dv.setUint16(2, rows, true); | ||
| 165 | this.sendFrame(MSG.resize, p); | ||
| 166 | } | ||
| 167 | zoomCols() { return Math.max(2, Math.floor(window.innerWidth / METRICS.w)); } | ||
| 168 | zoomRows() { return Math.max(2, Math.floor((window.innerHeight - 26) / METRICS.h)); } | ||
| 169 | |||
| 170 | // --- wire in --- | ||
| 171 | onMessage(bytes) { | ||
| 172 | if (bytes.length < 1) return; | ||
| 173 | if (bytes[0] === ENV_CONTROL) { | ||
| 174 | const { state } = JSON.parse(new TextDecoder().decode(bytes.subarray(1))); | ||
| 175 | this.setBadge(state, state); | ||
| 176 | if (state === 'up') this.sendAttach(false); // browser owns re-attach | ||
| 177 | return; | ||
| 178 | } | ||
| 179 | if (bytes[0] !== ENV_FRAME || bytes.length < 6) return; | ||
| 180 | const type = bytes[1]; | ||
| 181 | const len = new DataView(bytes.buffer, bytes.byteOffset).getUint32(2, true); | ||
| 182 | if (6 + len !== bytes.length) return; | ||
| 183 | const payload = bytes.subarray(6); | ||
| 184 | |||
| 185 | switch (type) { | ||
| 186 | case MSG.snapshot: | ||
| 187 | case MSG.delta: { | ||
| 188 | if (!this.stage(payload)) return; | ||
| 189 | const r = this.core.mux_apply_frame(type, payload.length); | ||
| 190 | if (r === 0) { | ||
| 191 | this.gotState = true; | ||
| 192 | if (this.badgeIs('full')) this.setBadge('up', 'up'); | ||
| 193 | if (this.scrollPages === 0) this.paintLive(); | ||
| 194 | return; | ||
| 195 | } | ||
| 196 | if (r === 1) this.sendAttach(true); // RESYNC: quote (0,0) | ||
| 197 | return; | ||
| 198 | } | ||
| 199 | case MSG.exit_status: { | ||
| 200 | // Before any state this is the daemon refusing the attach | ||
| 201 | // (session full) — the CLI's own discriminator, mirrored. | ||
| 202 | if (!this.gotState) this.setBadge('full', 'session full'); | ||
| 203 | else this.setBadge('exited', `exited ${payload[0] ?? 0}`); | ||
| 204 | return; | ||
| 205 | } | ||
| 206 | case MSG.scrollback_chunk: { | ||
| 207 | if (this.scrollPages === 0 || payload.length < 6) return; | ||
| 208 | const rows = payload.subarray(6); // echoed start+count stripped | ||
| 209 | if (!this.stage(rows)) return; | ||
| 210 | if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll(); | ||
| 211 | return; | ||
| 212 | } | ||
| 213 | case MSG.pty_mode: // no prediction in the web client (spec non-goal) | ||
| 214 | default: | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | } | ||
| 218 | |||
| 219 | // --- painting --- | ||
| 220 | sizeCanvas() { | ||
| 221 | const cols = this.core.mux_cols(), rows = this.core.mux_rows(); | ||
| 222 | const w = cols * METRICS.w, h = rows * METRICS.h; | ||
| 223 | if (this.canvas.width !== w || this.canvas.height !== h) { | ||
| 224 | this.canvas.width = w; | ||
| 225 | this.canvas.height = h; | ||
| 226 | this.core.mux_mark_all_dirty(); | ||
| 227 | } | ||
| 228 | } | ||
| 229 | paintLive() { | ||
| 230 | this.sizeCanvas(); | ||
| 231 | const n = this.core.mux_read_viewport(); | ||
| 232 | for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i)); | ||
| 233 | this.paintCursor(); | ||
| 234 | } | ||
| 235 | paintScroll() { | ||
| 236 | this.sizeCanvas(); | ||
| 237 | this.core.mux_read_scroll_viewport(); | ||
| 238 | const rows = this.core.mux_rows(); | ||
| 239 | for (let y = 0; y < rows; y++) this.paintRow(y); | ||
| 240 | } | ||
| 241 | paintRow(y) { | ||
| 242 | const cols = this.core.mux_cols(); | ||
| 243 | const base = this.core.mux_viewport_ptr() + y * cols * 16; | ||
| 244 | // Fresh view per row paint; see the header discipline. | ||
| 245 | const cells = new DataView(this.core.memory.buffer, base, cols * 16); | ||
| 246 | const ctx = this.ctx; | ||
| 247 | ctx.font = FONT; | ||
| 248 | ctx.textBaseline = 'alphabetic'; | ||
| 249 | for (let x = 0; x < cols; x++) { | ||
| 250 | const cp = cells.getUint32(x * 16, true); | ||
| 251 | const fg = cells.getUint32(x * 16 + 4, true); | ||
| 252 | const bg = cells.getUint32(x * 16 + 8, true); | ||
| 253 | const flags = cells.getUint32(x * 16 + 12, true); | ||
| 254 | if (flags & (1 << 17)) continue; // spacer: the wide cell painted it | ||
| 255 | const wide = (flags & (1 << 16)) !== 0; | ||
| 256 | const inverse = (flags & (1 << 4)) !== 0; | ||
| 257 | let fgC = colorOf(fg, DEFAULT_FG), bgC = colorOf(bg, DEFAULT_BG); | ||
| 258 | if (inverse) { const t = fgC; fgC = bgC; bgC = t; } | ||
| 259 | const px = x * METRICS.w, py = y * METRICS.h, cw = METRICS.w * (wide ? 2 : 1); | ||
| 260 | ctx.fillStyle = bgC; | ||
| 261 | ctx.fillRect(px, py, cw, METRICS.h); | ||
| 262 | if (cp !== 0 && cp !== 32) { | ||
| 263 | const bold = (flags & 1) !== 0, faint = (flags & 4) !== 0, italic = (flags & 2) !== 0; | ||
| 264 | ctx.fillStyle = fgC; | ||
| 265 | ctx.globalAlpha = faint ? 0.6 : 1; | ||
| 266 | ctx.font = `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${FONT}`; | ||
| 267 | ctx.fillText(String.fromCodePoint(cp), px, py + METRICS.ascent); | ||
| 268 | ctx.globalAlpha = 1; | ||
| 269 | ctx.font = FONT; | ||
| 270 | } | ||
| 271 | const ustyle = (flags >> 8) & 7; | ||
| 272 | if (ustyle !== 0) { | ||
| 273 | ctx.fillStyle = fgC; | ||
| 274 | ctx.fillRect(px, py + METRICS.h - 2, cw, ustyle === 2 ? 2 : 1); // double→thick | ||
| 275 | } | ||
| 276 | if (flags & (1 << 6)) { // strikethrough | ||
| 277 | ctx.fillStyle = fgC; | ||
| 278 | ctx.fillRect(px, py + (METRICS.h >> 1), cw, 1); | ||
| 279 | } | ||
| 280 | } | ||
| 281 | } | ||
| 282 | paintCursor() { | ||
| 283 | if (this.scrollPages !== 0) return; | ||
| 284 | const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y(); | ||
| 285 | this.ctx.fillStyle = '#c8ccd488'; | ||
| 286 | this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h); | ||
| 287 | } | ||
| 288 | |||
| 289 | // --- scrollback --- | ||
| 290 | onWheel(ev) { | ||
| 291 | const rows = this.core.mux_rows(); | ||
| 292 | const hist = this.core.mux_history_rows(); | ||
| 293 | if (ev.deltaY < 0) { | ||
| 294 | const maxPages = Math.ceil(hist / rows); | ||
| 295 | if (this.scrollPages < maxPages) this.scrollPages++; | ||
| 296 | else return; | ||
| 297 | } else { | ||
| 298 | if (this.scrollPages === 0) return; | ||
| 299 | this.scrollPages--; | ||
| 300 | } | ||
| 301 | if (this.scrollPages === 0) { | ||
| 302 | this.setBadge('up', 'up'); | ||
| 303 | this.core.mux_mark_all_dirty(); | ||
| 304 | this.paintLive(); | ||
| 305 | return; | ||
| 306 | } | ||
| 307 | this.setBadge('scroll', `history -${this.scrollPages}`); | ||
| 308 | const start = this.core.mux_scroll_start(this.scrollPages, rows); | ||
| 309 | const p = new Uint8Array(6); | ||
| 310 | const dv = new DataView(p.buffer); | ||
| 311 | dv.setUint32(0, start, true); | ||
| 312 | dv.setUint16(4, rows, true); | ||
| 313 | this.sendFrame(MSG.fetch_scrollback, p); | ||
| 314 | } | ||
| 315 | |||
| 316 | exitScroll() { | ||
| 317 | if (this.scrollPages === 0) return; | ||
| 318 | this.scrollPages = 0; | ||
| 319 | this.setBadge('up', 'up'); | ||
| 320 | this.core.mux_mark_all_dirty(); | ||
| 321 | this.paintLive(); | ||
| 322 | } | ||
| 323 | |||
| 324 | // --- chrome --- | ||
| 325 | setBadge(cls, text) { | ||
| 326 | const b = this.el.querySelector('.badge'); | ||
| 327 | b.className = `badge ${cls}`; | ||
| 328 | b.textContent = text; | ||
| 329 | } | ||
| 330 | badgeIs(cls) { return this.el.querySelector('.badge').classList.contains(cls); } | ||
| 331 | } | ||
| 332 | |||
| 333 | // --- zoom / focus --- | ||
| 334 | let zoomedTile = null; | ||
| 335 | const shade = document.getElementById('shade'); | ||
| 336 | const ime = document.getElementById('ime'); | ||
| 337 | |||
| 338 | function zoom(tile) { | ||
| 339 | if (zoomedTile) unzoom(); | ||
| 340 | zoomedTile = tile; | ||
| 341 | tile.zoomed = true; | ||
| 342 | tile.el.classList.add('zoomed'); | ||
| 343 | shade.classList.add('on'); | ||
| 344 | ime.focus(); | ||
| 345 | tile.sendResizeIfDiffers(); | ||
| 346 | } | ||
| 347 | function unzoom() { | ||
| 348 | if (!zoomedTile) return; | ||
| 349 | zoomedTile.exitScroll(); | ||
| 350 | zoomedTile.zoomed = false; | ||
| 351 | zoomedTile.el.classList.remove('zoomed'); | ||
| 352 | zoomedTile = null; | ||
| 353 | shade.classList.remove('on'); | ||
| 354 | ime.blur(); | ||
| 355 | // Unzoom sends NOTHING: the session stays attached, the grid stays | ||
| 356 | // where it is (spec). The tile keeps painting as a wall tile. | ||
| 357 | } | ||
| 358 | shade.addEventListener('click', unzoom); | ||
| 359 | |||
| 360 | // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). | ||
| 361 | document.addEventListener('keydown', (ev) => { | ||
| 362 | const t = zoomedTile; | ||
| 363 | if (!t) return; | ||
| 364 | if (ev.isComposing) return; // IME owns it; compositionend delivers | ||
| 365 | const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0); | ||
| 366 | // Leave genuine browser chords alone (copy/paste arrive as events). | ||
| 367 | if (ev.ctrlKey && ev.shiftKey && (ev.key.toLowerCase() === 'c' || ev.key.toLowerCase() === 'v')) return; | ||
| 368 | if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') { | ||
| 369 | t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule) | ||
| 370 | ev.preventDefault(); | ||
| 371 | return; | ||
| 372 | } | ||
| 373 | if (KEY[ev.key] !== undefined && ev.key !== 'char') { | ||
| 374 | ev.preventDefault(); | ||
| 375 | t.sendKey(KEY[ev.key], 0, mods); | ||
| 376 | return; | ||
| 377 | } | ||
| 378 | if ([...ev.key].length === 1) { | ||
| 379 | ev.preventDefault(); | ||
| 380 | t.sendKey(KEY.char, ev.key.codePointAt(0), mods); | ||
| 381 | } | ||
| 382 | }); | ||
| 383 | document.addEventListener('paste', (ev) => { | ||
| 384 | if (!zoomedTile) return; | ||
| 385 | ev.preventDefault(); | ||
| 386 | const text = ev.clipboardData?.getData('text'); | ||
| 387 | if (text) zoomedTile.sendPaste(text); | ||
| 388 | }); | ||
| 389 | ime.addEventListener('compositionend', (ev) => { | ||
| 390 | if (zoomedTile && ev.data) zoomedTile.sendPaste(ev.data); | ||
| 391 | ime.value = ''; | ||
| 392 | }); | ||
| 393 | window.addEventListener('resize', () => zoomedTile?.sendResizeIfDiffers()); | ||
| 394 | |||
| 395 | // --- boot --- | ||
| 396 | (async function boot() { | ||
| 397 | compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); | ||
| 398 | const labels = await (await fetch('/tiles')).json(); | ||
| 399 | const wall = document.getElementById('wall'); | ||
| 400 | for (let i = 0; i < labels.length; i++) { | ||
| 401 | const tile = new Tile(i, labels[i], wall); | ||
| 402 | tile.start(); | ||
| 403 | } | ||
| 404 | })(); | ||