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 | })(); |