Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/terminal.js

85.7 KiB, 14 runs

created by r2519314175:1449, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* terminal.js — the terminal a person actually looks at.
2 *
3 * `window.DaimondTerminal.create(host, opts)` puts a working terminal inside an
4 * element and hands back a handle. It draws bytes and it produces bytes; it
5 * knows nothing about the hand, the wire, or who is on the other end. That is
6 * deliberate — the same panel has to serve a pty on this machine, a pty over a
7 * WebSocket on another, and a recorded stream in a test, and none of those are
8 * the renderer's business.
9 *
10 * ── Canvas, not DOM ─────────────────────────────────────────────────
11 *
12 * A node per cell is the obvious way and it does not survive the load case. An
13 * 80×24 screen is 1,920 cells; a build printing three thousand lines a second
14 * rewrites every one of them many times over. Measured by the benchmark in
15 * `dev/termdemo.mjs`, which draws the same screenful both ways and times only
16 * the drawing (headless Chromium, software rasterisation, dpr 1 — the
17 * PESSIMISTIC environment for canvas and a neutral one for the DOM):
18 *
19 * 80×24 200×50
20 * span-per-cell, rebuilt 12.3 ms 67.5 ms ← 15 fps at 200×50
21 * canvas, full repaint 0.6 ms 2.7 ms
22 * canvas, full repaint, worst 3.9 ms 12.0 ms ← every cell its own colour
23 * canvas, scroll-blit 1.0 ms 3.0 ms ← the load case
24 *
25 * The DOM figures are the whole frame budget spent laying out text nobody asked
26 * to be selectable, and they are the OPTIMISTIC ones: no stylesheet of any size
27 * to recalculate against, and no account of the memory ten thousand styled spans
28 * take. So: canvas, by a factor of twenty-five at the size that matters.
29 *
30 * Two numbers worth keeping in view. At a device pixel ratio of 2 the worst case
31 * — a colour chart, where no two neighbouring cells share a style and every
32 * glyph is its own draw — rises to 94 ms in software rasterisation; the fix for
33 * that is a glyph atlas, and it is NOT built here, because the case is a colour
34 * chart rather than a build log and the typical figure at dpr 2 is 18 ms. And
35 * parsing, which is separate from drawing and never on the frame's critical
36 * path, runs at about 15 MB/s: five thousand lines of build log in 22 ms.
37 *
38 * What that costs is what the rest of this file spends its length on. A canvas
39 * has no text, so selection, copy and screen-reader access all have to be built
40 * rather than inherited. They are, below, and the accessibility section says
41 * plainly what it does and does not achieve.
42 *
43 * ── The screen model, and the seam it sits behind ───────────────────
44 *
45 * A VT parser over a grid of cells is being built in fe2o3 as well, to run in
46 * wasm. This file does NOT depend on it yet — `createScreen` below is a plain
47 * JavaScript model of the same shape — but everything that draws or types talks
48 * to the model only through the interface written here, so swapping in the
49 * wasm-backed one is a change of source and not a rewrite.
50 *
51 * A screen model must provide:
52 *
53 * cols, rows the grid, in cells.
54 * write(u8OrString) feed output. Parses, mutates the grid, moves
55 * the cursor, sets modes, records damage.
56 * Never draws and never touches the DOM.
57 * resize(cols, rows) reflow to a new grid.
58 * reset() back to a fresh screen.
59 * compose() fill the flat cell arrays for the current
60 * viewport and return the damage since the
61 * last call: { all, rows, scrolled }. Only
62 * damaged rows need be filled.
63 * cells { ch, attr, fg, bg }, four Uint32Arrays of
64 * cols*rows, row-major, filled by compose().
65 * With a wasm model these are views onto its
66 * memory, so a caller must re-read them after
67 * any resize.
68 * ext(y, x) the combining marks on one cell, if any, as
69 * a string. Rare; kept out of the flat arrays.
70 * cursor { x, y, visible, shape } in viewport cells,
71 * or y === -1 when the cursor is scrolled off.
72 * modes { appCursor, appKeypad, bracketed, alt,
73 * mouse, mouseSgr, wrap, reverse }.
74 * scrollback() lines held above the live screen.
75 * viewOffset(), setViewOffset(n) where the window sits in that scrollback.
76 * absTop() absolute index of the topmost line the model
77 * still holds, so a selection can be anchored
78 * somewhere that survives scrolling.
79 * lineText(abs) one line as plain text, trailing blanks cut.
80 * lineCells(abs) one line's code points, for a column-exact
81 * copy. Null when that line is gone.
82 * onBell, onTitle, onReply callbacks the model raises. `onReply` is the
83 * answer to a query the program made (cursor
84 * position, device attributes) and MUST be
85 * sent back as input, or a program that asks
86 * waits for ever.
87 *
88 * Colour and attributes are packed into integers rather than objects, because
89 * an object per cell is a garbage-collector problem at these rates and because
90 * integers are what a wasm model would hand over anyway. See `ATTR` and
91 * `packColour`.
92 *
93 * ── The damage model ────────────────────────────────────────────────
94 *
95 * Repainting the whole grid on every byte is unusable, and it is also the wrong
96 * shape: bytes arrive in bursts far faster than a screen refreshes. So a write
97 * only records what changed, and painting happens once per animation frame.
98 * Two cases are worth separating, and both are common:
99 *
100 * a REPL prompt, a progress bar, a spinner — a handful of cells on one row.
101 * Repaint that row and nothing else.
102 *
103 * a build log — every line scrolls the screen, so every row's CONTENT is new
104 * even though the pixels mostly are not. The canvas is blitted up over itself
105 * and only the newly exposed rows are drawn, which is what makes the load
106 * case cost about the same as a single line.
107 */
108(function () {
109 'use strict';
110
111 /// What a person reads. Nothing the program prints passes through here — the
112 /// terminal draws the bytes it is given and translates none of them.
113 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
114 function tn(k, n, v) { return window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k; }
115 /// Bind an attribute to a key where i18n is present, and set it plainly where
116 /// it is not -- this file runs in contexts that never load i18n.js.
117 function bindOr(node, attr, key) {
118 if (window.DaimondI18n && DaimondI18n.bind) DaimondI18n.bind(node, attr, key);
119 else node.setAttribute(attr, key);
120 }
121
122 // ── Cell encoding ───────────────────────────────────────────────
123
124 /// Attribute bits, one Uint32 per cell.
125 var ATTR = {
126 BOLD: 1,
127 DIM: 2,
128 ITALIC: 4,
129 UNDER: 8,
130 BLINK: 16,
131 REVERSE: 32,
132 HIDDEN: 64,
133 STRIKE: 128,
134 WIDE: 256, // the left half of a double-width character
135 WIDE_TAIL: 512, // the right half; carries no glyph of its own
136 };
137
138 /// A colour, packed into a Uint32.
139 ///
140 /// Mode 0 is "whatever the palette says the default is", which is NOT the
141 /// same as any particular colour: it has to follow the app's theme, and a
142 /// cell that recorded the resolved value at the time it was printed would
143 /// keep the old palette's colour after a theme change.
144 function packColour(mode, value) { return ((mode & 3) << 24) | (value & 0xFFFFFF); }
145 var COL_DEFAULT = 0;
146 var COL_INDEXED = 1; // value is 0..255 in the xterm palette
147 var COL_RGB = 2; // value is 0xRRGGBB
148
149 // ── Character width ─────────────────────────────────────────────
150 //
151 // A terminal grid has to agree with the program about how many cells a
152 // character occupies, or every subsequent column on the line is wrong. These
153 // are the East Asian Wide and Fullwidth blocks, plus the emoji ranges that
154 // every terminal treats as wide, and the combining marks that occupy none.
155
156 var WIDE = [
157 [0x1100, 0x115F], [0x2329, 0x232A], [0x2E80, 0x303E], [0x3041, 0x33FF],
158 [0x3400, 0x4DBF], [0x4E00, 0x9FFF], [0xA000, 0xA4CF], [0xA960, 0xA97F],
159 [0xAC00, 0xD7A3], [0xF900, 0xFAFF], [0xFE10, 0xFE19], [0xFE30, 0xFE6F],
160 [0xFF00, 0xFF60], [0xFFE0, 0xFFE6], [0x1F300, 0x1F64F], [0x1F680, 0x1F6FF],
161 [0x1F900, 0x1F9FF], [0x20000, 0x2FFFD], [0x30000, 0x3FFFD],
162 ];
163 var ZERO = [
164 [0x0300, 0x036F], [0x0483, 0x0489], [0x0591, 0x05BD], [0x0610, 0x061A],
165 [0x064B, 0x065F], [0x0670, 0x0670], [0x06D6, 0x06DC], [0x0730, 0x074A],
166 [0x07A6, 0x07B0], [0x0900, 0x0903], [0x093A, 0x093C], [0x0941, 0x0948],
167 [0x0E31, 0x0E31], [0x0E34, 0x0E3A], [0x0EB1, 0x0EB1], [0x0EB4, 0x0EB9],
168 [0x1AB0, 0x1AFF], [0x1DC0, 0x1DFF], [0x200B, 0x200F], [0x2060, 0x2064],
169 [0x20D0, 0x20F0], [0xFE00, 0xFE0F], [0xFE20, 0xFE2F],
170 ];
171
172 /// Whether `cp` lies in one of `ranges`, by bisection.
173 function inRanges(ranges, cp) {
174 var lo = 0, hi = ranges.length - 1;
175 while (lo <= hi) {
176 var mid = (lo + hi) >> 1;
177 if (cp < ranges[mid][0]) hi = mid - 1;
178 else if (cp > ranges[mid][1]) lo = mid + 1;
179 else return true;
180 }
181 return false;
182 }
183
184 /// How many cells one code point takes: 0, 1 or 2.
185 function charWidth(cp) {
186 if (cp < 0x0300) return cp < 0x20 ? 0 : 1; // the fast, overwhelmingly common path
187 if (inRanges(ZERO, cp)) return 0;
188 if (inRanges(WIDE, cp)) return 2;
189 return 1;
190 }
191
192 // ── The screen model ────────────────────────────────────────────
193
194 /// One line of the grid.
195 ///
196 /// Four parallel typed arrays rather than an array of cell objects: a screen
197 /// of objects is tens of thousands of allocations that the collector then has
198 /// to walk, and scrolling would copy pointers rather than memory. `ext` holds
199 /// the combining marks that will not fit in a single code point, and is null
200 /// on the overwhelming majority of lines.
201 function newLine(cols) {
202 return {
203 ch: new Uint32Array(cols),
204 attr: new Uint32Array(cols),
205 fg: new Uint32Array(cols),
206 bg: new Uint32Array(cols),
207 ext: null, // index -> string of combining marks
208 wrap: false, // this line continues onto the next
209 };
210 }
211
212 /// Blank one line, or a span of it, in the given attributes.
213 function blankLine(ln, from, to, fg, bg, attr) {
214 for (var i = from; i < to; i++) {
215 ln.ch[i] = 32; ln.attr[i] = attr; ln.fg[i] = fg; ln.bg[i] = bg;
216 }
217 if (ln.ext) for (var k in ln.ext) { if (+k >= from && +k < to) delete ln.ext[k]; }
218 }
219
220 /// A screen: a grid, a cursor, modes, scrollback and damage.
221 ///
222 /// This is the JavaScript half of the seam described at the top of the file.
223 /// Everything below it is the VT parser; everything above it is drawing.
224 function createScreen(cols, rows, opts) {
225 opts = opts || {};
226 var S = {};
227 var SB_MAX = opts.scrollback === undefined ? 5000 : opts.scrollback;
228
229 var lines = []; // the live screen, rows entries
230 var alt = null; // the alternate screen, while one is in use
231 var sb = []; // scrollback, oldest first
232 var dropped = 0; // lines evicted from the front of the scrollback
233 var view = 0; // how far back the window is, in lines
234
235 var cur = { x: 0, y: 0, fg: COL_DEFAULT, bg: COL_DEFAULT, attr: 0 };
236 var saved = null;
237 var pend = false; // the deferred wrap: the cursor sits ON the last column
238 var top = 0, bot = rows - 1;
239 var tabs = {};
240
241 var dirty = new Uint8Array(rows);
242 var allDirty = true;
243 var scrolled = 0; // net whole-screen scrolls since the last compose
244
245 var flat = {
246 ch: new Uint32Array(cols * rows),
247 attr: new Uint32Array(cols * rows),
248 fg: new Uint32Array(cols * rows),
249 bg: new Uint32Array(cols * rows),
250 };
251
252 S.cols = cols; S.rows = rows;
253 S.cells = flat;
254 S.cursor = { x: 0, y: 0, visible: true, shape: 'block' };
255 S.modes = {
256 appCursor: false, appKeypad: false, bracketed: false,
257 alt: false, mouse: 0, mouseSgr: false, wrap: true, reverse: false,
258 };
259 S.onBell = null; S.onTitle = null; S.onReply = null;
260
261 for (var i = 0; i < rows; i++) { lines.push(newLine(cols)); blankLine(lines[i], 0, cols, 0, 0, 0); }
262 for (var c = 8; c < cols; c += 8) tabs[c] = true;
263
264 // ── damage ──────────────────────────────────────────────
265 function mark(y) { if (y >= 0 && y < rows) dirty[y] = 1; }
266 function markAll() { allDirty = true; }
267
268 // ── the grid ────────────────────────────────────────────
269
270 /// Absolute index of a line: stable while the model holds it, so a
271 /// selection anchored on one keeps pointing at the same text as the
272 /// screen scrolls underneath it.
273 function absOf(y) { return dropped + sb.length + y; }
274
275 /// The line shown at viewport row `y`, honouring the scrollback offset.
276 function shown(y) {
277 var k = sb.length - view + y;
278 return k < sb.length ? sb[k] : lines[k - sb.length];
279 }
280
281 /// Push the top line into scrollback and open a blank one at the bottom.
282 ///
283 /// Only the primary screen with a full-height scroll region keeps
284 /// history: a full-screen program's redraw is not a transcript, and
285 /// keeping it would fill the scrollback with the frames of a `vim`.
286 function scrollUp(n) {
287 var keep = !S.modes.alt && top === 0 && bot === rows - 1;
288 for (var i = 0; i < n; i++) {
289 var out = lines[top];
290 lines.splice(top, 1);
291 if (keep && SB_MAX > 0) {
292 sb.push(out);
293 if (sb.length > SB_MAX) { sb.shift(); dropped++; }
294 }
295 var ln = newLine(cols);
296 blankLine(ln, 0, cols, cur.fg, cur.bg, 0);
297 lines.splice(bot, 0, ln);
298 }
299 if (keep && n < rows) {
300 // The rows moved with their content, so the damage moves with
301 // them; the renderer turns this into a blit and repaints only
302 // the rows that are genuinely new.
303 dirty.copyWithin(0, n);
304 dirty.fill(0, rows - n);
305 for (var j = rows - n; j < rows; j++) dirty[j] = 1;
306 scrolled += n;
307 // A window scrolled back stays where the reader put it, which
308 // means the whole viewport now shows different lines.
309 if (view > 0) { view = Math.min(view + n, sb.length); markAll(); }
310 } else {
311 markAll();
312 }
313 }
314
315 function scrollDown(n) {
316 for (var i = 0; i < n; i++) {
317 lines.splice(bot, 1);
318 var ln = newLine(cols);
319 blankLine(ln, 0, cols, cur.fg, cur.bg, 0);
320 lines.splice(top, 0, ln);
321 }
322 markAll();
323 }
324
325 function lineFeed() {
326 if (cur.y === bot) scrollUp(1);
327 else if (cur.y < rows - 1) { cur.y++; }
328 pend = false;
329 }
330
331 /// Print one code point at the cursor, wrapping as the modes say.
332 function put(cp) {
333 var w = charWidth(cp);
334 if (w === 0) {
335 // A combining mark belongs to the cell before the cursor.
336 var px = cur.x > 0 ? cur.x - 1 : 0;
337 var pl = lines[cur.y];
338 if (pl.attr[px] & ATTR.WIDE_TAIL) px = px > 0 ? px - 1 : px;
339 if (!pl.ext) pl.ext = {};
340 pl.ext[px] = (pl.ext[px] || '') + String.fromCodePoint(cp);
341 mark(cur.y);
342 return;
343 }
344 if (pend && S.modes.wrap) {
345 lines[cur.y].wrap = true;
346 cur.x = 0;
347 lineFeed();
348 pend = false;
349 }
350 if (cur.x + w > cols) {
351 if (!S.modes.wrap) { cur.x = cols - w; }
352 else { lines[cur.y].wrap = true; cur.x = 0; lineFeed(); }
353 }
354 var ln = lines[cur.y], x = cur.x;
355 // Overwriting half of a wide character leaves the other half
356 // orphaned; blank it, or the grid draws a glyph twice.
357 if (ln.attr[x] & ATTR.WIDE_TAIL) blankLine(ln, x - 1, x + 1, cur.fg, cur.bg, 0);
358 if ((ln.attr[x] & ATTR.WIDE) && x + 1 < cols) blankLine(ln, x, x + 2, cur.fg, cur.bg, 0);
359 ln.ch[x] = cp;
360 ln.fg[x] = cur.fg; ln.bg[x] = cur.bg;
361 ln.attr[x] = cur.attr | (w === 2 ? ATTR.WIDE : 0);
362 if (ln.ext && ln.ext[x] !== undefined) delete ln.ext[x];
363 if (w === 2 && x + 1 < cols) {
364 ln.ch[x + 1] = 0;
365 ln.fg[x + 1] = cur.fg; ln.bg[x + 1] = cur.bg;
366 ln.attr[x + 1] = cur.attr | ATTR.WIDE_TAIL;
367 }
368 mark(cur.y);
369 if (cur.x + w >= cols) { cur.x = cols - 1; pend = true; }
370 else { cur.x += w; }
371 }
372
373 // ── the parser ──────────────────────────────────────────
374 //
375 // A state machine over the decoded text. Decoding is done by the
376 // platform's own streaming UTF-8 decoder, which is both faster than
377 // anything written here and correct across a chunk boundary — a control
378 // sequence split between two reads is the normal case, not the odd one.
379
380 var dec = new TextDecoder('utf-8');
381 var st = 0; // 0 ground, 1 esc, 2 csi, 3 osc/string, 4 charset
382 var pStr = ''; // CSI parameter and intermediate bytes
383 var pPriv = ''; // the private marker a CSI opened with
384 var oStr = ''; // the OSC/DCS payload
385 var oKind = ''; // which string sequence is open
386 var oEsc = false; // an ESC seen inside a string: half of a possible ST
387
388 function params(def) {
389 var out = pStr.split(';').map(function (s) {
390 var n = parseInt(s.split(':')[0], 10);
391 return isNaN(n) ? def : n;
392 });
393 return out;
394 }
395 function p1(def) { var v = params(def)[0]; return v === undefined || v === 0 ? (def === 0 ? 0 : def) : v; }
396
397 function sgr() {
398 var raw = pStr.length ? pStr.split(';') : ['0'];
399 for (var i = 0; i < raw.length; i++) {
400 // The colon form (38:2::r:g:b) is what a modern program emits;
401 // the semicolon form is what an old one emits. Both are real.
402 var bits = raw[i].split(':');
403 var n = parseInt(bits[0], 10);
404 if (isNaN(n)) n = 0;
405 if (n === 38 || n === 48 || n === 58) {
406 var col = COL_DEFAULT;
407 if (bits.length > 1) {
408 if (bits[1] === '5') col = packColour(COL_INDEXED, parseInt(bits[2], 10) || 0);
409 else if (bits[1] === '2') {
410 // 38:2::r:g:b has an empty colour-space slot; 38:2:r:g:b does not.
411 var off = bits.length >= 6 ? 3 : 2;
412 col = packColour(COL_RGB,
413 ((parseInt(bits[off], 10) || 0) << 16)
414 | ((parseInt(bits[off + 1], 10) || 0) << 8)
415 | (parseInt(bits[off + 2], 10) || 0));
416 }
417 } else if (raw[i + 1] === '5') {
418 col = packColour(COL_INDEXED, parseInt(raw[i + 2], 10) || 0); i += 2;
419 } else if (raw[i + 1] === '2') {
420 col = packColour(COL_RGB,
421 ((parseInt(raw[i + 2], 10) || 0) << 16)
422 | ((parseInt(raw[i + 3], 10) || 0) << 8)
423 | (parseInt(raw[i + 4], 10) || 0)); i += 4;
424 }
425 if (n === 38) cur.fg = col; else if (n === 48) cur.bg = col;
426 continue;
427 }
428 if (n === 0) { cur.attr = 0; cur.fg = COL_DEFAULT; cur.bg = COL_DEFAULT; }
429 else if (n === 1) cur.attr |= ATTR.BOLD;
430 else if (n === 2) cur.attr |= ATTR.DIM;
431 else if (n === 3) cur.attr |= ATTR.ITALIC;
432 else if (n === 4) cur.attr |= ATTR.UNDER;
433 else if (n === 5 || n === 6) cur.attr |= ATTR.BLINK;
434 else if (n === 7) cur.attr |= ATTR.REVERSE;
435 else if (n === 8) cur.attr |= ATTR.HIDDEN;
436 else if (n === 9) cur.attr |= ATTR.STRIKE;
437 else if (n === 21 || n === 22) cur.attr &= ~(ATTR.BOLD | ATTR.DIM);
438 else if (n === 23) cur.attr &= ~ATTR.ITALIC;
439 else if (n === 24) cur.attr &= ~ATTR.UNDER;
440 else if (n === 25) cur.attr &= ~ATTR.BLINK;
441 else if (n === 27) cur.attr &= ~ATTR.REVERSE;
442 else if (n === 28) cur.attr &= ~ATTR.HIDDEN;
443 else if (n === 29) cur.attr &= ~ATTR.STRIKE;
444 else if (n >= 30 && n <= 37) cur.fg = packColour(COL_INDEXED, n - 30);
445 else if (n === 39) cur.fg = COL_DEFAULT;
446 else if (n >= 40 && n <= 47) cur.bg = packColour(COL_INDEXED, n - 40);
447 else if (n === 49) cur.bg = COL_DEFAULT;
448 else if (n >= 90 && n <= 97) cur.fg = packColour(COL_INDEXED, n - 90 + 8);
449 else if (n >= 100 && n <= 107) cur.bg = packColour(COL_INDEXED, n - 100 + 8);
450 }
451 }
452
453 /// Switch between the primary and alternate screens.
454 ///
455 /// The alternate screen is what makes `vim` leave your shell's output
456 /// where it was: it is a second grid with no scrollback, thrown away
457 /// when the program exits.
458 function useAlt(on, saveCursor) {
459 if (on === S.modes.alt) return;
460 if (on) {
461 alt = lines;
462 if (saveCursor) saved = { x: cur.x, y: cur.y, fg: cur.fg, bg: cur.bg, attr: cur.attr };
463 lines = [];
464 for (var i = 0; i < rows; i++) { var ln = newLine(cols); blankLine(ln, 0, cols, 0, 0, 0); lines.push(ln); }
465 S.modes.alt = true;
466 cur.x = 0; cur.y = 0;
467 } else {
468 lines = alt || lines;
469 alt = null;
470 S.modes.alt = false;
471 if (saveCursor && saved) { cur.x = saved.x; cur.y = saved.y; cur.fg = saved.fg; cur.bg = saved.bg; cur.attr = saved.attr; }
472 }
473 top = 0; bot = rows - 1;
474 pend = false;
475 markAll();
476 }
477
478 function setMode(on) {
479 var ps = params(0);
480 for (var i = 0; i < ps.length; i++) {
481 var n = ps[i];
482 if (pPriv === '?') {
483 if (n === 1) S.modes.appCursor = on;
484 else if (n === 5) { S.modes.reverse = on; markAll(); }
485 else if (n === 7) S.modes.wrap = on;
486 else if (n === 25) S.cursor.visible = on;
487 else if (n === 12) { /* cursor blink; the renderer decides, see BLINK_MS */ }
488 else if (n === 1000 || n === 1002 || n === 1003) S.modes.mouse = on ? n : 0;
489 else if (n === 1006) S.modes.mouseSgr = on;
490 else if (n === 47 || n === 1047) useAlt(on, false);
491 else if (n === 1049) useAlt(on, true);
492 else if (n === 2004) S.modes.bracketed = on;
493 }
494 }
495 }
496
497 function csi(fin) {
498 var ps, n, ln, i;
499 switch (fin) {
500 case '@': { // insert blanks
501 n = Math.max(1, p1(1)); ln = lines[cur.y];
502 for (i = cols - 1; i >= cur.x + n; i--) {
503 ln.ch[i] = ln.ch[i - n]; ln.attr[i] = ln.attr[i - n];
504 ln.fg[i] = ln.fg[i - n]; ln.bg[i] = ln.bg[i - n];
505 }
506 blankLine(ln, cur.x, Math.min(cols, cur.x + n), cur.fg, cur.bg, 0);
507 mark(cur.y); break;
508 }
509 case 'A': cur.y = Math.max(top, cur.y - Math.max(1, p1(1))); pend = false; break;
510 case 'B': cur.y = Math.min(bot, cur.y + Math.max(1, p1(1))); pend = false; break;
511 case 'C': cur.x = Math.min(cols - 1, cur.x + Math.max(1, p1(1))); pend = false; break;
512 case 'D': cur.x = Math.max(0, cur.x - Math.max(1, p1(1))); pend = false; break;
513 case 'E': cur.y = Math.min(bot, cur.y + Math.max(1, p1(1))); cur.x = 0; pend = false; break;
514 case 'F': cur.y = Math.max(top, cur.y - Math.max(1, p1(1))); cur.x = 0; pend = false; break;
515 case 'G': case '`': cur.x = Math.min(cols - 1, Math.max(0, p1(1) - 1)); pend = false; break;
516 case 'H': case 'f':
517 ps = params(1);
518 cur.y = Math.min(rows - 1, Math.max(0, (ps[0] || 1) - 1));
519 cur.x = Math.min(cols - 1, Math.max(0, (ps[1] || 1) - 1));
520 pend = false; break;
521 case 'I': { // forward tab
522 n = Math.max(1, p1(1));
523 for (i = 0; i < n; i++) { do { cur.x++; } while (cur.x < cols - 1 && !tabs[cur.x]); }
524 cur.x = Math.min(cur.x, cols - 1); break;
525 }
526 case 'J': { // erase in display
527 n = p1(0);
528 var from = n === 0 ? cur.y : 0, to = n === 1 ? cur.y : rows - 1;
529 for (i = from; i <= to; i++) {
530 var a = (n === 0 && i === cur.y) ? cur.x : 0;
531 var b = (n === 1 && i === cur.y) ? cur.x + 1 : cols;
532 blankLine(lines[i], a, b, cur.fg, cur.bg, 0);
533 lines[i].wrap = false;
534 }
535 markAll(); break;
536 }
537 case 'K': { // erase in line
538 n = p1(0); ln = lines[cur.y];
539 blankLine(ln, n === 1 ? 0 : cur.x, n === 0 ? cols : cur.x + 1, cur.fg, cur.bg, 0);
540 if (n === 2) blankLine(ln, 0, cols, cur.fg, cur.bg, 0);
541 mark(cur.y); break;
542 }
543 case 'L': { // insert lines
544 n = Math.max(1, p1(1));
545 if (cur.y >= top && cur.y <= bot) {
546 for (i = 0; i < n; i++) {
547 lines.splice(bot, 1);
548 var nl = newLine(cols); blankLine(nl, 0, cols, cur.fg, cur.bg, 0);
549 lines.splice(cur.y, 0, nl);
550 }
551 markAll();
552 }
553 break;
554 }
555 case 'M': { // delete lines
556 n = Math.max(1, p1(1));
557 if (cur.y >= top && cur.y <= bot) {
558 for (i = 0; i < n; i++) {
559 lines.splice(cur.y, 1);
560 var ml = newLine(cols); blankLine(ml, 0, cols, cur.fg, cur.bg, 0);
561 lines.splice(bot, 0, ml);
562 }
563 markAll();
564 }
565 break;
566 }
567 case 'P': { // delete characters
568 n = Math.max(1, p1(1)); ln = lines[cur.y];
569 for (i = cur.x; i < cols; i++) {
570 var src = i + n;
571 if (src < cols) {
572 ln.ch[i] = ln.ch[src]; ln.attr[i] = ln.attr[src];
573 ln.fg[i] = ln.fg[src]; ln.bg[i] = ln.bg[src];
574 } else { ln.ch[i] = 32; ln.attr[i] = 0; ln.fg[i] = cur.fg; ln.bg[i] = cur.bg; }
575 }
576 mark(cur.y); break;
577 }
578 case 'S': scrollUp(Math.max(1, p1(1))); break;
579 case 'T': scrollDown(Math.max(1, p1(1))); break;
580 case 'X': { // erase characters
581 n = Math.max(1, p1(1));
582 blankLine(lines[cur.y], cur.x, Math.min(cols, cur.x + n), cur.fg, cur.bg, 0);
583 mark(cur.y); break;
584 }
585 case 'Z': { // backward tab
586 n = Math.max(1, p1(1));
587 for (i = 0; i < n; i++) { do { cur.x--; } while (cur.x > 0 && !tabs[cur.x]); }
588 cur.x = Math.max(0, cur.x); break;
589 }
590 case 'd': cur.y = Math.min(rows - 1, Math.max(0, p1(1) - 1)); pend = false; break;
591 case 'g': if (p1(0) === 3) tabs = {}; else delete tabs[cur.x]; break;
592 case 'h': setMode(true); break;
593 case 'l': setMode(false); break;
594 case 'm': sgr(); break;
595 case 'n':
596 // A program that asks where the cursor is BLOCKS until it is
597 // told. Dropping the reply is how a shell appears to hang.
598 if (p1(0) === 6 && S.onReply) S.onReply('\x1b[' + (cur.y + 1) + ';' + (cur.x + 1) + 'R');
599 else if (p1(0) === 5 && S.onReply) S.onReply('\x1b[0n');
600 break;
601 case 'c': if (S.onReply) S.onReply('\x1b[?6c'); break; // a VT102, which is what we draw
602 case 'q': { // DECSCUSR — the cursor's shape
603 n = p1(0);
604 S.cursor.shape = (n === 3 || n === 4) ? 'underline' : (n === 5 || n === 6) ? 'bar' : 'block';
605 break;
606 }
607 case 'r':
608 ps = params(0);
609 top = Math.max(0, (ps[0] || 1) - 1);
610 bot = Math.min(rows - 1, (ps[1] || rows) - 1);
611 if (top >= bot) { top = 0; bot = rows - 1; }
612 cur.x = 0; cur.y = top; pend = false; break;
613 case 's': saved = { x: cur.x, y: cur.y, fg: cur.fg, bg: cur.bg, attr: cur.attr }; break;
614 case 'u': if (saved) { cur.x = saved.x; cur.y = saved.y; cur.fg = saved.fg; cur.bg = saved.bg; cur.attr = saved.attr; } break;
615 default: break; // an unknown final byte is consumed, never printed
616 }
617 }
618
619 function esc(c) {
620 switch (c) {
621 case 'D': lineFeed(); break;
622 case 'E': cur.x = 0; lineFeed(); break;
623 case 'M': if (cur.y === top) scrollDown(1); else if (cur.y > 0) cur.y--; pend = false; break;
624 case 'H': tabs[cur.x] = true; break;
625 case '7': saved = { x: cur.x, y: cur.y, fg: cur.fg, bg: cur.bg, attr: cur.attr }; break;
626 case '8': if (saved) { cur.x = saved.x; cur.y = saved.y; cur.fg = saved.fg; cur.bg = saved.bg; cur.attr = saved.attr; } break;
627 case '=': S.modes.appKeypad = true; break;
628 case '>': S.modes.appKeypad = false; break;
629 case 'c': S.reset(); break;
630 default: break;
631 }
632 }
633
634 function osc() {
635 var semi = oStr.indexOf(';');
636 var kind = semi < 0 ? oStr : oStr.slice(0, semi);
637 var body = semi < 0 ? '' : oStr.slice(semi + 1);
638 if ((kind === '0' || kind === '2') && S.onTitle) S.onTitle(body);
639 }
640
641 /// Feed the model output. Bytes, or an already decoded string.
642 S.write = function (data) {
643 var s;
644 if (typeof data === 'string') s = data;
645 else if (data instanceof Uint8Array) s = dec.decode(data, { stream: true });
646 else if (data instanceof ArrayBuffer) s = dec.decode(new Uint8Array(data), { stream: true });
647 else return;
648 for (var i = 0; i < s.length; i++) {
649 var c = s.charCodeAt(i);
650 if (st === 0) {
651 if (c < 0x20) {
652 if (c === 0x1b) { st = 1; }
653 else if (c === 0x0a || c === 0x0b || c === 0x0c) lineFeed();
654 else if (c === 0x0d) { cur.x = 0; pend = false; }
655 else if (c === 0x08) { if (cur.x > 0) cur.x--; pend = false; }
656 else if (c === 0x09) { do { cur.x++; } while (cur.x < cols - 1 && !tabs[cur.x]); pend = false; }
657 else if (c === 0x07 && S.onBell) S.onBell();
658 continue;
659 }
660 if (c === 0x7f) continue;
661 if (c >= 0xD800 && c <= 0xDBFF && i + 1 < s.length) {
662 put(((c - 0xD800) << 10) + (s.charCodeAt(++i) - 0xDC00) + 0x10000);
663 } else put(c);
664 continue;
665 }
666 var ch = s[i];
667 if (st === 1) {
668 if (ch === '[') { st = 2; pStr = ''; pPriv = ''; }
669 else if (ch === ']') { st = 3; oStr = ''; oKind = 'osc'; oEsc = false; }
670 else if (ch === 'P' || ch === 'X' || ch === '^' || ch === '_') { st = 3; oStr = ''; oKind = 'dcs'; oEsc = false; }
671 else if (ch === '(' || ch === ')' || ch === '*' || ch === '+') { st = 4; }
672 else { st = 0; esc(ch); }
673 continue;
674 }
675 if (st === 2) {
676 if (!pStr.length && !pPriv.length && '<=>?'.indexOf(ch) >= 0) { pPriv = ch; continue; }
677 if (c >= 0x30 && c <= 0x3f) { pStr += ch; continue; }
678 if (c >= 0x20 && c <= 0x2f) { pStr += ch; continue; } // intermediates, kept for the final's sake
679 st = 0;
680 csi(ch);
681 continue;
682 }
683 if (st === 3) {
684 // The String Terminator is ESC \, and its two bytes routinely
685 // fall in different `write` calls -- a network read splits a
686 // burst anywhere -- so the ESC is remembered in a flag that
687 // outlives the chunk rather than looked back for in `s`, where
688 // `s.charCodeAt(-1)` is NaN and the terminator is missed. That
689 // miss left the parser collecting the rest of the session into
690 // `oStr` and drawing nothing: a live terminal that looks hung,
691 // which is what an interactive `ssh` produced.
692 if (oEsc) {
693 oEsc = false;
694 if (ch === '\\') { st = 0; if (oKind === 'osc') osc(); continue; }
695 // An ESC not followed by `\` was not an ST; it is dropped and
696 // this byte is handled as an ordinary one, as it was before.
697 }
698 if (c === 0x07) { st = 0; if (oKind === 'osc') osc(); continue; }
699 if (c === 0x1b) { oEsc = true; continue; } // the ST's first half
700 oStr += ch;
701 continue;
702 }
703 if (st === 4) { st = 0; continue; } // a charset designation, consumed
704 }
705 S.cursor.x = cur.x;
706 S.cursor.y = cur.y;
707 };
708
709 // ── what the renderer reads ─────────────────────────────
710
711 /// Fill the flat cell arrays for whatever has changed, and say what that
712 /// was. `everything` is the caller announcing that it is going to repaint
713 /// the whole grid regardless — a theme change, a resize, a selection —
714 /// and it must be passed, because the arrays hold ONLY what was last
715 /// composed: a forced repaint that read them without this drew the rows
716 /// as they were several frames ago, which is a stale screen produced by
717 /// an optimisation that was working correctly.
718 S.compose = function (everything) {
719 if (everything) allDirty = true;
720 var res = { all: allDirty, rows: dirty, scrolled: allDirty ? 0 : scrolled };
721 var from = 0, to = rows;
722 for (var y = from; y < to; y++) {
723 if (!allDirty && !dirty[y]) continue;
724 var ln = shown(y), o = y * cols;
725 if (!ln) { flat.ch.fill(32, o, o + cols); flat.attr.fill(0, o, o + cols); flat.fg.fill(0, o, o + cols); flat.bg.fill(0, o, o + cols); continue; }
726 flat.ch.set(ln.ch, o);
727 flat.attr.set(ln.attr, o);
728 flat.fg.set(ln.fg, o);
729 flat.bg.set(ln.bg, o);
730 }
731 // The cursor is only on screen while the window is live.
732 S.cursor.x = cur.x;
733 S.cursor.y = view > 0 ? -1 : cur.y;
734 allDirty = false;
735 scrolled = 0;
736 dirty = new Uint8Array(rows);
737 return res;
738 };
739
740 /// The combining marks on one viewport row, or null — which is the
741 /// answer on almost every row, and asking once per row rather than once
742 /// per cell is the difference between a lookup and ten thousand.
743 S.extRow = function (y) {
744 var ln = shown(y);
745 return (ln && ln.ext) || null;
746 };
747 S.ext = function (y, x) {
748 var e = S.extRow(y);
749 return (e && e[x]) || '';
750 };
751
752 S.scrollback = function () { return sb.length; };
753 S.viewOffset = function () { return view; };
754 S.setViewOffset = function (n) {
755 n = Math.max(0, Math.min(sb.length, Math.round(n)));
756 if (n === view) return false;
757 view = n;
758 markAll();
759 return true;
760 };
761 S.absTop = function () { return dropped; };
762 S.absOfRow = function (y) { return dropped + sb.length - view + y; };
763 /// Where an absolute line sits in the viewport, or outside it. Part of the
764 /// interface rather than used here: a host that wants to scroll TO a
765 /// found line needs it, and it belongs beside its inverse.
766 S.rowOfAbs = function (a) { return a - (dropped + sb.length - view); };
767
768 S.lineCells = function (a) {
769 var k = a - dropped;
770 if (k < 0) return null;
771 return k < sb.length ? sb[k] : (lines[k - sb.length] || null);
772 };
773 S.lineText = function (a) {
774 var ln = S.lineCells(a);
775 if (!ln) return '';
776 var out = '';
777 for (var x = 0; x < cols; x++) {
778 if (ln.attr[x] & ATTR.WIDE_TAIL) continue;
779 out += (ln.ch[x] ? String.fromCodePoint(ln.ch[x]) : ' ') + (ln.ext && ln.ext[x] ? ln.ext[x] : '');
780 }
781 return out.replace(/\s+$/, '');
782 };
783 /// Whether the line at `a` was broken by the terminal's own wrap rather
784 /// than by the program. A copy joins those, because a wrapped path is
785 /// one path and pasting it with a newline in the middle breaks it.
786 S.lineWrapped = function (a) { var ln = S.lineCells(a); return !!(ln && ln.wrap); };
787
788 /// The last row of a screen with anything written on it, or 0.
789 ///
790 /// Only ever asked of the PARKED primary screen, and only when a
791 /// program moved to the alternate screen without saving a cursor: a
792 /// shell prompt sits on the last written row, so that is the row worth
793 /// keeping when the window shrinks underneath `vim`.
794 function lastUsed(ls) {
795 for (var y = ls.length - 1; y >= 0; y--) {
796 var ch = ls[y].ch;
797 for (var x = 0; x < ch.length; x++) {
798 if (ch[x] !== 32 && ch[x] !== 0) return y;
799 }
800 }
801 return 0;
802 }
803
804 /// Push one line off the top of a screen into the history.
805 ///
806 /// The same three rules `scrollUp` follows: the history is bounded, what
807 /// falls off its front is counted, and a window scrolled back stays on
808 /// the text the reader put it on.
809 function toScrollback(ln) {
810 if (SB_MAX <= 0) return;
811 sb.push(ln);
812 if (sb.length > SB_MAX) { sb.shift(); dropped++; }
813 if (view > 0) view = Math.min(view + 1, sb.length);
814 }
815
816 /// Bring one screen to `nr` rows, keeping the cursor on it.
817 ///
818 /// **Checked against tmux**, which is the only authority worth having
819 /// here. Shrinking a 20-row screen holding two lines to 10 rows leaves
820 /// both lines where they are and the cursor on row 2, because what goes
821 /// is the empty rows BELOW the cursor; only once there is nothing left
822 /// below it does the top of the screen go into the history, taking the
823 /// cursor up with it. Growing takes those same lines back out, so the
824 /// screen a reader was looking at comes back exactly — which is what the
825 /// oracle does, row for row, and what taking rows from the bottom
826 /// regardless did not: it threw the two lines away and left a blank
827 /// viewport.
828 ///
829 /// # Arguments
830 /// * `ls` - The screen's lines, mutated in place.
831 /// * `c` - Its cursor, whose `y` moves with the rows.
832 /// * `nr` - The new height.
833 function fitHeight(ls, c, nr) {
834 while (ls.length > nr) {
835 if (c.y + 1 < ls.length) {
836 ls.pop();
837 } else {
838 toScrollback(ls.shift());
839 c.y = Math.max(0, c.y - 1);
840 }
841 }
842 while (ls.length < nr) {
843 if (sb.length) {
844 ls.unshift(sb.pop());
845 c.y++;
846 if (view > 0) view--;
847 } else {
848 var ln = newLine(cols);
849 blankLine(ln, 0, cols, 0, 0, 0);
850 ls.push(ln);
851 }
852 }
853 }
854
855 S.resize = function (nc, nr) {
856 nc = Math.max(1, nc | 0); nr = Math.max(1, nr | 0);
857 if (nc === cols && nr === rows) return;
858 // The PRIMARY screen is the one with a history and a cursor worth
859 // keeping, whether or not it is the one being looked at. The
860 // alternate is a program's redraw — not a transcript — so it is
861 // trimmed from the bottom and contributes nothing to the history.
862 // Resizing only the live screen left the parked one at the old
863 // width, and a `vim` exited after a resize handed back a grid the
864 // renderer then read past the end of.
865 var prim = S.modes.alt ? alt : lines;
866 var pcur = S.modes.alt ? (saved || { y: lastUsed(prim) }) : cur;
867 fitHeight(prim, pcur, nr);
868 if (S.modes.alt) {
869 while (lines.length > nr) lines.pop();
870 while (lines.length < nr) { var b = newLine(cols); blankLine(b, 0, cols, 0, 0, 0); lines.push(b); }
871 }
872 for (var i = 0; i < prim.length; i++) prim[i] = refit(prim[i], nc);
873 if (S.modes.alt) {
874 for (var k = 0; k < lines.length; k++) lines[k] = refit(lines[k], nc);
875 }
876 for (var j = 0; j < sb.length; j++) sb[j] = refit(sb[j], nc);
877 cur.y = Math.max(0, Math.min(nr - 1, cur.y));
878 cur.x = Math.min(nc - 1, cur.x);
879 if (saved) {
880 saved.y = Math.max(0, Math.min(nr - 1, saved.y));
881 saved.x = Math.min(nc - 1, saved.x);
882 }
883 cols = nc; rows = nr;
884 S.cols = nc; S.rows = nr;
885 top = 0; bot = nr - 1;
886 tabs = {};
887 for (var c2 = 8; c2 < nc; c2 += 8) tabs[c2] = true;
888 dirty = new Uint8Array(nr);
889 flat.ch = new Uint32Array(nc * nr); flat.attr = new Uint32Array(nc * nr);
890 flat.fg = new Uint32Array(nc * nr); flat.bg = new Uint32Array(nc * nr);
891 view = Math.min(view, sb.length);
892 markAll();
893 };
894
895 /// One line at a new width. Truncated or padded; NOT reflowed.
896 ///
897 /// Reflowing history on resize is what a full terminal does and it is a
898 /// large piece of work with its own class of bugs (a wrapped line that
899 /// was itself the tail of a wrap, a selection anchored inside one). This
900 /// is the honest smaller thing, and it is recorded as a gap rather than
901 /// dressed up.
902 function refit(ln, nc) {
903 if (ln.ch.length === nc) return ln;
904 var out = newLine(nc);
905 blankLine(out, 0, nc, 0, 0, 0);
906 var n = Math.min(nc, ln.ch.length);
907 out.ch.set(ln.ch.subarray(0, n)); out.attr.set(ln.attr.subarray(0, n));
908 out.fg.set(ln.fg.subarray(0, n)); out.bg.set(ln.bg.subarray(0, n));
909 out.wrap = ln.wrap;
910 if (ln.ext) { out.ext = {}; for (var k in ln.ext) if (+k < nc) out.ext[k] = ln.ext[k]; }
911 return out;
912 }
913
914 S.reset = function () {
915 lines = []; alt = null; sb = []; dropped = 0; view = 0;
916 for (var i = 0; i < rows; i++) { var ln = newLine(cols); blankLine(ln, 0, cols, 0, 0, 0); lines.push(ln); }
917 cur = { x: 0, y: 0, fg: COL_DEFAULT, bg: COL_DEFAULT, attr: 0 };
918 saved = null; pend = false; top = 0; bot = rows - 1;
919 S.modes.appCursor = false; S.modes.appKeypad = false; S.modes.bracketed = false;
920 S.modes.alt = false; S.modes.mouse = 0; S.modes.mouseSgr = false;
921 S.modes.wrap = true; S.modes.reverse = false;
922 S.cursor.visible = true; S.cursor.shape = 'block';
923 st = 0; oEsc = false; oStr = ''; oKind = ''; markAll();
924 };
925
926 /// Everything the model holds, as text. The a11y mirror and a
927 /// select-all both want it, and neither should reach past the interface
928 /// to get it.
929 S.allText = function () {
930 var out = [];
931 for (var a = dropped; a < dropped + sb.length + rows; a++) out.push(S.lineText(a));
932 while (out.length && out[out.length - 1] === '') out.pop();
933 return out.join('\n');
934 };
935
936 return S;
937 }
938
939 // ── Colour resolution ───────────────────────────────────────────
940 //
941 // The 16 named colours come from the stylesheet, so the terminal wears the
942 // palette the app is wearing. 16..255 are the xterm cube and grey ramp,
943 // which are DEFINED values — a program asking for colour 208 wants that
944 // orange, and tinting it per theme would be answering a different question.
945
946 var CUBE = [0, 95, 135, 175, 215, 255];
947
948 function readPalette(el) {
949 var cs = getComputedStyle(el);
950 var pal = new Array(256), bgs = new Array(256);
951 for (var i = 0; i < 16; i++) {
952 pal[i] = (cs.getPropertyValue('--term-ansi-' + i) || '').trim() || '#888888';
953 // The same name can need two values. On a paper-white ground, colour
954 // 7 as LETTERING has to be dark or the words are invisible, and
955 // colour 7 as a BACKGROUND has to be pale or a status bar comes out
956 // inverted. Which one is wanted is known at the moment of asking, so
957 // it is asked: a background may override its own value, and where it
958 // does not, the two are the same colour.
959 bgs[i] = (cs.getPropertyValue('--term-ansi-bg-' + i) || '').trim() || pal[i];
960 }
961 for (var n = 16; n < 232; n++) {
962 var k = n - 16;
963 pal[n] = 'rgb(' + CUBE[(k / 36) | 0] + ',' + CUBE[((k / 6) | 0) % 6] + ',' + CUBE[k % 6] + ')';
964 bgs[n] = pal[n];
965 }
966 for (var g = 232; g < 256; g++) {
967 var v = 8 + (g - 232) * 10;
968 pal[g] = 'rgb(' + v + ',' + v + ',' + v + ')';
969 bgs[g] = pal[g];
970 }
971 return {
972 ansi: pal,
973 ansiBg: bgs,
974 fg: (cs.getPropertyValue('--term-fg') || '').trim() || '#ddd',
975 bg: (cs.getPropertyValue('--term-bg') || '').trim() || '#111',
976 cursor: (cs.getPropertyValue('--term-cursor') || '').trim() || '#ddd',
977 sel: (cs.getPropertyValue('--term-selection') || '').trim() || 'rgba(120,160,255,0.35)',
978 font: (cs.getPropertyValue('--term-font') || cs.getPropertyValue('--font-mono') || 'monospace').trim(),
979 size: parseFloat(cs.getPropertyValue('--term-font-size')) || 13,
980 };
981 }
982
983 /// One packed colour as something a canvas will take.
984 ///
985 /// The default and the indexed cases are table lookups; only twenty-four-bit
986 /// colour builds a string, and those are memoised — a `ls --color` prints
987 /// the same handful of colours thousands of times, and rebuilding
988 /// `rgb(r,g,b)` per cell is allocation in the tightest loop there is.
989 var rgbMemo = new Map();
990 function colourOf(pal, packed, dflt, forBg) {
991 var mode = (packed >>> 24) & 3;
992 if (mode === COL_DEFAULT) return dflt;
993 if (mode === COL_INDEXED) return (forBg ? pal.ansiBg : pal.ansi)[packed & 0xFF] || dflt;
994 var v = packed & 0xFFFFFF;
995 var hit = rgbMemo.get(v);
996 if (hit) return hit;
997 hit = 'rgb(' + ((v >> 16) & 255) + ',' + ((v >> 8) & 255) + ',' + (v & 255) + ')';
998 if (rgbMemo.size < 4096) rgbMemo.set(v, hit);
999 return hit;
1000 }
1001
1002 // ── Keys ────────────────────────────────────────────────────────
1003 //
1004 // An arrow key that sends the wrong bytes makes every REPL feel broken, so
1005 // these are the xterm sequences rather than something plausible. The
1006 // modifier code is xterm's: 1 + shift + 2*alt + 4*ctrl + 8*meta, appended as
1007 // a second parameter, and omitted entirely when it would be 1.
1008
1009 var TILDE = {
1010 Insert: 2, Delete: 3, PageUp: 5, PageDown: 6,
1011 F5: 15, F6: 17, F7: 18, F8: 19, F9: 20, F10: 21, F11: 23, F12: 24,
1012 };
1013 var ARROW = { ArrowUp: 'A', ArrowDown: 'B', ArrowRight: 'C', ArrowLeft: 'D', Home: 'H', End: 'F' };
1014 var FKEY = { F1: 'P', F2: 'Q', F3: 'R', F4: 'S' };
1015
1016 function modCode(ev) {
1017 return 1 + (ev.shiftKey ? 1 : 0) + (ev.altKey ? 2 : 0) + (ev.ctrlKey ? 4 : 0) + (ev.metaKey ? 8 : 0);
1018 }
1019
1020 /// The bytes one keypress sends, or null when the terminal should not send
1021 /// anything at all (a shortcut, or a key it does not own).
1022 ///
1023 /// Returned as a string of code points below 256 for the control sequences
1024 /// and as ordinary text otherwise; the caller UTF-8 encodes the lot, which
1025 /// is a no-op for the sequences and correct for the text.
1026 function keyBytes(ev, modes) {
1027 var k = ev.key, m = modCode(ev), tail = m > 1 ? ';' + m : '';
1028
1029 // A cursor key in application mode is SS3, not CSI — and only when it
1030 // carries no modifier, which is the rule readline and vi both assume.
1031 if (ARROW[k]) {
1032 var f = ARROW[k];
1033 if (m > 1) return '\x1b[1' + tail + f;
1034 return (modes.appCursor ? '\x1bO' : '\x1b[') + f;
1035 }
1036 if (TILDE[k] !== undefined) return '\x1b[' + TILDE[k] + tail + '~';
1037 if (FKEY[k]) return m > 1 ? '\x1b[1' + tail + FKEY[k] : '\x1bO' + FKEY[k];
1038
1039 switch (k) {
1040 case 'Enter':
1041 // CR, not LF. The line discipline turns it into a newline; sending
1042 // LF straight past it is what makes a shell echo a blank line.
1043 return ev.altKey ? '\x1b\r' : '\r';
1044 case 'Backspace':
1045 // DEL, which is what every Unix terminal has sent for decades.
1046 // Ctrl-Backspace is the one that sends BS, and readline binds it to
1047 // "delete the word behind".
1048 if (ev.ctrlKey) return '\x08';
1049 return ev.altKey ? '\x1b\x7f' : '\x7f';
1050 case 'Tab':
1051 if (ev.shiftKey) return '\x1b[Z';
1052 return ev.ctrlKey || ev.altKey ? null : '\t';
1053 case 'Escape':
1054 return '\x1b';
1055 case ' ':
1056 if (ev.ctrlKey) return '\x00'; // Ctrl-Space is NUL, the "set mark" of emacs
1057 break;
1058 default: break;
1059 }
1060
1061 if (ev.ctrlKey && !ev.altKey && !ev.metaKey && k.length === 1) {
1062 var cp = k.toUpperCase().charCodeAt(0);
1063 if (cp >= 64 && cp <= 95) return String.fromCharCode(cp - 64); // @ A..Z [ \ ] ^ _
1064 if (k === '?') return '\x7f';
1065 if (k === '/') return '\x1f';
1066 if (k === '-') return '\x1f';
1067 return null;
1068 }
1069 if (ev.metaKey) return null; // the browser's and the operating system's
1070 if (k.length === 1 || (k.codePointAt(0) > 0xFF && k.length === 2)) {
1071 return ev.altKey ? '\x1b' + k : k;
1072 }
1073 return null;
1074 }
1075
1076 // ── The terminal ────────────────────────────────────────────────
1077
1078 var enc = new TextEncoder();
1079 var seq = 0;
1080
1081 /// How long the cursor spends on and off. Honoured only where the reader has
1082 /// not asked for less motion — a blinking block in the corner of the eye is
1083 /// exactly what that setting exists to stop.
1084 var BLINK_MS = 530;
1085
1086 /// How long the page waits after the last byte before deciding the output
1087 /// has settled and is worth announcing. Short enough to feel prompt after a
1088 /// command, long enough that a build does not announce every line.
1089 var SETTLE_MS = 600;
1090
1091 /// How many lines an announcement will read out before it summarises
1092 /// instead. A screen reader given a whole build log recites it, and a person
1093 /// with a running commentary they cannot stop switches the announcements off.
1094 var SAY_MAX = 12;
1095
1096 function create(host, opts) {
1097 opts = opts || {};
1098 var id = 'term' + (++seq);
1099 var onData = opts.onData || function () {};
1100 var onResize = opts.onResize || function () {};
1101 var onBell = opts.onBell || null;
1102 var onTitle = opts.onTitle || null;
1103
1104 // ── the furniture ───────────────────────────────────
1105 var root = document.createElement('div');
1106 root.className = 'term';
1107 root.setAttribute('data-term', id);
1108
1109 var canvas = document.createElement('canvas');
1110 canvas.className = 'term-canvas';
1111 // The canvas is a picture of text the screen reader is given properly
1112 // elsewhere; announcing it as an image called "canvas" is worse than
1113 // silence.
1114 canvas.setAttribute('aria-hidden', 'true');
1115
1116 // A real textarea, not a div with a tabindex. It is what gives us an
1117 // input method for languages that need one, a paste event with the
1118 // clipboard on it, and a soft keyboard on a phone. It carries no text:
1119 // anything that lands in it is sent and cleared.
1120 var input = document.createElement('textarea');
1121 input.className = 'term-input';
1122 input.setAttribute('spellcheck', 'false');
1123 input.setAttribute('autocapitalize', 'off');
1124 input.setAttribute('autocorrect', 'off');
1125 input.setAttribute('autocomplete', 'off');
1126 // Bound where it is ours to name. A caller-supplied label is a finished
1127 // string with no key behind it, so it stays as given.
1128 // Bound through the same guard every other lookup in this file uses:
1129 // terminal.js is loaded standalone by three verifiers and by the pty
1130 // harness, where i18n.js is not there at all. A caller-supplied label is
1131 // a finished string with no key behind it, so it stays as given.
1132 if (opts.label) input.setAttribute('aria-label', opts.label);
1133 else bindOr(input, 'aria-label', 'term.label');
1134 input.setAttribute('aria-describedby', id + '-hint');
1135 input.setAttribute('aria-multiline', 'true');
1136 input.rows = 1;
1137
1138 var hint = document.createElement('p');
1139 hint.className = 'term-sr';
1140 hint.id = id + '-hint';
1141 hint.textContent = t('term.hint');
1142
1143 // What has SETTLED, announced once. See the accessibility note in the
1144 // file header for why this is not the screen itself.
1145 var say = document.createElement('div');
1146 say.className = 'term-sr';
1147 say.setAttribute('role', 'log');
1148 say.setAttribute('aria-live', 'polite');
1149 say.setAttribute('aria-atomic', 'false');
1150
1151 // The screen as text, for a reader to browse at their own pace. Not a
1152 // live region: it is the transcript, not the announcement.
1153 var mirror = document.createElement('div');
1154 mirror.className = 'term-sr term-mirror';
1155 mirror.setAttribute('role', 'region');
1156 bindOr(mirror, 'aria-label', 'term.screen_label');
1157
1158 var chip = document.createElement('div'); // the size, while it is changing
1159 chip.className = 'term-size';
1160 chip.setAttribute('aria-hidden', 'true');
1161
1162 var paste = document.createElement('div'); // the multi-line paste question
1163 paste.className = 'term-paste';
1164 paste.hidden = true;
1165
1166 var menu = document.createElement('div'); // what a right-click offers
1167 menu.className = 'term-menu';
1168 menu.hidden = true;
1169
1170 root.appendChild(canvas);
1171 root.appendChild(input);
1172 root.appendChild(chip);
1173 root.appendChild(paste);
1174 root.appendChild(menu);
1175 root.appendChild(hint);
1176 root.appendChild(say);
1177 root.appendChild(mirror);
1178 host.appendChild(root);
1179
1180 var ctx = canvas.getContext('2d', { alpha: false });
1181 var pal = readPalette(root);
1182 var cw = 8, chh = 16, base = 12, dpr = 1;
1183 var screen = createScreen(80, 24, { scrollback: opts.scrollback });
1184 var reduced = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
1185
1186 var sel = null; // { a: {abs,col}, b: {abs,col}, rect: bool } while there is one
1187 var dragging = false;
1188 var frame = 0;
1189 var blinkOn = true, blinkTimer = 0;
1190 var lastCur = { x: -1, y: -1 };
1191 var settleTimer = 0;
1192 var saidTo = -1; // the last absolute line already announced
1193 var wroteSince = 0;
1194 var alive = true;
1195
1196 // ── measurement ─────────────────────────────────────
1197 //
1198 // Never assume a character is eight pixels wide. The face comes from the
1199 // palette, the size from the type scale, and a user who has just made
1200 // the app's text larger has changed both.
1201
1202 function measure() {
1203 pal = readPalette(root);
1204 dpr = window.devicePixelRatio || 1;
1205 ctx.font = pal.size + 'px ' + pal.font;
1206 // A long run divided by its length, so the answer is not the
1207 // rounding of one glyph's advance. The fractional value is KEPT: a
1208 // rounded cell width drifts a whole column across eighty of them.
1209 var probe = 'MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM';
1210 cw = ctx.measureText(probe).width / probe.length;
1211 var m = ctx.measureText('Mg');
1212 var asc = m.actualBoundingBoxAscent || pal.size * 0.8;
1213 var desc = m.actualBoundingBoxDescent || pal.size * 0.2;
1214 chh = Math.ceil(asc + desc + Math.max(2, pal.size * 0.25));
1215 base = Math.round((chh + asc - desc) / 2);
1216 if (!(cw > 0)) cw = pal.size * 0.6;
1217 buildFonts();
1218 forgetCtx();
1219 }
1220
1221 /// Turn the box we have been given into columns and rows, and tell
1222 /// whoever is listening — which is the only way the kernel ever finds
1223 /// out, and a program that has not been told draws to the wrong width.
1224 function fit(quiet) {
1225 measure();
1226 var box = root.getBoundingClientRect();
1227 var pad = 4;
1228 var cols = Math.max(MIN_COLS, Math.floor((box.width - pad * 2) / cw));
1229 var rows = Math.max(MIN_ROWS, Math.floor((box.height - pad * 2) / chh));
1230 if (!isFinite(cols) || !isFinite(rows)) return;
1231 var changed = cols !== screen.cols || rows !== screen.rows;
1232 if (changed) screen.resize(cols, rows);
1233 canvas.width = Math.ceil(cols * cw * dpr);
1234 canvas.height = Math.ceil(rows * chh * dpr);
1235 canvas.style.width = (cols * cw) + 'px';
1236 canvas.style.height = (rows * chh) + 'px';
1237 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1238 ctx.textBaseline = 'alphabetic';
1239 forgetCtx();
1240 root.setAttribute('data-cols', cols);
1241 root.setAttribute('data-rows', rows);
1242 chip.textContent = cols + '×' + rows;
1243 // The FIRST measurement is always reported, even when it happens to
1244 // match the grid we started with: a host that is never told the size
1245 // never tells the kernel, and a program that has not been told draws
1246 // to eighty columns whatever the panel is.
1247 if (changed || !reported) {
1248 reported = true;
1249 if (!quiet) {
1250 chip.classList.add('on');
1251 clearTimeout(chip._t);
1252 chip._t = setTimeout(function () { chip.classList.remove('on'); }, 900);
1253 }
1254 onResize(cols, rows);
1255 }
1256 paint(true);
1257 }
1258 var MIN_COLS = 20, MIN_ROWS = 4;
1259 var reported = false;
1260
1261 // ── painting ────────────────────────────────────────
1262
1263 /// Whether a code point can be drawn inside a batched run.
1264 ///
1265 /// A run is one `fillText` for many cells, which is only sound while
1266 /// every glyph in it advances by exactly one cell. That is true of the
1267 /// monospace face's own ASCII and true of nothing else we can promise —
1268 /// a glyph the face lacks comes from a fallback with its own metrics, so
1269 /// anything outside ASCII is drawn cell by cell and centred.
1270 function runnable(cp) { return cp >= 0x20 && cp <= 0x7E; }
1271
1272 /// The box-drawing and block-element ranges, which have to MEET their
1273 /// neighbours rather than sit politely inside their own cell.
1274 function boxDrawing(cp) { return cp >= 0x2500 && cp <= 0x259F; }
1275
1276 /// The four font strings that exist — plain, bold, italic, both — built
1277 /// once per palette rather than once per run of text.
1278 var fonts = ['', '', '', ''];
1279 function buildFonts() {
1280 for (var i = 0; i < 4; i++) {
1281 fonts[i] = (i & 2 ? 'italic ' : '') + (i & 1 ? '700 ' : '') + pal.size + 'px ' + pal.font;
1282 }
1283 }
1284 function styleOf(attr) {
1285 return fonts[(attr & ATTR.BOLD ? 1 : 0) | (attr & ATTR.ITALIC ? 2 : 0)];
1286 }
1287
1288 // Assigning to `ctx.font` costs a font-shorthand parse EVERY time, even
1289 // when the value is the one already there, and a screen of alternating
1290 // styles assigns it once per cell. Measured on a 200×50 grid of
1291 // per-cell colour, guarding these three assignments took a full repaint
1292 // from 99 ms to 6 ms — the single largest thing in this file.
1293 var curFont = '', curFill = '', curAlpha = 1;
1294 function setFont(f) { if (f !== curFont) { ctx.font = f; curFont = f; } }
1295 function setFill(f) { if (f !== curFill) { ctx.fillStyle = f; curFill = f; } }
1296 function setAlpha(a) { if (a !== curAlpha) { ctx.globalAlpha = a; curAlpha = a; } }
1297 /// Resizing a canvas throws its whole context state away, so anything
1298 /// remembered about it has to be forgotten at the same moment or the
1299 /// guard above starts skipping assignments that are genuinely needed.
1300 function forgetCtx() { curFont = ''; curFill = ''; curAlpha = -1; }
1301
1302 /// Where a row's selection starts and ends, in columns, or null.
1303 function selOn(y) {
1304 if (!sel) return null;
1305 var a = sel.a, b = sel.b;
1306 if (a.abs > b.abs || (a.abs === b.abs && a.col > b.col)) { a = sel.b; b = sel.a; }
1307 var abs = screen.absOfRow(y);
1308 if (abs < a.abs || abs > b.abs) return null;
1309 if (sel.rect) {
1310 return { from: Math.min(a.col, b.col), to: Math.max(a.col, b.col) };
1311 }
1312 return {
1313 from: abs === a.abs ? a.col : 0,
1314 to: abs === b.abs ? b.col : screen.cols,
1315 };
1316 }
1317
1318 function paintRow(y, cells) {
1319 var cols = screen.cols, o = y * cols;
1320 var yTop = y * chh;
1321 var s = selOn(y);
1322 var rev = screen.modes.reverse;
1323 var defFg = rev ? pal.bg : pal.fg, defBg = rev ? pal.fg : pal.bg;
1324 // Asked once for the row, not once per cell: almost every row has
1325 // none, and this loop is the hottest one in the file.
1326 var exts = screen.extRow(y);
1327
1328 // Backgrounds first, as runs — a row of one colour is one rect.
1329 setAlpha(1);
1330 setFill(defBg);
1331 ctx.fillRect(0, yTop, cols * cw, chh);
1332 var x = 0;
1333 while (x < cols) {
1334 var attr = cells.attr[o + x];
1335 var inv = attr & ATTR.REVERSE;
1336 var bg = inv ? cells.fg[o + x] : cells.bg[o + x];
1337 var n = 1;
1338 while (x + n < cols
1339 && (cells.attr[o + x + n] & ATTR.REVERSE) === inv
1340 && (inv ? cells.fg[o + x + n] : cells.bg[o + x + n]) === bg) n++;
1341 if (bg !== 0 || inv) {
1342 var col = colourOf(pal, bg, inv ? defFg : defBg, true);
1343 if (col !== defBg) { setFill(col); ctx.fillRect(x * cw, yTop, n * cw, chh); }
1344 }
1345 x += n;
1346 }
1347 if (s) {
1348 setFill(pal.sel);
1349 ctx.fillRect(s.from * cw, yTop, Math.max(0, s.to - s.from) * cw, chh);
1350 }
1351
1352 // Then the glyphs, batched by style while it is safe to batch.
1353 x = 0;
1354 while (x < cols) {
1355 var a2 = cells.attr[o + x];
1356 if (a2 & ATTR.WIDE_TAIL) { x++; continue; }
1357 var cp = cells.ch[o + x] || 32;
1358 if (cp === 32 && !(a2 & (ATTR.UNDER | ATTR.STRIKE))) { x++; continue; }
1359 if (a2 & ATTR.HIDDEN) { x++; continue; }
1360 var fg = a2 & ATTR.REVERSE ? colourOf(pal, cells.bg[o + x], defBg) : colourOf(pal, cells.fg[o + x], defFg);
1361 setFont(styleOf(a2));
1362 setAlpha((a2 & ATTR.DIM) ? 0.62 : 1);
1363 setFill(fg);
1364 var extra = (exts && exts[x]) || '';
1365 if (!runnable(cp) || extra) {
1366 // Off the safe path: draw the one cell, centred, so a wide
1367 // glyph or a fallback face lands where the grid says.
1368 var str = String.fromCodePoint(cp) + extra;
1369 var w = (a2 & ATTR.WIDE) ? 2 : 1;
1370 var adv = ctx.measureText(str).width;
1371 if (adv > 0 && (boxDrawing(cp) || adv > w * cw + 0.5)) {
1372 // Stretched to the cell rather than centred in it. A
1373 // box-drawing rule has to MEET its neighbour or a table
1374 // comes out as a row of disconnected dashes — the mono
1375 // faces this app names all draw those glyphs wider than
1376 // their advance so they join on their own, but a face
1377 // that does not would break every table, and stretching
1378 // costs nothing on the ones that do. The same stretch
1379 // pulls an oversized fallback glyph back inside its own
1380 // cell instead of letting it print over its neighbour.
1381 ctx.save();
1382 ctx.translate(x * cw, yTop + base);
1383 ctx.scale((w * cw) / adv, 1);
1384 ctx.fillText(str, 0, 0);
1385 ctx.restore();
1386 if (a2 & ATTR.UNDER) ctx.fillRect(x * cw, yTop + base + 2, w * cw, 1);
1387 if (a2 & ATTR.STRIKE) ctx.fillRect(x * cw, yTop + base - pal.size * 0.3, w * cw, 1);
1388 x += w;
1389 continue;
1390 }
1391 var dx = adv <= w * cw ? (w * cw - adv) / 2 : 0;
1392 ctx.fillText(str, x * cw + dx, yTop + base);
1393 if (a2 & ATTR.UNDER) ctx.fillRect(x * cw, yTop + base + 2, w * cw, 1);
1394 if (a2 & ATTR.STRIKE) ctx.fillRect(x * cw, yTop + base - pal.size * 0.3, w * cw, 1);
1395 x += w;
1396 continue;
1397 }
1398 var run = '', n2 = 0;
1399 while (x + n2 < cols) {
1400 var an = cells.attr[o + x + n2];
1401 var cn = cells.ch[o + x + n2] || 32;
1402 if (an !== a2 || cells.fg[o + x + n2] !== cells.fg[o + x] || cells.bg[o + x + n2] !== cells.bg[o + x]) break;
1403 if (!runnable(cn) || (exts && exts[x + n2])) break;
1404 run += String.fromCharCode(cn);
1405 n2++;
1406 }
1407 ctx.fillText(run, x * cw, yTop + base);
1408 if (a2 & ATTR.UNDER) ctx.fillRect(x * cw, yTop + base + 2, n2 * cw, 1);
1409 if (a2 & ATTR.STRIKE) ctx.fillRect(x * cw, yTop + base - pal.size * 0.3, n2 * cw, 1);
1410 x += n2;
1411 }
1412 }
1413
1414 function paintCursor(cells) {
1415 var c = screen.cursor;
1416 if (!c.visible || c.y < 0 || c.y >= screen.rows) return;
1417 if (!focused()) {
1418 // Unfocused: an outline, so it is still findable but does not
1419 // claim to be taking your typing.
1420 ctx.strokeStyle = pal.cursor;
1421 ctx.lineWidth = 1;
1422 ctx.strokeRect(c.x * cw + 0.5, c.y * chh + 0.5, cw - 1, chh - 1);
1423 return;
1424 }
1425 if (!blinkOn) return;
1426 setAlpha(1);
1427 setFill(pal.cursor);
1428 if (c.shape === 'bar') { ctx.fillRect(c.x * cw, c.y * chh, Math.max(1, cw * 0.15), chh); return; }
1429 if (c.shape === 'underline') { ctx.fillRect(c.x * cw, c.y * chh + chh - 2, cw, 2); return; }
1430 ctx.fillRect(c.x * cw, c.y * chh, cw, chh);
1431 // The character under a block cursor is redrawn in the ground
1432 // colour, or the cursor swallows it.
1433 var o = c.y * screen.cols + c.x;
1434 var cp = cells.ch[o] || 32;
1435 if (cp !== 32) {
1436 setFont(styleOf(cells.attr[o]));
1437 setFill(pal.bg);
1438 ctx.fillText(String.fromCodePoint(cp), c.x * cw, c.y * chh + base);
1439 }
1440 }
1441
1442 /// One frame. `force` repaints everything; otherwise the damage decides.
1443 function paint(force) {
1444 if (!alive) return;
1445 var d = screen.compose(force);
1446 var cells = screen.cells;
1447 var all = force || d.all || (sel && d.scrolled);
1448 if (all) {
1449 setAlpha(1);
1450 setFill(screen.modes.reverse ? pal.fg : pal.bg);
1451 ctx.fillRect(0, 0, screen.cols * cw, screen.rows * chh);
1452 for (var y = 0; y < screen.rows; y++) paintRow(y, cells);
1453 } else {
1454 if (d.scrolled > 0 && d.scrolled < screen.rows) {
1455 // The load case. Everything above the new lines is already
1456 // drawn correctly one row higher, so move the pixels rather
1457 // than the glyphs and draw only what is genuinely new.
1458 var shift = d.scrolled * chh;
1459 ctx.save();
1460 ctx.setTransform(1, 0, 0, 1, 0, 0);
1461 ctx.drawImage(canvas,
1462 0, shift * dpr, canvas.width, canvas.height - shift * dpr,
1463 0, 0, canvas.width, canvas.height - shift * dpr);
1464 ctx.restore();
1465 }
1466 for (var y2 = 0; y2 < screen.rows; y2++) if (d.rows[y2]) paintRow(y2, cells);
1467 }
1468 // The cell the cursor has left must be repainted, and the one it has
1469 // arrived at drawn over.
1470 if (!all && (lastCur.x !== screen.cursor.x || lastCur.y !== screen.cursor.y)) {
1471 if (lastCur.y >= 0 && lastCur.y < screen.rows && !d.rows[lastCur.y]) paintRow(lastCur.y, cells);
1472 if (screen.cursor.y >= 0 && !d.rows[screen.cursor.y]) paintRow(screen.cursor.y, cells);
1473 }
1474 paintCursor(cells);
1475 lastCur.x = screen.cursor.x; lastCur.y = screen.cursor.y;
1476 drawScrollHint();
1477 }
1478
1479 /// A thumb down the right edge while there is history above. Drawn on the
1480 /// canvas rather than as a real scrollbar because the canvas IS the
1481 /// scrolling surface; there is no overflowing box for a browser to give
1482 /// a bar to.
1483 function drawScrollHint() {
1484 var sbLines = screen.scrollback();
1485 if (!sbLines) return;
1486 // The strip is CLEARED first. A blitted frame carries the previous
1487 // thumb up the canvas with it, so drawing the new one over the top
1488 // left a ghost of the old — and, worse, made the incremental frame
1489 // and the full repaint disagree, which is the one thing the blit is
1490 // not allowed to do.
1491 // On WHOLE pixels. The cell width is fractional by design, so a strip
1492 // placed at `cols * cw` has an antialiased edge that blends with
1493 // whatever lies under it — which differs between a blitted frame and
1494 // a repainted one, and that difference is a real, visible seam.
1495 var sx = Math.floor(screen.cols * cw) - 3;
1496 setAlpha(1);
1497 setFill(screen.modes.reverse ? pal.fg : pal.bg);
1498 ctx.fillRect(sx, 0, 3, screen.rows * chh);
1499 var total = sbLines + screen.rows;
1500 var h = Math.max(18, (screen.rows / total) * screen.rows * chh);
1501 var atTop = (sbLines - screen.viewOffset()) / sbLines;
1502 var y = atTop * (screen.rows * chh - h);
1503 setAlpha(screen.viewOffset() ? 0.55 : 0.22);
1504 setFill(pal.cursor);
1505 ctx.fillRect(sx, Math.round(y), 3, Math.round(h));
1506 setAlpha(1);
1507 }
1508
1509 function schedule() {
1510 if (frame) return;
1511 frame = requestAnimationFrame(function () { frame = 0; paint(false); });
1512 }
1513
1514 function focused() { return document.activeElement === input; }
1515
1516 // ── the model's own voice ───────────────────────────
1517 screen.onBell = function () {
1518 root.classList.add('bell');
1519 setTimeout(function () { root.classList.remove('bell'); }, 160);
1520 if (onBell) onBell();
1521 };
1522 screen.onTitle = function (s) { if (onTitle) onTitle(s); };
1523 screen.onReply = function (s) { send(s); };
1524
1525 // ── input ───────────────────────────────────────────
1526
1527 function send(str) {
1528 if (!str) return;
1529 onData(enc.encode(str));
1530 }
1531
1532 var composing = false;
1533 input.addEventListener('compositionstart', function () { composing = true; });
1534 input.addEventListener('compositionend', function (e) {
1535 composing = false;
1536 if (e.data) send(e.data);
1537 input.value = '';
1538 });
1539 // A soft keyboard on a phone reports keydown as 229 and delivers the
1540 // text here instead. Without this the terminal takes no typing at all on
1541 // Android.
1542 input.addEventListener('input', function () {
1543 if (composing) return;
1544 if (input.value) { send(input.value); input.value = ''; }
1545 });
1546
1547 input.addEventListener('keydown', function (ev) {
1548 if (composing) return;
1549 // The shortcuts the TERMINAL owns, before the program sees anything.
1550 if (ev.ctrlKey && ev.shiftKey && !ev.altKey) {
1551 var kk = ev.key.toLowerCase();
1552 if (kk === 'c') { ev.preventDefault(); copy(); return; }
1553 // Ctrl-Shift-V is the browser's own paste, and it must be LEFT
1554 // ALONE: preventing it stops the paste event ever firing, so the
1555 // shortcut the hint tells people to use would do nothing at all.
1556 // Plain Ctrl-V is a different key — it sends \x16, readline's
1557 // quoted-insert — and IS prevented, further down.
1558 if (kk === 'v') return;
1559 if (kk === 'a') { ev.preventDefault(); selectAll(); return; }
1560 }
1561 // PLAIN Ctrl-C AND Ctrl-V, which is what people reach for. Ctrl-Shift-C and
1562 // Ctrl-Shift-V still work and are what the hint names, because they are what
1563 // every other terminal on the machine uses.
1564 //
1565 // Ctrl-C COPIES ONLY WHERE THERE IS A SELECTION. With none it is the
1566 // interrupt, and the interrupt is not negotiable: it is the only way to stop
1567 // a program that is not going to stop by itself, and a terminal that had
1568 // taken it away would be broken in the one situation where being broken
1569 // costs the most. Typing clears the selection (see below), so the selection
1570 // under a Ctrl-C is one the person made deliberately and just now.
1571 if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
1572 var ck = ev.key.toLowerCase();
1573 if (ck === 'c' && sel && selectionText()) {
1574 ev.preventDefault();
1575 copy();
1576 sel = null; schedule();
1577 return;
1578 }
1579 // NOT prevented, and that is the whole mechanism: the field under the
1580 // pointer is focused, so the browser's own paste fires and the `paste`
1581 // listener below gets it -- including the multi-line question. Preventing
1582 // it here would send \x16, readline's quoted-insert, which is what this
1583 // key used to do and what nobody was reaching for.
1584 if (ck === 'v') return;
1585 }
1586 if (ev.shiftKey && (ev.key === 'PageUp' || ev.key === 'PageDown')) {
1587 ev.preventDefault();
1588 scrollLines(ev.key === 'PageUp' ? -(screen.rows - 1) : (screen.rows - 1));
1589 return;
1590 }
1591 if (ev.shiftKey && ev.ctrlKey === false && ev.key === 'Home' && screen.viewOffset()) {
1592 ev.preventDefault(); scrollTo(screen.scrollback()); return;
1593 }
1594 var bytes = keyBytes(ev, screen.modes);
1595 if (bytes === null) return; // not ours; let the browser have it
1596 ev.preventDefault();
1597 // Typing is a statement that you want to be where the program is.
1598 if (screen.viewOffset()) { screen.setViewOffset(0); schedule(); }
1599 if (sel) { sel = null; schedule(); }
1600 send(bytes);
1601 });
1602
1603 // ── paste ───────────────────────────────────────────
1604 //
1605 // A multi-line paste into a shell runs every line the moment it arrives.
1606 // Bracketed paste exists to stop exactly that, and a program that has
1607 // asked for it gets the text wrapped so it can tell paste from typing.
1608 // A program that has NOT asked is the dangerous case, and it is the one
1609 // where the person is asked first.
1610
1611 input.addEventListener('paste', function (ev) {
1612 ev.preventDefault();
1613 var text = (ev.clipboardData || window.clipboardData).getData('text');
1614 if (!text) return;
1615 pasteText(text);
1616 });
1617
1618 function pasteText(text) {
1619 text = text.replace(/\r\n/g, '\r').replace(/\n/g, '\r');
1620 if (screen.modes.bracketed) {
1621 // The terminator must not appear inside the payload, or the
1622 // paste ends early and the rest is typed.
1623 send('\x1b[200~' + text.replace(/\x1b\[201~/g, '') + '\x1b[201~');
1624 return;
1625 }
1626 var lines = text.split('\r').filter(function (s, i, a) { return i < a.length - 1 || s.length; });
1627 if (lines.length <= 1) { send(text); return; }
1628 askPaste(text, lines);
1629 }
1630
1631 /// The question, drawn inside the terminal rather than as a dialog: it
1632 /// belongs to this panel, and a modal over the whole app for a paste
1633 /// would be the wrong weight.
1634 function askPaste(text, lines) {
1635 paste.hidden = false;
1636 paste.innerHTML = '';
1637 var p = document.createElement('p');
1638 p.className = 'term-paste-say';
1639 p.textContent = t('term.paste_warn', { n: lines.length });
1640 var row = document.createElement('div');
1641 row.className = 'term-paste-row';
1642 function btn(label, cls, fn) {
1643 var b = document.createElement('button');
1644 b.type = 'button';
1645 b.className = 'term-paste-btn' + (cls ? ' ' + cls : '');
1646 b.textContent = label;
1647 b.addEventListener('click', function () { closePaste(); fn(); });
1648 row.appendChild(b);
1649 return b;
1650 }
1651 var first = btn(t('term.paste_first'), 'primary', function () { send(lines[0]); });
1652 btn(t('term.paste_all', { n: lines.length }), '', function () { send(text); });
1653 btn(t('common.cancel'), '', function () {});
1654 paste.appendChild(p);
1655 paste.appendChild(row);
1656 paste.addEventListener('keydown', escClose);
1657 first.focus();
1658 }
1659 function escClose(ev) { if (ev.key === 'Escape') { ev.stopPropagation(); closePaste(); } }
1660 function closePaste() {
1661 paste.hidden = true;
1662 paste.innerHTML = '';
1663 paste.removeEventListener('keydown', escClose);
1664 input.focus();
1665 }
1666
1667 // ── selection ───────────────────────────────────────
1668 //
1669 // A canvas has no text to select, so the whole of this is built: where
1670 // the pointer is in cells, what lies between two of those, and how it
1671 // reads back as text. Without it the terminal is one you cannot copy
1672 // from, which is one nobody will use.
1673
1674 function cellAt(ev) {
1675 var box = canvas.getBoundingClientRect();
1676 var x = Math.floor((ev.clientX - box.left) / cw);
1677 var y = Math.floor((ev.clientY - box.top) / chh);
1678 return {
1679 col: Math.max(0, Math.min(screen.cols, x)),
1680 row: Math.max(0, Math.min(screen.rows - 1, y)),
1681 };
1682 }
1683
1684 // The field that takes the keyboard is `pointer-events: none`, so the canvas is
1685 // what a finger touches. Focus is given here, inside the gesture, or a phone
1686 // would have a terminal with no soft keyboard. Passive: nothing is prevented.
1687 canvas.addEventListener('touchstart', function () { input.focus(); }, { passive: true });
1688
1689 // ── what a right-click offers ───────────────────────
1690 //
1691 // A canvas's own context menu is the browser's, and on a terminal it offers
1692 // "Save image as" -- an answer to a question nobody asked, in place of the
1693 // three a person wants. Reported on 2026-08-26: "right click ... currently
1694 // copy/saves the terminal as an image".
1695 //
1696 // Paste is offered rather than done, because a right-click paste into a shell
1697 // runs whatever was on the clipboard, and the multi-line question below is no
1698 // use to someone who did not mean to paste at all.
1699
1700 function closeMenu() {
1701 if (menu.hidden) return;
1702 menu.hidden = true;
1703 menu.innerHTML = '';
1704 input.focus();
1705 }
1706
1707 function openMenu(ev) {
1708 var box = canvas.getBoundingClientRect();
1709 var text = selectionText();
1710 menu.hidden = false;
1711 menu.innerHTML = '';
1712 menu.style.left = Math.max(0, Math.min(box.width - 8, ev.clientX - box.left)) + 'px';
1713 menu.style.top = Math.max(0, Math.min(box.height - 8, ev.clientY - box.top)) + 'px';
1714 var first = null;
1715 function item(label, enabled, fn) {
1716 var b = document.createElement('button');
1717 b.type = 'button';
1718 b.className = 'term-menu-item';
1719 b.textContent = label;
1720 b.disabled = !enabled;
1721 b.addEventListener('click', function () { closeMenu(); fn(); });
1722 menu.appendChild(b);
1723 if (enabled && !first) first = b;
1724 return b;
1725 }
1726 item(t('term.menu_copy'), !!text, function () { copy(); });
1727 item(t('term.menu_paste'), true, function () { pasteFromClipboard(); });
1728 item(t('term.menu_select_all'), true, function () { selectAll(); });
1729 menu.addEventListener('keydown', function (e) {
1730 if (e.key === 'Escape') { e.stopPropagation(); closeMenu(); }
1731 });
1732 if (first) first.focus();
1733 }
1734
1735 canvas.addEventListener('contextmenu', function (ev) {
1736 ev.preventDefault();
1737 openMenu(ev);
1738 });
1739 // Any other press dismisses it, the way every menu does.
1740 window.addEventListener('mousedown', function (ev) {
1741 if (!menu.hidden && !menu.contains(ev.target)) closeMenu();
1742 }, true);
1743
1744 /// The clipboard, asked for rather than waited for.
1745 ///
1746 /// A `paste` event only arrives when the browser's own paste fires, and a menu
1747 /// item is not that. `readText` needs a secure context and the user's
1748 /// permission; where it is refused the person is told to use the key, which
1749 /// always works because it IS the browser's paste.
1750 function pasteFromClipboard() {
1751 if (!navigator.clipboard || !navigator.clipboard.readText) {
1752 announce(t('term.clipboard_denied'));
1753 return;
1754 }
1755 navigator.clipboard.readText().then(function (text) {
1756 if (text) pasteText(text);
1757 }, function () { announce(t('term.clipboard_denied')); });
1758 }
1759
1760 canvas.addEventListener('mousedown', function (ev) {
1761 if (ev.button !== 0) return;
1762 input.focus();
1763 // While a program is reading the mouse, the mouse is the program's —
1764 // unless Shift is held, which is the escape hatch every terminal has
1765 // for exactly this.
1766 if (screen.modes.mouse && !ev.shiftKey) { mouseReport(ev, 'down'); ev.preventDefault(); return; }
1767 var c = cellAt(ev);
1768 var a = { abs: screen.absOfRow(c.row), col: c.col };
1769 if (ev.detail === 2) { selectWord(c); return; }
1770 if (ev.detail >= 3) { selectRow(c); return; }
1771 sel = { a: a, b: { abs: a.abs, col: a.col }, rect: ev.altKey };
1772 dragging = true;
1773 paint(true);
1774 ev.preventDefault();
1775 });
1776 window.addEventListener('mousemove', function (ev) {
1777 if (!dragging) return;
1778 var c = cellAt(ev);
1779 sel.b = { abs: screen.absOfRow(c.row), col: c.col };
1780 paint(true);
1781 });
1782 window.addEventListener('mouseup', function (ev) {
1783 if (screen.modes.mouse && !dragging && !ev.shiftKey) { mouseReport(ev, 'up'); return; }
1784 if (!dragging) return;
1785 dragging = false;
1786 if (sel && sel.a.abs === sel.b.abs && sel.a.col === sel.b.col) { sel = null; paint(true); }
1787 });
1788
1789 function selectWord(c) {
1790 var abs = screen.absOfRow(c.row);
1791 var text = screen.lineText(abs);
1792 var word = /[\w./~:@%+=-]/;
1793 var i = Math.min(c.col, text.length - 1), a = i, b = i;
1794 if (i < 0 || !word.test(text[i] || '')) { sel = null; paint(true); return; }
1795 while (a > 0 && word.test(text[a - 1])) a--;
1796 while (b < text.length - 1 && word.test(text[b + 1])) b++;
1797 sel = { a: { abs: abs, col: a }, b: { abs: abs, col: b + 1 }, rect: false };
1798 paint(true);
1799 }
1800 function selectRow(c) {
1801 var abs = screen.absOfRow(c.row);
1802 sel = { a: { abs: abs, col: 0 }, b: { abs: abs, col: screen.cols }, rect: false };
1803 paint(true);
1804 }
1805 function selectAll() {
1806 sel = { a: { abs: screen.absTop(), col: 0 },
1807 b: { abs: screen.absOfRow(screen.rows - 1), col: screen.cols }, rect: false };
1808 paint(true);
1809 }
1810
1811 /// What is selected, as text.
1812 ///
1813 /// A line the terminal itself wrapped is joined to the next, because it
1814 /// was one line before the width got in the way — a pasted path with a
1815 /// newline in the middle of it is not the path.
1816 function selectionText() {
1817 if (!sel) return '';
1818 var a = sel.a, b = sel.b;
1819 if (a.abs > b.abs || (a.abs === b.abs && a.col > b.col)) { a = sel.b; b = sel.a; }
1820 var out = [];
1821 for (var i = a.abs; i <= b.abs; i++) {
1822 var ln = screen.lineCells(i);
1823 if (!ln) continue;
1824 var from = sel.rect ? Math.min(a.col, b.col) : (i === a.abs ? a.col : 0);
1825 var to = sel.rect ? Math.max(a.col, b.col) : (i === b.abs ? b.col : screen.cols);
1826 var s = '';
1827 for (var x = from; x < to && x < ln.ch.length; x++) {
1828 if (ln.attr[x] & ATTR.WIDE_TAIL) continue;
1829 s += (ln.ch[x] ? String.fromCodePoint(ln.ch[x]) : ' ') + (ln.ext && ln.ext[x] ? ln.ext[x] : '');
1830 }
1831 out.push({ text: s.replace(/\s+$/, ''), joined: !sel.rect && ln.wrap && i < b.abs });
1832 }
1833 var acc = '';
1834 for (var k = 0; k < out.length; k++) {
1835 acc += out[k].text;
1836 if (k < out.length - 1) acc += out[k].joined ? '' : '\n';
1837 }
1838 return acc;
1839 }
1840
1841 function copy() {
1842 var text = selectionText();
1843 if (!text) { announce(t('term.nothing_selected')); return Promise.resolve(false); }
1844 var n = text.split('\n').length;
1845 var done = function () { announce(tn('term.copied', n)); return true; };
1846 if (navigator.clipboard && navigator.clipboard.writeText) {
1847 return navigator.clipboard.writeText(text).then(done, function () { return legacyCopy(text) && done(); });
1848 }
1849 return Promise.resolve(legacyCopy(text) && done());
1850 }
1851 function legacyCopy(text) {
1852 var ta = document.createElement('textarea');
1853 ta.value = text;
1854 ta.style.position = 'fixed';
1855 ta.style.opacity = '0';
1856 document.body.appendChild(ta);
1857 ta.select();
1858 var ok = false;
1859 try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
1860 document.body.removeChild(ta);
1861 input.focus();
1862 return ok;
1863 }
1864
1865 // ── the mouse, when the program is reading it ───────
1866
1867 function mouseReport(ev, kind) {
1868 var c = cellAt(ev);
1869 var b = ev.button === 1 ? 1 : ev.button === 2 ? 2 : 0;
1870 if (kind === 'wheel') b = ev.deltaY < 0 ? 64 : 65;
1871 if (ev.shiftKey) b += 4;
1872 if (ev.altKey) b += 8;
1873 if (ev.ctrlKey) b += 16;
1874 if (screen.modes.mouseSgr) {
1875 send('\x1b[<' + b + ';' + (c.col + 1) + ';' + (c.row + 1) + (kind === 'up' ? 'm' : 'M'));
1876 } else {
1877 if (kind === 'up') b = 3;
1878 send('\x1b[M' + String.fromCharCode(32 + b, 32 + c.col + 1, 32 + c.row + 1));
1879 }
1880 }
1881
1882 // ── scrolling ───────────────────────────────────────
1883
1884 function scrollTo(n) { if (screen.setViewOffset(n)) paint(true); }
1885 function scrollLines(n) { scrollTo(screen.viewOffset() - n); }
1886
1887 canvas.addEventListener('wheel', function (ev) {
1888 if (screen.modes.mouse) { mouseReport(ev, 'wheel'); ev.preventDefault(); return; }
1889 if (screen.modes.alt) {
1890 // A full-screen program has no scrollback of its own, so the
1891 // wheel becomes the arrow keys — which is what makes `less` and
1892 // `vim` answer a trackpad at all.
1893 var n = ev.deltaY < 0 ? 3 : 3;
1894 var k = ev.deltaY < 0 ? 'A' : 'B';
1895 var s = '';
1896 for (var i = 0; i < n; i++) s += (screen.modes.appCursor ? '\x1bO' : '\x1b[') + k;
1897 send(s);
1898 ev.preventDefault();
1899 return;
1900 }
1901 scrollLines(ev.deltaY < 0 ? -3 : 3);
1902 ev.preventDefault();
1903 }, { passive: false });
1904
1905 // ── what a screen reader is told ────────────────────
1906 //
1907 // The canvas is a picture, and a picture of a terminal is nothing at all
1908 // to a screen reader. Two separate things are provided, because they
1909 // answer two different questions.
1910 //
1911 // The LOG says what has just happened, once the output has settled.
1912 // Announcing as it arrives would recite a build log at the reader for
1913 // as long as the build ran — the same reasoning the guide gives for
1914 // announcing an answer once rather than as it is typed. So nothing is
1915 // said until the bytes stop for SETTLE_MS, and a torrent is summarised
1916 // rather than read.
1917 //
1918 // The MIRROR is the transcript, kept as ordinary text a reader can
1919 // arrow through at their own pace, updated on the same settling.
1920 //
1921 // What this does NOT achieve, and it should be said rather than implied:
1922 // no colour or emphasis is conveyed; the cursor's position is not
1923 // announced as it moves; a full-screen program that redraws in place
1924 // (`vim`, `top`) gives a snapshot with no narrative, so a reader is told
1925 // what the screen says and not what changed; and there is no braille
1926 // cursor routing.
1927
1928 function announce(text) {
1929 if (!text) return;
1930 var p = document.createElement('p');
1931 p.textContent = text;
1932 say.appendChild(p);
1933 while (say.childNodes.length > 4) say.removeChild(say.firstChild);
1934 }
1935
1936 function settle() {
1937 var last = screen.absOfRow(screen.rows - 1);
1938 var first = screen.absTop();
1939 if (saidTo < first - 1) saidTo = first - 1;
1940 var lines = [];
1941 for (var a = saidTo + 1; a <= last; a++) {
1942 var s = screen.lineText(a);
1943 if (s) lines.push(s);
1944 }
1945 saidTo = last;
1946 if (screen.modes.alt) {
1947 // A redraw is not a transcript. Say what is on the screen now.
1948 announce(t('term.screen_now') + ' ' + visibleText());
1949 } else if (lines.length > SAY_MAX) {
1950 announce(tn('term.printed_lines', lines.length)
1951 + ' ' + lines.slice(-SAY_MAX).join('. '));
1952 } else if (lines.length) {
1953 announce(lines.join('. '));
1954 }
1955 mirrorNow();
1956 wroteSince = 0;
1957 }
1958
1959 function visibleText() {
1960 var out = [];
1961 for (var y = 0; y < screen.rows; y++) {
1962 var s = screen.lineText(screen.absOfRow(y));
1963 if (s) out.push(s);
1964 }
1965 return out.join('. ');
1966 }
1967
1968 function mirrorNow() {
1969 var frag = document.createDocumentFragment();
1970 for (var y = 0; y < screen.rows; y++) {
1971 var d = document.createElement('div');
1972 d.textContent = screen.lineText(screen.absOfRow(y)) || ' ';
1973 frag.appendChild(d);
1974 }
1975 mirror.innerHTML = '';
1976 mirror.appendChild(frag);
1977 }
1978
1979 // ── the handle ──────────────────────────────────────
1980
1981 function write(data) {
1982 screen.write(data);
1983 wroteSince++;
1984 schedule();
1985 clearTimeout(settleTimer);
1986 settleTimer = setTimeout(settle, SETTLE_MS);
1987 }
1988
1989 /// Bytes as the wire carries them. Base64 is decoded here rather than by
1990 /// the caller so a chunk boundary in the middle of a UTF-8 character is
1991 /// the model's problem, which it already handles.
1992 function writeBase64(s) {
1993 var bin = atob(s);
1994 var u8 = new Uint8Array(bin.length);
1995 for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
1996 write(u8);
1997 }
1998
1999 // Focus is drawn on the wrapper, outside the canvas, against the panel
2000 // behind it — see terminal.css. Nothing is done here beyond repainting,
2001 // because the cursor's own look changes with focus.
2002 input.addEventListener('focus', function () { blinkOn = true; paint(true); });
2003 input.addEventListener('blur', function () { paint(true); });
2004
2005 function startBlink() {
2006 clearInterval(blinkTimer);
2007 if (reduced && reduced.matches) { blinkOn = true; return; }
2008 blinkTimer = setInterval(function () {
2009 if (!focused() || !screen.cursor.visible) return;
2010 blinkOn = !blinkOn;
2011 paint(false);
2012 }, BLINK_MS);
2013 }
2014 startBlink();
2015 if (reduced && reduced.addEventListener) reduced.addEventListener('change', startBlink);
2016
2017 // The palette can change under us at any moment. `data-theme` is what
2018 // the app stamps, so watching it is watching the actual event rather
2019 // than guessing from a custom one that may or may not be dispatched.
2020 var themeWatch = new MutationObserver(function () { measure(); paint(true); });
2021 themeWatch.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-ink', 'data-skin', 'style'] });
2022
2023 var ro = null, roTimer = 0;
2024 if (window.ResizeObserver) {
2025 ro = new ResizeObserver(function () {
2026 // Debounced: a drag of a panel edge fires this on every frame,
2027 // and each one would be a `TIOCSWINSZ` and a SIGWINCH to the
2028 // program. Programs redraw on that, so an undebounced resize is
2029 // a hundred full redraws while the mouse is moving.
2030 clearTimeout(roTimer);
2031 roTimer = setTimeout(function () { fit(false); }, 120);
2032 });
2033 ro.observe(root);
2034 }
2035
2036 if (document.fonts && document.fonts.ready) {
2037 document.fonts.ready.then(function () { if (alive) fit(true); });
2038 }
2039 fit(true);
2040 mirrorNow();
2041
2042 var handle = {
2043 el: root,
2044 screen: screen,
2045 write: write,
2046 writeBase64: writeBase64,
2047 /// Text straight into the model, for a note the terminal itself is
2048 /// making — an exit status, a refusal — never program output.
2049 say: function (line) { write('\r\n' + line + '\r\n'); },
2050 send: send,
2051 size: function () { return { cols: screen.cols, rows: screen.rows }; },
2052 cell: function () { return { w: cw, h: chh }; },
2053 fit: function () { fit(false); },
2054 focus: function () { input.focus(); },
2055 paste: pasteText,
2056 copy: copy,
2057 selection: selectionText,
2058 selectAll: selectAll,
2059 clearSelection: function () { sel = null; paint(true); },
2060 scrollLines: scrollLines,
2061 scrollToBottom: function () { scrollTo(0); },
2062 reset: function () { screen.reset(); sel = null; saidTo = -1; paint(true); },
2063 palette: function () { return pal; },
2064 destroy: function () {
2065 alive = false;
2066 clearInterval(blinkTimer);
2067 clearTimeout(settleTimer);
2068 clearTimeout(roTimer);
2069 if (ro) ro.disconnect();
2070 themeWatch.disconnect();
2071 if (frame) cancelAnimationFrame(frame);
2072 if (root.parentNode) root.parentNode.removeChild(root);
2073 },
2074 /// Test only: paint synchronously rather than waiting for a frame.
2075 /// `_paintNow` forces the whole grid; `_paintFrame` is the ordinary
2076 /// frame, which is the one the damage model actually decides.
2077 _paintNow: function () { paint(true); },
2078 _paintFrame: function () { paint(false); },
2079 };
2080 return handle;
2081 }
2082
2083 window.DaimondTerminal = {
2084 create: create,
2085 /// The screen model on its own, for a test or a future host that wants
2086 /// the grid without the drawing.
2087 screen: createScreen,
2088 charWidth: charWidth,
2089 ATTR: ATTR,
2090 /// Bytes as base64, which is how the hand's wire carries them.
2091 b64: function (u8) {
2092 var s = '';
2093 for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
2094 return btoa(s);
2095 },
2096 };
2097})();