anvilsign in

collin/anvil

1const DEFAULT_COLOR = 256;
2const FLAG_BOLD = 0x01;
3const FLAG_DIM = 0x02;
4const FLAG_ITALIC = 0x04;
5const FLAG_UNDERLINE = 0x08;
6const FLAG_REVERSE = 0x20;
7const FLAG_INVISIBLE = 0x40;
8const FLAG_STRIKETHROUGH = 0x80;
9const DEFAULT_SCROLLBACK_OVERSCAN_ROWS = 10;
10function rgbToCSS(packed) {
11 const r = (packed >> 16) & 0xff;
12 const g = (packed >> 8) & 0xff;
13 const b = packed & 0xff;
14 return `rgb(${r},${g},${b})`;
15}
16function colorToCSS(index) {
17 if (index === DEFAULT_COLOR)
18 return null;
19 if (index < 16)
20 return `var(--term-color-${index})`;
21 if (index < 232) {
22 const n = index - 16;
23 const r = Math.floor(n / 36) * 51;
24 const g = (Math.floor(n / 6) % 6) * 51;
25 const b = (n % 6) * 51;
26 return `rgb(${r},${g},${b})`;
27 }
28 const level = (index - 232) * 10 + 8;
29 return `rgb(${level},${level},${level})`;
30}
31function cellFgCSS(fg, fgRgb) {
32 if (fgRgb !== undefined)
33 return rgbToCSS(fgRgb);
34 return colorToCSS(fg);
35}
36function cellBgCSS(bg, bgRgb) {
37 if (bgRgb !== undefined)
38 return rgbToCSS(bgRgb);
39 return colorToCSS(bg);
40}
41function buildCellStyle(fg, bg, flags, fgRgb, bgRgb) {
42 let fgIdx = fg, bgIdx = bg, fgR = fgRgb, bgR = bgRgb;
43 if (flags & FLAG_REVERSE) {
44 const tmpIdx = fgIdx;
45 fgIdx = bgIdx;
46 bgIdx = tmpIdx;
47 const tmpR = fgR;
48 fgR = bgR;
49 bgR = tmpR;
50 if (fgR === undefined && fgIdx === DEFAULT_COLOR)
51 fgIdx = 0;
52 if (bgR === undefined && bgIdx === DEFAULT_COLOR)
53 bgIdx = 7;
54 }
55 const fgCSS = cellFgCSS(fgIdx, fgR);
56 const bgCSS = cellBgCSS(bgIdx, bgR);
57 let style = "";
58 if (fgCSS)
59 style += `color:${fgCSS};`;
60 if (bgCSS)
61 style += `background:${bgCSS};`;
62 if (flags & FLAG_BOLD)
63 style += "font-weight:bold;";
64 if (flags & FLAG_DIM)
65 style += "opacity:0.5;";
66 if (flags & FLAG_ITALIC)
67 style += "font-style:italic;";
68 const decorations = [];
69 if (flags & FLAG_UNDERLINE)
70 decorations.push("underline");
71 if (flags & FLAG_STRIKETHROUGH)
72 decorations.push("line-through");
73 if (decorations.length)
74 style += `text-decoration:${decorations.join(" ")};`;
75 if (flags & FLAG_INVISIBLE)
76 style += "visibility:hidden;";
77 return style;
78}
79function appendRun(parent, text, style) {
80 const span = document.createElement("span");
81 if (style)
82 span.style.cssText = style;
83 span.textContent = text;
84 parent.appendChild(span);
85}
86function escapeHTML(text) {
87 return text
88 .replace(/&/g, "&amp;")
89 .replace(/</g, "&lt;")
90 .replace(/>/g, "&gt;")
91 .replace(/"/g, "&quot;");
92}
93function safeLinkHref(uri) {
94 if (!uri)
95 return undefined;
96 try {
97 const url = new URL(uri);
98 return url.protocol === "http:" || url.protocol === "https:"
99 ? url.href
100 : undefined;
101 }
102 catch {
103 return undefined;
104 }
105}
106function linkIdentity(cell) {
107 if (!cell.linkUri)
108 return "";
109 return cell.linkKey ?? `fallback\0${cell.linkId ?? ""}\0${cell.linkUri}`;
110}
111function resolveColors(fg, bg, flags, fgRgb, bgRgb) {
112 let fgIdx = fg, bgIdx = bg, fgR = fgRgb, bgR = bgRgb;
113 if (flags & FLAG_REVERSE) {
114 [fgIdx, bgIdx] = [bgIdx, fgIdx];
115 [fgR, bgR] = [bgR, fgR];
116 if (fgR === undefined && fgIdx === DEFAULT_COLOR)
117 fgIdx = 0;
118 if (bgR === undefined && bgIdx === DEFAULT_COLOR)
119 bgIdx = 7;
120 }
121 return {
122 fg: cellFgCSS(fgIdx, fgR) || "var(--term-fg)",
123 bg: cellBgCSS(bgIdx, bgR) || "var(--term-bg)",
124 };
125}
126// Pixel-snapped vertical gradient stops keyed off `--term-row-height` so that
127// every cell paints the eighth-block boundary on the same physical pixel —
128// using raw percentages (e.g. `12.5%`) at the canonical 17px row-height
129// resolves to 2.125px and the browser rounds it differently across cells,
130// producing the per-cell jog Claude Code's horizontal-rule (`▔▔▔▔▔`) makes
131// visible against the row immediately below.
132const SNAP_1_8 = "round(calc(var(--term-row-height) * 0.125), 1px)";
133const SNAP_2_8 = "round(calc(var(--term-row-height) * 0.25), 1px)";
134const SNAP_3_8 = "round(calc(var(--term-row-height) * 0.375), 1px)";
135const SNAP_4_8 = "round(calc(var(--term-row-height) * 0.5), 1px)";
136const SNAP_5_8 = "round(calc(var(--term-row-height) * 0.625), 1px)";
137const SNAP_6_8 = "round(calc(var(--term-row-height) * 0.75), 1px)";
138const SNAP_7_8 = "round(calc(var(--term-row-height) * 0.875), 1px)";
139function getBlockBackground(cp, fg, bg) {
140 switch (cp) {
141 case 0x2580:
142 return `linear-gradient(${fg} ${SNAP_4_8},${bg} ${SNAP_4_8})`;
143 case 0x2581:
144 return `linear-gradient(${bg} ${SNAP_7_8},${fg} ${SNAP_7_8})`;
145 case 0x2582:
146 return `linear-gradient(${bg} ${SNAP_6_8},${fg} ${SNAP_6_8})`;
147 case 0x2583:
148 return `linear-gradient(${bg} ${SNAP_5_8},${fg} ${SNAP_5_8})`;
149 case 0x2584:
150 return `linear-gradient(${bg} ${SNAP_4_8},${fg} ${SNAP_4_8})`;
151 case 0x2585:
152 return `linear-gradient(${bg} ${SNAP_3_8},${fg} ${SNAP_3_8})`;
153 case 0x2586:
154 return `linear-gradient(${bg} ${SNAP_2_8},${fg} ${SNAP_2_8})`;
155 case 0x2587:
156 return `linear-gradient(${bg} ${SNAP_1_8},${fg} ${SNAP_1_8})`;
157 case 0x2588:
158 return fg;
159 case 0x2589:
160 return `linear-gradient(to right,${fg} 87.5%,${bg} 87.5%)`;
161 case 0x258a:
162 return `linear-gradient(to right,${fg} 75%,${bg} 75%)`;
163 case 0x258b:
164 return `linear-gradient(to right,${fg} 62.5%,${bg} 62.5%)`;
165 case 0x258c:
166 return `linear-gradient(to right,${fg} 50%,${bg} 50%)`;
167 case 0x258d:
168 return `linear-gradient(to right,${fg} 37.5%,${bg} 37.5%)`;
169 case 0x258e:
170 return `linear-gradient(to right,${fg} 25%,${bg} 25%)`;
171 case 0x258f:
172 return `linear-gradient(to right,${fg} 12.5%,${bg} 12.5%)`;
173 case 0x2590:
174 return `linear-gradient(to right,${bg} 50%,${fg} 50%)`;
175 case 0x2591:
176 return `color-mix(in srgb,${fg} 25%,${bg})`;
177 case 0x2592:
178 return `color-mix(in srgb,${fg} 50%,${bg})`;
179 case 0x2593:
180 return `color-mix(in srgb,${fg} 75%,${bg})`;
181 case 0x2594:
182 return `linear-gradient(${fg} ${SNAP_1_8},${bg} ${SNAP_1_8})`;
183 case 0x2595:
184 return `linear-gradient(to right,${bg} 87.5%,${fg} 87.5%)`;
185 default: {
186 const QUADRANTS = {
187 0x2596: [false, false, true, false],
188 0x2597: [false, false, false, true],
189 0x2598: [true, false, false, false],
190 0x2599: [true, false, true, true],
191 0x259a: [true, false, false, true],
192 0x259b: [true, true, true, false],
193 0x259c: [true, true, false, true],
194 0x259d: [false, true, false, false],
195 0x259e: [false, true, true, false],
196 0x259f: [false, true, true, true],
197 };
198 const q = QUADRANTS[cp];
199 if (!q)
200 return fg;
201 const [tl, tr, bl, br] = q;
202 if (tl && tr && bl && br)
203 return fg;
204 const layers = [];
205 const POS = ["0 0", "100% 0", "0 100%", "100% 100%"];
206 q.forEach((filled, i) => {
207 if (filled)
208 layers.push(`linear-gradient(${fg},${fg}) ${POS[i]}/50% 50% no-repeat`);
209 });
210 layers.push(bg);
211 return layers.join(",");
212 }
213 }
214}
215export class Renderer {
216 constructor(container) {
217 this.rows = 0;
218 this.cols = 0;
219 this.rowEls = [];
220 this.prevCursorRow = -1;
221 this.prevCursorCol = -1;
222 this.prevContainerBg = "";
223 this.prevRowBg = [];
224 this._scrollbackRowEls = [];
225 this._scrollbackStartKey = 0;
226 this._renderedScrollbackCount = -1;
227 this._renderedDiscardedCount = -1;
228 this._scrollbackTopSpacer = null;
229 this._scrollbackBottomSpacer = null;
230 this.container = container;
231 }
232 setup(cols, rows) {
233 this.cols = cols;
234 this.rows = rows;
235 this.container.innerHTML = "";
236 this.rowEls = [];
237 this.prevRowBg = [];
238 this._scrollbackRowEls = [];
239 this._scrollbackStartKey = 0;
240 this._renderedScrollbackCount = -1;
241 this._renderedDiscardedCount = -1;
242 const fragment = document.createDocumentFragment();
243 this._scrollbackTopSpacer = document.createElement("div");
244 this._scrollbackTopSpacer.className = "term-scrollback-spacer";
245 fragment.appendChild(this._scrollbackTopSpacer);
246 this._scrollbackBottomSpacer = document.createElement("div");
247 this._scrollbackBottomSpacer.className = "term-scrollback-spacer";
248 fragment.appendChild(this._scrollbackBottomSpacer);
249 for (let r = 0; r < rows; r++) {
250 const rowEl = document.createElement("div");
251 rowEl.className = "term-row";
252 fragment.appendChild(rowEl);
253 this.rowEls.push(rowEl);
254 }
255 this.container.appendChild(fragment);
256 this.prevCursorRow = -1;
257 this.prevCursorCol = -1;
258 }
259 _buildRowContent(rowEl, getCell, lineLen, cursorCol, rowIndex) {
260 let html = "";
261 let runStyle = "";
262 let runText = "";
263 let runCells = [];
264 let runStart = 0;
265 let runLinkKey = "";
266 let runLinkUri;
267 let outputLinkKey = "";
268 const appendContent = (content, linkKey, uri) => {
269 const href = safeLinkHref(uri);
270 const nextLinkKey = href ? linkKey : "";
271 if (nextLinkKey !== outputLinkKey) {
272 if (outputLinkKey)
273 html += "</a>";
274 if (nextLinkKey) {
275 html += `<a class="term-link" href="${escapeHTML(href)}" target="_blank" rel="noopener noreferrer">`;
276 }
277 outputLinkKey = nextLinkKey;
278 }
279 html += content;
280 };
281 const flushRun = (endCol) => {
282 if (!runText)
283 return;
284 const escaped = escapeHTML(runText);
285 let content = "";
286 if (cursorCol >= runStart && cursorCol < endCol) {
287 const offset = cursorCol - runStart;
288 const before = runCells.slice(0, offset).join("");
289 const cursorChar = runCells[offset] || " ";
290 const after = runCells.slice(offset + 1).join("");
291 if (before) {
292 content += runStyle
293 ? `<span style="${runStyle}">${escapeHTML(before)}</span>`
294 : `<span>${escapeHTML(before)}</span>`;
295 }
296 content += runStyle
297 ? `<span class="term-cursor" style="${runStyle}">${escapeHTML(cursorChar)}</span>`
298 : `<span class="term-cursor">${escapeHTML(cursorChar)}</span>`;
299 if (after) {
300 content += runStyle
301 ? `<span style="${runStyle}">${escapeHTML(after)}</span>`
302 : `<span>${escapeHTML(after)}</span>`;
303 }
304 }
305 else {
306 content += runStyle
307 ? `<span style="${runStyle}">${escaped}</span>`
308 : `<span>${escaped}</span>`;
309 }
310 appendContent(content, runLinkKey, runLinkUri);
311 runText = "";
312 runCells = [];
313 };
314 const appendStyledSpan = (className, style, text, linkKey, linkUri) => {
315 const classAttr = className ? ` class="${className}"` : "";
316 const styleAttr = style ? ` style="${style}"` : "";
317 appendContent(`<span${classAttr}${styleAttr}>${escapeHTML(text)}</span>`, linkKey, linkUri);
318 };
319 for (let col = 0; col < this.cols; col++) {
320 const cell = getCell(col);
321 const inBounds = col < lineLen;
322 const cp = inBounds ? cell.char : 0;
323 const width = inBounds ? (cell.width ?? 1) : 1;
324 const cellLinkKey = inBounds ? linkIdentity(cell) : "";
325 const cellLinkUri = inBounds ? cell.linkUri : undefined;
326 if (inBounds && width === 0) {
327 flushRun(col);
328 // Skipping is only right when this continues the wide cell to the
329 // left, which already covers both columns and its cursor. A width-0
330 // cell with no wide cell before it owns its column, so dropping it
331 // would shorten the row.
332 const continuesWide = col > 0 && (getCell(col - 1).width ?? 1) === 2;
333 if (!continuesWide) {
334 appendStyledSpan(col === cursorCol ? "term-cursor" : "", "", " ", cellLinkKey, cellLinkUri);
335 }
336 runStyle = "";
337 runLinkKey = "";
338 runLinkUri = undefined;
339 runText = "";
340 runCells = [];
341 runStart = col + 1;
342 continue;
343 }
344 if (inBounds && width === 2) {
345 flushRun(col);
346 // A scrollback row keeps the width it was stored at, so a narrower
347 // grid can put the last rendered column on a wide lead whose
348 // continuation is outside the row. Drawing the pair here would spill
349 // a second column past the row.
350 if (col + 1 >= this.cols) {
351 appendStyledSpan(col === cursorCol ? "term-cursor" : "", "", " ", cellLinkKey, cellLinkUri);
352 runStyle = "";
353 runLinkKey = "";
354 runLinkUri = undefined;
355 runText = "";
356 runCells = [];
357 runStart = col + 1;
358 continue;
359 }
360 const ch = cell.chars ?? (cp >= 32 ? String.fromCodePoint(cp) : " ");
361 const style = buildCellStyle(cell.fg, cell.bg, cell.flags, cell.fgRgb, cell.bgRgb);
362 const cls = cursorCol >= col && cursorCol < col + 2
363 ? "term-wide term-cursor"
364 : "term-wide";
365 appendStyledSpan(cls, style, ch, cellLinkKey, cellLinkUri);
366 runStyle = "";
367 runLinkKey = "";
368 runLinkUri = undefined;
369 runText = "";
370 runCells = [];
371 runStart = col + 2;
372 continue;
373 }
374 if (inBounds && cp >= 0x2580 && cp <= 0x259f) {
375 flushRun(col);
376 const colors = resolveColors(cell.fg, cell.bg, cell.flags, cell.fgRgb, cell.bgRgb);
377 const cls = col === cursorCol ? "term-block term-cursor" : "term-block";
378 const bg = getBlockBackground(cp, colors.fg, colors.bg);
379 const dim = cell.flags & FLAG_DIM ? "opacity:0.5;" : "";
380 appendContent(`<span class="${cls}" style="background:${bg};${dim}"></span>`, cellLinkKey, cellLinkUri);
381 runStyle = "";
382 runLinkKey = "";
383 runLinkUri = undefined;
384 runText = "";
385 runCells = [];
386 runStart = col + 1;
387 }
388 else {
389 const ch = cell.chars ?? (inBounds && cp >= 32 ? String.fromCodePoint(cp) : " ");
390 const style = inBounds
391 ? buildCellStyle(cell.fg, cell.bg, cell.flags, cell.fgRgb, cell.bgRgb)
392 : "";
393 if (style !== runStyle || cellLinkKey !== runLinkKey) {
394 flushRun(col);
395 runStyle = style;
396 runLinkKey = cellLinkKey;
397 runLinkUri = cellLinkUri;
398 runText = ch;
399 runCells = [ch];
400 runStart = col;
401 }
402 else {
403 runText += ch;
404 runCells.push(ch);
405 }
406 }
407 }
408 flushRun(this.cols);
409 if (outputLinkKey)
410 html += "</a>";
411 rowEl.innerHTML = html;
412 let bgCss = "";
413 if (lineLen >= this.cols && this.cols > 0) {
414 const lastCell = getCell(this.cols - 1);
415 let bgIdx = lastCell.bg;
416 let bgR = lastCell.bgRgb;
417 if (lastCell.flags & FLAG_REVERSE) {
418 bgIdx = lastCell.fg;
419 bgR = lastCell.fgRgb;
420 if (bgR === undefined && bgIdx === DEFAULT_COLOR)
421 bgIdx = 7;
422 }
423 bgCss = cellBgCSS(bgIdx, bgR) || "";
424 }
425 const boxShadow = bgCss ? `0 1px 0 ${bgCss}` : "";
426 if (rowIndex >= 0) {
427 if (bgCss !== (this.prevRowBg[rowIndex] ?? "")) {
428 rowEl.style.background = bgCss;
429 rowEl.style.boxShadow = boxShadow;
430 this.prevRowBg[rowIndex] = bgCss;
431 }
432 }
433 else {
434 rowEl.style.background = bgCss;
435 rowEl.style.boxShadow = boxShadow;
436 }
437 }
438 _buildScrollbackRowEl(core, sbOffset) {
439 const rowEl = document.createElement("div");
440 rowEl.className = "term-row term-scrollback-row";
441 const lineLen = core.getScrollbackLineLen(sbOffset);
442 this._buildRowContent(rowEl, (col) => core.getScrollbackCell(sbOffset, col), lineLen, -1, -1);
443 return rowEl;
444 }
445 syncScrollback(core, viewport) {
446 const scrollbackCount = core.getScrollbackCount();
447 const rowHeight = viewport?.rowHeight ?? 0;
448 const virtual = viewport !== undefined && rowHeight > 0;
449 const overscan = viewport?.overscanRows ?? DEFAULT_SCROLLBACK_OVERSCAN_ROWS;
450 const hasDiscardedCount = viewport?.scrollbackDiscardedCount !== undefined;
451 const discardedCount = viewport?.scrollbackDiscardedCount ?? 0;
452 const viewportHeight = viewport && viewport.clientHeight > 0
453 ? viewport.clientHeight
454 : this.rows * rowHeight;
455 const firstVisible = virtual
456 ? Math.floor(viewport.scrollTop / rowHeight)
457 : 0;
458 const visibleRows = virtual
459 ? Math.ceil(viewportHeight / rowHeight)
460 : scrollbackCount;
461 let start = virtual
462 ? Math.max(0, Math.min(scrollbackCount, firstVisible - overscan))
463 : 0;
464 let end = virtual
465 ? Math.max(start, Math.min(scrollbackCount, firstVisible + visibleRows + overscan))
466 : scrollbackCount;
467 const selection = this.container.ownerDocument.getSelection();
468 const selectionInContainer = selection !== null &&
469 !selection.isCollapsed &&
470 (this.container.contains(selection.anchorNode) ||
471 this.container.contains(selection.focusNode));
472 if (selectionInContainer && this._scrollbackRowEls.length > 0) {
473 start = Math.min(start, Math.max(0, this._scrollbackStartKey - discardedCount));
474 end = Math.max(end, Math.min(scrollbackCount, this._scrollbackStartKey -
475 discardedCount +
476 this._scrollbackRowEls.length));
477 }
478 const startKey = discardedCount + start;
479 if (hasDiscardedCount &&
480 scrollbackCount === this._renderedScrollbackCount &&
481 discardedCount === this._renderedDiscardedCount &&
482 startKey === this._scrollbackStartKey &&
483 end - start === this._scrollbackRowEls.length) {
484 return;
485 }
486 const previous = new Map();
487 for (let i = 0; i < this._scrollbackRowEls.length; i++) {
488 previous.set(this._scrollbackStartKey + i, this._scrollbackRowEls[i]);
489 }
490 const endKey = discardedCount + end;
491 for (const [key, rowEl] of previous) {
492 if (key < startKey || key >= endKey)
493 rowEl.remove();
494 }
495 const nextRows = [];
496 let nextSibling = this._scrollbackTopSpacer?.nextSibling ?? null;
497 for (let index = start; index < end; index++) {
498 const key = discardedCount + index;
499 const offset = scrollbackCount - 1 - index;
500 const candidate = this._buildScrollbackRowEl(core, offset);
501 const existing = previous.get(key);
502 let rowEl = candidate;
503 let positioned = false;
504 if (existing &&
505 existing.innerHTML === candidate.innerHTML &&
506 existing.style.cssText === candidate.style.cssText) {
507 rowEl = existing;
508 }
509 else if (existing) {
510 existing.replaceWith(candidate);
511 positioned = true;
512 }
513 if (!positioned && rowEl !== nextSibling) {
514 this.container.insertBefore(rowEl, nextSibling ?? this._scrollbackBottomSpacer);
515 }
516 nextSibling = rowEl.nextSibling;
517 nextRows.push(rowEl);
518 }
519 this._scrollbackRowEls = nextRows;
520 this._scrollbackStartKey = startKey;
521 this._renderedScrollbackCount = scrollbackCount;
522 this._renderedDiscardedCount = discardedCount;
523 if (this._scrollbackTopSpacer) {
524 this._scrollbackTopSpacer.style.height = `${start * rowHeight}px`;
525 }
526 if (this._scrollbackBottomSpacer) {
527 this._scrollbackBottomSpacer.style.height = `${(scrollbackCount - end) * rowHeight}px`;
528 }
529 }
530 render(core, viewport) {
531 const rows = core.getRows();
532 const cols = core.getCols();
533 let resized = false;
534 if (rows !== this.rows || cols !== this.cols) {
535 this.setup(cols, rows);
536 resized = true;
537 }
538 this.syncScrollback(core, viewport);
539 const cursor = core.getCursor();
540 const cursorVisible = cursor.visible;
541 const needsCursorUpdate = cursor.row !== this.prevCursorRow || cursor.col !== this.prevCursorCol;
542 for (let r = 0; r < this.rows; r++) {
543 const isDirty = resized || core.isDirtyRow(r);
544 const hadCursor = r === this.prevCursorRow && needsCursorUpdate;
545 const hasCursor = r === cursor.row;
546 if (isDirty || hadCursor || (hasCursor && needsCursorUpdate)) {
547 const cCol = hasCursor && cursorVisible ? cursor.col : -1;
548 this._buildRowContent(this.rowEls[r], (col) => core.getCell(r, col), this.cols, cCol, r);
549 }
550 }
551 this.prevCursorRow = cursor.row;
552 this.prevCursorCol = cursor.col;
553 const lastRowDirty = resized || core.isDirtyRow(this.rows - 1);
554 if (lastRowDirty) {
555 const bottomRight = core.getCell(this.rows - 1, this.cols - 1);
556 let gridBgIdx = bottomRight.bg;
557 let gridBgRgb = bottomRight.bgRgb;
558 if (bottomRight.flags & FLAG_REVERSE) {
559 gridBgIdx = bottomRight.fg;
560 gridBgRgb = bottomRight.fgRgb;
561 if (gridBgRgb === undefined && gridBgIdx === DEFAULT_COLOR)
562 gridBgIdx = 7;
563 }
564 const containerBg = cellBgCSS(gridBgIdx, gridBgRgb) || "";
565 if (containerBg !== this.prevContainerBg) {
566 this.container.style.background = containerBg;
567 this.prevContainerBg = containerBg;
568 }
569 }
570 core.clearDirty();
571 }
572}