14cef649
fix(web): the page never ran; unzoom was unreachable; a paste wrapped every chunk
a73x 2026-08-13 12:21
Commit message
src/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -330,18 +330,50 @@ export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 { | |||
| 330 | return @intCast(seq.len); | 330 | return @intCast(seq.len); |
| 331 | } | 331 | } |
| 332 | 332 | ||
| 333 | /// Wrap `len` staged bytes in bracketed paste into the output buffer. | 333 | /// `len` staged bytes to the output buffer, UNCHANGED. Two callers, and |
| 334 | /// The shell chunks pastes at 32 KiB, so the wrap always fits the 64 KiB | 334 | /// the distinction between them is the point: |
| 335 | /// output buffer; -2 if the host ignored that contract. | 335 | /// |
| 336 | export fn mux_paste_encode(len: u32) i32 { | 336 | /// - An IME's compositionend hands over finished text, and finished |
| 337 | if (len > input_buf.len) return -2; | 337 | /// text is TYPING. Bracketing it would tell the application a human |
| 338 | const total = keymap.paste_begin.len + len + keymap.paste_end.len; | 338 | /// did not write it — vim would skip paste mode's indentation, a |
| 339 | if (total > output_buf.len) return -2; | 339 | /// shell's bracketed-paste guard would refuse to run it — so composed |
| 340 | /// text goes out raw, exactly as the keystrokes it stands in for. | ||
| 341 | /// - The body chunks of a real paste, between the markers below. | ||
| 342 | /// | ||
| 343 | /// -2 if the host staged more than either buffer holds. | ||
| 344 | export fn mux_text_encode(len: u32) i32 { | ||
| 345 | if (len > input_buf.len or len > output_buf.len) return -2; | ||
| 346 | @memcpy(output_buf[0..len], input_buf[0..len]); | ||
| 347 | output_len = len; | ||
| 348 | return @intCast(len); | ||
| 349 | } | ||
| 350 | |||
| 351 | /// The bracketed-paste markers, each on its own, because a paste too big | ||
| 352 | /// for one message is still ONE paste: the host sends begin, then N | ||
| 353 | /// unwrapped chunks through mux_text_encode, then end. | ||
| 354 | /// | ||
| 355 | /// keymap.pasteInto's contract is "the wrap and nothing else" around the | ||
| 356 | /// WHOLE paste, and this is the code finally matching that doc. Wrapping | ||
| 357 | /// each chunk instead — which is what the shell used to do — put a | ||
| 358 | /// paste-END in the middle of the pasted text, and an application that | ||
| 359 | /// acts on the marker acts on it right there: vim leaves paste mode | ||
| 360 | /// 32 KiB in and re-indents the rest. | ||
| 361 | /// | ||
| 362 | /// UNCONDITIONAL, a known v1 limitation: the daemon's pty_mode frame | ||
| 363 | /// carries only icanon/echo, so the hub cannot know whether the | ||
| 364 | /// application asked for mode 2004. An application that did not ask sees | ||
| 365 | /// the literal markers. Teaching pty_mode to carry the bit is a daemon | ||
| 366 | /// protocol change and is deferred. | ||
| 367 | export fn mux_paste_begin() i32 { | ||
| 340 | @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin); | 368 | @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin); |
| 341 | @memcpy(output_buf[keymap.paste_begin.len..][0..len], input_buf[0..len]); | 369 | output_len = @intCast(keymap.paste_begin.len); |
| 342 | @memcpy(output_buf[keymap.paste_begin.len + len ..][0..keymap.paste_end.len], keymap.paste_end); | 370 | return @intCast(keymap.paste_begin.len); |
| 343 | output_len = @intCast(total); | 371 | } |
| 344 | return @intCast(total); | 372 | |
| 373 | export fn mux_paste_end() i32 { | ||
| 374 | @memcpy(output_buf[0..keymap.paste_end.len], keymap.paste_end); | ||
| 375 | output_len = @intCast(keymap.paste_end.len); | ||
| 376 | return @intCast(keymap.paste_end.len); | ||
| 345 | } | 377 | } |
| 346 | 378 | ||
| 347 | // --------------------------------------------------------------------- | 379 | // --------------------------------------------------------------------- |
web/index.html
| Old | New | ||
|---|---|---|---|
| @@ -32,15 +32,23 @@ | |||
| 32 | .badge.up { background: #16351f; color: #6fce8a; } | 32 | .badge.up { background: #16351f; color: #6fce8a; } |
| 33 | .badge.gone, .badge.exited, .badge.full { background: #3d1a1a; color: #e07a7a; } | 33 | .badge.gone, .badge.exited, .badge.full { background: #3d1a1a; color: #e07a7a; } |
| 34 | .badge.scroll { background: #1a2c3d; color: #6ab0e0; } | 34 | .badge.scroll { background: #1a2c3d; color: #6ab0e0; } |
| 35 | .tile canvas { display: block; width: 100%; height: auto; background: #000; } | 35 | /* mux.js sets width/height in CSS pixels and sizes the backing store to |
| 36 | /* The zoomed tile: same element, promoted. 1:1 pixels, keys go here. */ | 36 | that times devicePixelRatio, so glyphs rasterize at device resolution |
| 37 | instead of being a downscaled bitmap. No width:100% here: it would | ||
| 38 | stretch the backing store back out and undo exactly that. */ | ||
| 39 | .tile canvas { display: block; background: #000; } | ||
| 40 | /* The zoomed tile: same element, promoted. Keys go here. | ||
| 41 | INSET, not inset:0 — the ring of shade left visible around it is the | ||
| 42 | only way back out (clicking it unzooms), and a tile covering the | ||
| 43 | viewport made unzoom unreachable. Escape is deliberately NOT bound: | ||
| 44 | it belongs to the application, and vim needs it. */ | ||
| 37 | .tile.zoomed { | 45 | .tile.zoomed { |
| 38 | position: fixed; inset: 0; z-index: 10; border-radius: 0; cursor: default; | 46 | position: fixed; inset: 3vh 4vw; z-index: 20; cursor: default; |
| 39 | border: none; | ||
| 40 | } | 47 | } |
| 41 | .tile.zoomed canvas { width: auto; height: auto; margin: 0 auto; } | 48 | .tile.zoomed canvas { margin: 0 auto; } |
| 49 | /* Below the zoomed tile, above the wall: the click target for unzoom. */ | ||
| 42 | #shade { | 50 | #shade { |
| 43 | display: none; position: fixed; inset: 0; z-index: 5; background: #000a; | 51 | display: none; position: fixed; inset: 0; z-index: 10; background: #000a; |
| 44 | } | 52 | } |
| 45 | #shade.on { display: block; } | 53 | #shade.on { display: block; } |
| 46 | /* IME target: focusable, invisible, never display:none (that kills IME). */ | 54 | /* IME target: focusable, invisible, never display:none (that kills IME). */ |
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -29,6 +29,21 @@ const KEY = { | |||
| 29 | // 64 KiB inbound bound (webhub.zig ws_buffer_len). | 29 | // 64 KiB inbound bound (webhub.zig ws_buffer_len). |
| 30 | const PASTE_CHUNK = 32 * 1024; | 30 | const PASTE_CHUNK = 32 * 1024; |
| 31 | 31 | ||
| 32 | // The reconnect schedule, and it is NOT a new one: this is client.zig's | ||
| 33 | // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers") | ||
| 34 | // spelled in JS because the page has no Zig. Iteration zero waits not at | ||
| 35 | // all, then 200ms doubling to a 2s cap, and no retry cap ever. The hub | ||
| 36 | // paces its daemon leg by that function; the browser leg pacing itself | ||
| 37 | // differently would make one flapping link behave two ways depending on | ||
| 38 | // which half broke. Change both or neither. | ||
| 39 | const nextBackoffMs = (prev) => (prev === 0 ? 200 : Math.min(prev * 2, 2000)); | ||
| 40 | |||
| 41 | // How many straight failures to OPEN before a tile reads 'gone' rather | ||
| 42 | // than 'reconnecting'. Four is where the schedule hits its cap | ||
| 43 | // (0+200+400+800 = 1.4s of trying), which is long enough that restarting | ||
| 44 | // the hub under a live page never flashes 'gone'. | ||
| 45 | const GONE_AFTER_FAILURES = 4; | ||
| 46 | |||
| 32 | const FONT = '14px ui-monospace, monospace'; | 47 | const FONT = '14px ui-monospace, monospace'; |
| 33 | const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000'; | 48 | const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000'; |
| 34 | 49 | ||
| @@ -72,6 +87,10 @@ class Tile { | |||
| 72 | this.zoomed = false; | 87 | this.zoomed = false; |
| 73 | this.scrollPages = 0; | 88 | this.scrollPages = 0; |
| 74 | this.gotState = false; // JS mirror of the CLI's state_since_attach | 89 | this.gotState = false; // JS mirror of the CLI's state_since_attach |
| 90 | this.wsBackoffMs = 0; // browser-leg reconnect, client.zig's schedule | ||
| 91 | this.wsFailures = 0; // straight failures to OPEN, for the badge | ||
| 92 | this.wsOpened = false; // did THIS socket ever open? | ||
| 93 | this.drawScale = 0; // logical→CSS factor the backing store is sized for | ||
| 75 | 94 | ||
| 76 | this.el = document.createElement('div'); | 95 | this.el = document.createElement('div'); |
| 77 | this.el.className = 'tile'; | 96 | this.el.className = 'tile'; |
| @@ -93,16 +112,63 @@ class Tile { | |||
| 93 | } | 112 | } |
| 94 | 113 | ||
| 95 | async start() { | 114 | async start() { |
| 96 | const { instance } = await WebAssembly.instantiate(compiledCore, {}); | 115 | // NOT `const { instance }`: WebAssembly.instantiate has two return |
| 116 | // shapes, and which one you get depends on what you passed. Given | ||
| 117 | // BYTES it resolves to {module, instance}; given an already-compiled | ||
| 118 | // Module — which is what compiledCore is — it resolves to the | ||
| 119 | // Instance itself. Destructuring `instance` off an Instance yields | ||
| 120 | // undefined, and the TypeError lands inside an async method whose | ||
| 121 | // rejection nobody was reading. (verify.js passes bytes, so it takes | ||
| 122 | // the other shape and is right to destructure.) | ||
| 123 | const instance = await WebAssembly.instantiate(compiledCore, {}); | ||
| 97 | this.core = instance.exports; | 124 | this.core = instance.exports; |
| 98 | if (this.core.mux_init(80, 24) !== 0) { this.setBadge('gone', 'init failed'); return; } | 125 | if (this.core.mux_init(80, 24) !== 0) { this.setBadge('gone', 'init failed'); return; } |
| 99 | this.sizeCanvas(); | 126 | this.sizeCanvas(); |
| 127 | this.connect(); | ||
| 128 | } | ||
| 100 | 129 | ||
| 130 | // The browser leg's own reconnect. The hub reconnects its daemon leg | ||
| 131 | // and narrates it; nothing was reconnecting THIS socket, so a restarted | ||
| 132 | // hub (or a laptop that slept) left every tile permanently 'gone' with | ||
| 133 | // a reload as the only cure. The wasm core survives across this, so the | ||
| 134 | // re-attach on `up` still quotes have_seq/have_epoch and resumes. | ||
| 135 | connect() { | ||
| 136 | this.wsOpened = false; | ||
| 101 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); | 137 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); |
| 102 | this.ws.binaryType = 'arraybuffer'; | 138 | this.ws.binaryType = 'arraybuffer'; |
| 139 | this.ws.onopen = () => { | ||
| 140 | this.wsOpened = true; | ||
| 141 | this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule | ||
| 142 | this.wsFailures = 0; | ||
| 143 | // The hub narrates from here on: connecting → up. | ||
| 144 | this.setBadge('connecting', 'connecting'); | ||
| 145 | }; | ||
| 103 | this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data)); | 146 | this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data)); |
| 104 | this.ws.onclose = () => this.setBadge('gone', 'gone'); | 147 | // onerror always precedes onclose; the badge is decided in one place. |
| 105 | this.ws.onerror = () => this.setBadge('gone', 'gone'); | 148 | this.ws.onerror = () => {}; |
| 149 | this.ws.onclose = () => { | ||
| 150 | this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; | ||
| 151 | const dead = this.wsFailures >= GONE_AFTER_FAILURES; | ||
| 152 | this.setBadge(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); | ||
| 153 | const wait = this.wsBackoffMs; | ||
| 154 | this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs); | ||
| 155 | setTimeout(() => this.connect(), wait); | ||
| 156 | }; | ||
| 157 | } | ||
| 158 | |||
| 159 | // The core is unusable — re-init and re-attach from nothing. Everything | ||
| 160 | // it held is gone, so the attach quotes (0,0) and the daemon answers | ||
| 161 | // with a snapshot. | ||
| 162 | resetCore(why) { | ||
| 163 | console.warn(`mux tile ${this.idx}: re-initializing the core (${why})`); | ||
| 164 | if (this.core.mux_init(80, 24) !== 0) { | ||
| 165 | this.setBadge('gone', 'core failed'); | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | this.gotState = false; | ||
| 169 | this.scrollPages = 0; | ||
| 170 | this.drawScale = 0; // force the backing store to be re-sized | ||
| 171 | this.sendAttach(true); | ||
| 106 | } | 172 | } |
| 107 | 173 | ||
| 108 | // --- wasm memory access, always through fresh views --- | 174 | // --- wasm memory access, always through fresh views --- |
| @@ -142,15 +208,40 @@ class Tile { | |||
| 142 | const n = this.core.mux_key_encode(keyId, cp, mods); | 208 | const n = this.core.mux_key_encode(keyId, cp, mods); |
| 143 | if (n > 0) this.sendFrame(MSG.input, this.outBytes()); | 209 | if (n > 0) this.sendFrame(MSG.input, this.outBytes()); |
| 144 | } | 210 | } |
| 145 | sendPaste(text) { | 211 | // Plain UTF-8 bytes, no wrap. This is TYPING: an IME's compositionend |
| 212 | // hands over finished text, and finished text is what the user typed, | ||
| 213 | // however they reached it. Bracketing it would announce a paste to the | ||
| 214 | // application — vim would skip paste mode's indentation, a shell's | ||
| 215 | // bracketed-paste guard would refuse to run it. | ||
| 216 | sendText(text) { | ||
| 146 | const bytes = new TextEncoder().encode(text); | 217 | const bytes = new TextEncoder().encode(text); |
| 147 | for (let off = 0; off < bytes.length; off += PASTE_CHUNK) { | 218 | for (let off = 0; off < bytes.length; off += PASTE_CHUNK) { |
| 148 | const chunk = bytes.subarray(off, off + PASTE_CHUNK); | 219 | const chunk = bytes.subarray(off, off + PASTE_CHUNK); |
| 149 | if (!this.stage(chunk)) return; | 220 | if (!this.stage(chunk)) return; |
| 150 | if (this.core.mux_paste_encode(chunk.length) > 0) | 221 | if (this.core.mux_text_encode(chunk.length) > 0) |
| 151 | this.sendFrame(MSG.input, this.outBytes()); | 222 | this.sendFrame(MSG.input, this.outBytes()); |
| 152 | } | 223 | } |
| 153 | } | 224 | } |
| 225 | // ONE wrap around the WHOLE paste, however many messages it takes: | ||
| 226 | // begin, N unwrapped chunks, end. keymap.zig's pasteInto says "the wrap | ||
| 227 | // and nothing else"; wrapping each 32 KiB chunk (which is what this did) | ||
| 228 | // put a paste-END in the middle of the pasted text, and an application | ||
| 229 | // that acts on the marker acts on it right there. | ||
| 230 | // | ||
| 231 | // The wrap is UNCONDITIONAL — a known v1 limitation. The daemon's | ||
| 232 | // pty_mode frame carries icanon/echo and not mode 2004, so nothing here | ||
| 233 | // can tell whether the application asked for brackets; one that did not | ||
| 234 | // sees the literal markers. | ||
| 235 | sendPaste(text) { | ||
| 236 | if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes()); | ||
| 237 | // Unconditionally closed, including when a chunk fails to stage: | ||
| 238 | // leaving the application in paste mode is worse than a short paste. | ||
| 239 | try { | ||
| 240 | this.sendText(text); | ||
| 241 | } finally { | ||
| 242 | if (this.core.mux_paste_end() > 0) this.sendFrame(MSG.input, this.outBytes()); | ||
| 243 | } | ||
| 244 | } | ||
| 154 | sendResizeIfDiffers() { | 245 | sendResizeIfDiffers() { |
| 155 | // Only the zoomed tile, and only when its computed size actually | 246 | // Only the zoomed tile, and only when its computed size actually |
| 156 | // differs from the session's grid (spec: passive wall, latest-wins | 247 | // differs from the session's grid (spec: passive wall, latest-wins |
| @@ -164,8 +255,19 @@ class Tile { | |||
| 164 | dv.setUint16(2, rows, true); | 255 | dv.setUint16(2, rows, true); |
| 165 | this.sendFrame(MSG.resize, p); | 256 | this.sendFrame(MSG.resize, p); |
| 166 | } | 257 | } |
| 167 | zoomCols() { return Math.max(2, Math.floor(window.innerWidth / METRICS.w)); } | 258 | // The zoomed tile's OWN box, not the window: index.html insets it |
| 168 | zoomRows() { return Math.max(2, Math.floor((window.innerHeight - 26) / METRICS.h)); } | 259 | // (3vh 4vw) so a ring of shade stays visible and clickable, and that |
| 260 | // ring is the only way out of zoom. Measuring the element rather than | ||
| 261 | // re-deriving the inset here means the CSS stays the single source of | ||
| 262 | // the geometry — change the inset and the grid follows. | ||
| 263 | zoomBox() { | ||
| 264 | const r = this.el.getBoundingClientRect(); | ||
| 265 | const hdr = this.el.querySelector('header').getBoundingClientRect().height; | ||
| 266 | // border-box: the rect includes the 1px border on each side. | ||
| 267 | return { w: Math.max(0, r.width - 2), h: Math.max(0, r.height - hdr - 2) }; | ||
| 268 | } | ||
| 269 | zoomCols() { return Math.max(2, Math.floor(this.zoomBox().w / METRICS.w)); } | ||
| 270 | zoomRows() { return Math.max(2, Math.floor(this.zoomBox().h / METRICS.h)); } | ||
| 169 | 271 | ||
| 170 | // --- wire in --- | 272 | // --- wire in --- |
| 171 | onMessage(bytes) { | 273 | onMessage(bytes) { |
| @@ -185,7 +287,20 @@ class Tile { | |||
| 185 | switch (type) { | 287 | switch (type) { |
| 186 | case MSG.snapshot: | 288 | case MSG.snapshot: |
| 187 | case MSG.delta: { | 289 | case MSG.delta: { |
| 188 | if (!this.stage(payload)) return; | 290 | // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone |
| 291 | // proves the attach was admitted — decodable or not — while a | ||
| 292 | // short snapshot proves nothing. Without this an undecodable | ||
| 293 | // delta followed by exit_status read as "session full" instead of | ||
| 294 | // as the shell exiting. | ||
| 295 | if (type === MSG.delta) this.gotState = true; | ||
| 296 | if (!this.stage(payload)) { | ||
| 297 | // Over the core's 256 KiB staging cap. Silently returning left | ||
| 298 | // the replica permanently behind the session; a re-attach at | ||
| 299 | // (0,0) costs one snapshot and is correct. | ||
| 300 | console.warn(`mux tile ${this.idx}: frame of ${payload.length} B over the staging cap, re-attaching`); | ||
| 301 | this.sendAttach(true); | ||
| 302 | return; | ||
| 303 | } | ||
| 189 | const r = this.core.mux_apply_frame(type, payload.length); | 304 | const r = this.core.mux_apply_frame(type, payload.length); |
| 190 | if (r === 0) { | 305 | if (r === 0) { |
| 191 | this.gotState = true; | 306 | this.gotState = true; |
| @@ -193,7 +308,13 @@ class Tile { | |||
| 193 | if (this.scrollPages === 0) this.paintLive(); | 308 | if (this.scrollPages === 0) this.paintLive(); |
| 194 | return; | 309 | return; |
| 195 | } | 310 | } |
| 196 | if (r === 1) this.sendAttach(true); // RESYNC: quote (0,0) | 311 | if (r === 1) { this.sendAttach(true); return; } // RESYNC: quote (0,0) |
| 312 | // Negative is the core refusing: -1 uninitialized or readout | ||
| 313 | // buffers left stale by a failed grid move (wasm_core.zig | ||
| 314 | // documents that one as fatal-re-init), -2 over the cap, -3 a | ||
| 315 | // payload it will not replay. None of them leave a replica that | ||
| 316 | // can be trusted to keep painting. | ||
| 317 | this.resetCore(`mux_apply_frame(0x${type.toString(16)}) = ${r}`); | ||
| 197 | return; | 318 | return; |
| 198 | } | 319 | } |
| 199 | case MSG.exit_status: { | 320 | case MSG.exit_status: { |
| @@ -217,14 +338,49 @@ class Tile { | |||
| 217 | } | 338 | } |
| 218 | 339 | ||
| 219 | // --- painting --- | 340 | // --- painting --- |
| 341 | // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it | ||
| 342 | // must be drawn small. Two ways to do that, and only one is legible: | ||
| 343 | // draw at full logical size into a big bitmap and let CSS squash it | ||
| 344 | // (every glyph resampled, twice over on a HiDPI screen), or draw | ||
| 345 | // THROUGH a scaled context so the glyphs rasterize once, at device | ||
| 346 | // resolution, at the size they will actually be seen. This is the | ||
| 347 | // second. Everything below paints in logical cell coordinates and the | ||
| 348 | // transform does the rest. | ||
| 220 | sizeCanvas() { | 349 | sizeCanvas() { |
| 221 | const cols = this.core.mux_cols(), rows = this.core.mux_rows(); | 350 | const cols = this.core.mux_cols(), rows = this.core.mux_rows(); |
| 222 | const w = cols * METRICS.w, h = rows * METRICS.h; | 351 | const logicalW = cols * METRICS.w, logicalH = rows * METRICS.h; |
| 223 | if (this.canvas.width !== w || this.canvas.height !== h) { | 352 | const dpr = window.devicePixelRatio || 1; |
| 224 | this.canvas.width = w; | 353 | // The width the layout gives this tile. The zoomed tile computed its |
| 225 | this.canvas.height = h; | 354 | // grid FROM that box, so it lands at scale 1; a wall tile scales down. |
| 226 | this.core.mux_mark_all_dirty(); | 355 | // Never up: stretching a terminal is not a feature. |
| 356 | const availW = this.zoomed ? this.zoomBox().w : this.el.clientWidth; | ||
| 357 | const scale = availW > 0 ? Math.min(1, availW / logicalW) : 1; | ||
| 358 | const cssW = logicalW * scale, cssH = logicalH * scale; | ||
| 359 | // The backing store is CSS pixels × devicePixelRatio: the resolution | ||
| 360 | // the screen can actually show, and no more. | ||
| 361 | const bw = Math.max(1, Math.round(cssW * dpr)); | ||
| 362 | const bh = Math.max(1, Math.round(cssH * dpr)); | ||
| 363 | if (this.canvas.width !== bw || this.canvas.height !== bh || this.drawScale !== scale) { | ||
| 364 | this.canvas.width = bw; | ||
| 365 | this.canvas.height = bh; | ||
| 366 | this.canvas.style.width = `${cssW}px`; | ||
| 367 | this.canvas.style.height = `${cssH}px`; | ||
| 368 | this.drawScale = scale; | ||
| 369 | this.core.mux_mark_all_dirty(); // assigning width cleared the bitmap | ||
| 227 | } | 370 | } |
| 371 | // Re-applied every paint, not just on resize: assigning canvas.width | ||
| 372 | // resets the transform, and a paint that skipped this would draw one | ||
| 373 | // grid's worth of cells into the top-left corner of the bitmap. | ||
| 374 | this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0); | ||
| 375 | } | ||
| 376 | // The tile's CSS width moved (a window resize reflows the wall grid): | ||
| 377 | // re-fit and repaint from what the core already holds. No frame is | ||
| 378 | // requested and nothing is sent — a wall tile is passive. | ||
| 379 | reflow() { | ||
| 380 | if (!this.core) return; | ||
| 381 | this.core.mux_mark_all_dirty(); | ||
| 382 | if (this.scrollPages === 0) this.paintLive(); | ||
| 383 | else this.paintScroll(); | ||
| 228 | } | 384 | } |
| 229 | paintLive() { | 385 | paintLive() { |
| 230 | this.sizeCanvas(); | 386 | this.sizeCanvas(); |
| @@ -343,18 +499,27 @@ function zoom(tile) { | |||
| 343 | shade.classList.add('on'); | 499 | shade.classList.add('on'); |
| 344 | ime.focus(); | 500 | ime.focus(); |
| 345 | tile.sendResizeIfDiffers(); | 501 | tile.sendResizeIfDiffers(); |
| 502 | // Re-fit now rather than at the next frame: an idle session sends | ||
| 503 | // nothing, and a tile that zoomed but kept its wall-sized canvas would | ||
| 504 | // sit there postage-stamped until something happened to be typed. | ||
| 505 | tile.reflow(); | ||
| 346 | } | 506 | } |
| 347 | function unzoom() { | 507 | function unzoom() { |
| 348 | if (!zoomedTile) return; | 508 | if (!zoomedTile) return; |
| 349 | zoomedTile.exitScroll(); | 509 | const was = zoomedTile; |
| 350 | zoomedTile.zoomed = false; | 510 | was.exitScroll(); |
| 351 | zoomedTile.el.classList.remove('zoomed'); | 511 | was.zoomed = false; |
| 512 | was.el.classList.remove('zoomed'); | ||
| 352 | zoomedTile = null; | 513 | zoomedTile = null; |
| 353 | shade.classList.remove('on'); | 514 | shade.classList.remove('on'); |
| 354 | ime.blur(); | 515 | ime.blur(); |
| 355 | // Unzoom sends NOTHING: the session stays attached, the grid stays | 516 | // Unzoom sends NOTHING: the session stays attached, the grid stays |
| 356 | // where it is (spec). The tile keeps painting as a wall tile. | 517 | // where it is (spec). The tile keeps painting as a wall tile — at the |
| 518 | // wall's scale, which is the one thing that does have to be redone. | ||
| 519 | was.reflow(); | ||
| 357 | } | 520 | } |
| 521 | // The visible ring of shade around the inset tile IS the unzoom control. | ||
| 522 | // Escape is deliberately not bound: it belongs to the application. | ||
| 358 | shade.addEventListener('click', unzoom); | 523 | shade.addEventListener('click', unzoom); |
| 359 | 524 | ||
| 360 | // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). | 525 | // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). |
| @@ -387,10 +552,18 @@ document.addEventListener('paste', (ev) => { | |||
| 387 | if (text) zoomedTile.sendPaste(text); | 552 | if (text) zoomedTile.sendPaste(text); |
| 388 | }); | 553 | }); |
| 389 | ime.addEventListener('compositionend', (ev) => { | 554 | ime.addEventListener('compositionend', (ev) => { |
| 390 | if (zoomedTile && ev.data) zoomedTile.sendPaste(ev.data); | 555 | // sendText, NOT sendPaste: composed text is typing (see sendText). |
| 556 | if (zoomedTile && ev.data) zoomedTile.sendText(ev.data); | ||
| 391 | ime.value = ''; | 557 | ime.value = ''; |
| 392 | }); | 558 | }); |
| 393 | window.addEventListener('resize', () => zoomedTile?.sendResizeIfDiffers()); | 559 | const tiles = []; |
| 560 | window.addEventListener('resize', () => { | ||
| 561 | // The zoomed tile may claim a new grid; every wall tile just re-fits to | ||
| 562 | // the width the reflowed grid gave it. | ||
| 563 | zoomedTile?.sendResizeIfDiffers(); | ||
| 564 | for (const t of tiles) if (t !== zoomedTile) t.reflow(); | ||
| 565 | zoomedTile?.reflow(); | ||
| 566 | }); | ||
| 394 | 567 | ||
| 395 | // --- boot --- | 568 | // --- boot --- |
| 396 | (async function boot() { | 569 | (async function boot() { |
| @@ -399,6 +572,13 @@ window.addEventListener('resize', () => zoomedTile?.sendResizeIfDiffers()); | |||
| 399 | const wall = document.getElementById('wall'); | 572 | const wall = document.getElementById('wall'); |
| 400 | for (let i = 0; i < labels.length; i++) { | 573 | for (let i = 0; i < labels.length; i++) { |
| 401 | const tile = new Tile(i, labels[i], wall); | 574 | const tile = new Tile(i, labels[i], wall); |
| 402 | tile.start(); | 575 | tiles.push(tile); |
| 576 | // start() is async: instantiate or mux_init can fail, and an unhandled | ||
| 577 | // rejection left the tile stuck on 'connecting' with the reason only | ||
| 578 | // in the console's rejection noise. | ||
| 579 | tile.start().catch((err) => { | ||
| 580 | tile.setBadge('gone', 'gone'); | ||
| 581 | console.error(`mux tile ${i} (${labels[i]}): start failed`, err); | ||
| 582 | }); | ||
| 403 | } | 583 | } |
| 404 | })(); | 584 | })(); |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -177,10 +177,31 @@ async function main() { | |||
| 177 | check('key ctrl-up seq', outBytes().toString('latin1'), '\x1b[1;5A'); | 177 | check('key ctrl-up seq', outBytes().toString('latin1'), '\x1b[1;5A'); |
| 178 | check('key unknown', e.mux_key_encode(99, 0, 0), -3); | 178 | check('key unknown', e.mux_key_encode(99, 0, 0), -3); |
| 179 | 179 | ||
| 180 | // --- paste wrap --- | 180 | // --- text vs paste: the markers are separate exports on purpose --- |
| 181 | // Composed IME text is TYPING and goes out raw; a paste is the same | ||
| 182 | // bytes with ONE wrap around the whole of it, however many chunks it | ||
| 183 | // takes. Reassembling here is what proves a chunked paste carries | ||
| 184 | // exactly one begin and one end. | ||
| 181 | stage(Buffer.from('two\nlines')); | 185 | stage(Buffer.from('two\nlines')); |
| 182 | check('paste len', e.mux_paste_encode(9), 9 + 12); | 186 | check('text len', e.mux_text_encode(9), 9); |
| 183 | check('paste bytes', outBytes().toString('latin1'), '\x1b[200~two\nlines\x1b[201~'); | 187 | check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines'); |
| 188 | |||
| 189 | check('paste begin len', e.mux_paste_begin(), 6); | ||
| 190 | const pasteWire = [outBytes().toString('latin1')]; | ||
| 191 | for (const chunk of ['two\n', 'lines']) { | ||
| 192 | stage(Buffer.from(chunk)); | ||
| 193 | check(`paste chunk ${chunk.length}`, e.mux_text_encode(chunk.length), chunk.length); | ||
| 194 | pasteWire.push(outBytes().toString('latin1')); | ||
| 195 | } | ||
| 196 | check('paste end len', e.mux_paste_end(), 6); | ||
| 197 | pasteWire.push(outBytes().toString('latin1')); | ||
| 198 | check('paste wire', pasteWire.join(''), '\x1b[200~two\nlines\x1b[201~'); | ||
| 199 | // The markers themselves, so a drift in keymap.zig's spelling is caught | ||
| 200 | // here and not by an application quietly re-indenting. | ||
| 201 | check('paste begin bytes', pasteWire[0], '\x1b[200~'); | ||
| 202 | check('paste end bytes', pasteWire[pasteWire.length - 1], '\x1b[201~'); | ||
| 203 | // The staging cap is the refusal, not a truncation. | ||
| 204 | check('text over cap', e.mux_text_encode(e.mux_input_cap() + 1), -2); | ||
| 184 | 205 | ||
| 185 | // --- scroll scratch: never touches the live replica --- | 206 | // --- scroll scratch: never touches the live replica --- |
| 186 | check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates | 207 | check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates |
| @@ -207,6 +228,19 @@ async function main() { | |||
| 207 | // the daemon sends in one frame for the wall's grids. | 228 | // the daemon sends in one frame for the wall's grids. |
| 208 | check('input cap', e.mux_input_cap(), 256 * 1024); | 229 | check('input cap', e.mux_input_cap(), 256 * 1024); |
| 209 | 230 | ||
| 231 | // --- the shell's ACTUAL call list, read out of mux.js --- | ||
| 232 | // Everything above pins exports this file happens to name. This pins | ||
| 233 | // the ones the page names, which is the pair that has to agree: an | ||
| 234 | // export renamed on the Zig side is a TypeError in the browser and | ||
| 235 | // nowhere else, and the wasm builds fine without it. | ||
| 236 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); | ||
| 237 | const called = [...new Set( | ||
| 238 | [...shell.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]), | ||
| 239 | )].sort(); | ||
| 240 | check('shell calls something', called.length > 0, true); | ||
| 241 | const absent = called.filter((n) => typeof e[n] !== 'function'); | ||
| 242 | check(`shell calls only real exports (${called.length} of them)`, absent.join(','), ''); | ||
| 243 | |||
| 210 | // --- deinit / re-init --- | 244 | // --- deinit / re-init --- |
| 211 | e.mux_deinit(); | 245 | e.mux_deinit(); |
| 212 | check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); | 246 | check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); |