1// DOM renderer: dirty rows repaint as span runs, once per animation frame.
2// Deliberately DOM (not canvas/GL): terminal output keeps native selection,
3// browser find, and accessibility; throughput lands on the wasm parser, which
4// absorbs bytes at full speed while this layer paints at most once per frame.
5
6import type {
7 CellSnapshot,
8 FrameReader,
9 FrameSnapshot,
10 RowSnapshot,
11 Scrollbar,
12 Terminal,
13} from "../ghostty/terminal.ts";
14import { detectLinks, type DetectedLink, type DetectLinksOptions } from "../links.ts";
15import {
16 isPredictableText,
17 PredictionEngine,
18 resolvePredictOptions,
19 type PredictionMode,
20 type PredictionSite,
21 type PredictOptions,
22} from "../predict/engine.ts";
23import { columnTexts } from "../predict/frame.ts";
24import { cellsToRuns, cellText, linkCells, rowText } from "../runs.ts";
25import { stickyCommand, stickyTarget } from "../shell/sticky.ts";
26
27type GlyphFitMode = "wide" | "symbol" | "powerline";
28
29interface FittedGlyph {
30 element: HTMLElement;
31 key: string;
32 cache: boolean;
33 mode: GlyphFitMode;
34 target: number;
35}
36
37interface GlyphFit {
38 scaleX: number;
39 scaleY: number;
40 marginLeft: number;
41}
42
43const GLYPH_FIT_CACHE_LIMIT = 512;
44const GLYPH_FIT_CACHE_TEXT_LIMIT = 64;
45// Matches terminal.css: 10px track + 3px right inset.
46const SCROLLBAR_GUTTER = 13;
47const FONT_LOADING_CLASS = "clo-term-font-loading";
48const FONT_ERROR_EVENT = "clo-terminal-font-error";
49const SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000;
50const SYMBOL_FONT_FAMILY = "__clo_terminal_symbols_3_4_0";
51const ROOT_CLASSES = [
52 "clo-term",
53 "clo-term-has-scrollbar",
54 "clo-term-has-blink",
55 FONT_LOADING_CLASS,
56] as const;
57const ROOT_STYLE_PROPERTIES = [
58 "--clo-term-effective-fg",
59 "--clo-term-effective-bg",
60 "--clo-term-effective-cursor",
61] as const;
62/** The palette's first 16 slots, in index order: the eight hues, then the same eight bright. */
63const ANSI_HUES = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"];
64const ANSI_TOKENS = [
65 ...ANSI_HUES.map((hue) => `--clo-term-ansi-${hue}`),
66 ...ANSI_HUES.map((hue) => `--clo-term-ansi-bright-${hue}`),
67];
68const PRIMARY_FONT_SAMPLE = "W";
69const SYMBOL_FONT_SAMPLE = "\uE0A3";
70const POWERLINE_SPRITES = new Map<number, string>([
71 [0xe0b0, "right-arrow"],
72 [0xe0b2, "left-arrow"],
73 [0xe0b4, "right-round"],
74 [0xe0b6, "left-round"],
75 [0xe0b8, "lower-left"],
76 [0xe0ba, "lower-right"],
77 [0xe0bc, "upper-left"],
78 [0xe0be, "upper-right"],
79]);
80
81function privateUseBaseCodepoint(text: string): number | null {
82 const codepoint = text.codePointAt(0);
83 if (codepoint === undefined) return null;
84 return (codepoint >= 0xe000 && codepoint <= 0xf8ff) ||
85 (codepoint >= 0xf0000 && codepoint <= 0xffffd) ||
86 (codepoint >= 0x100000 && codepoint <= 0x10fffd)
87 ? codepoint
88 : null;
89}
90
91function applyGlyphFit(element: HTMLElement, fit: GlyphFit): void {
92 element.style.marginLeft = `${fit.marginLeft}px`;
93 element.style.transform = `scale(${fit.scaleX}, ${fit.scaleY})`;
94}
95
96interface FontCompletionEvent extends Event {
97 readonly fontfaces: readonly FontFace[];
98}
99
100interface FontCompletionSource {
101 addEventListener(type: "loadingdone", listener: EventListener): void;
102 removeEventListener(type: "loadingdone", listener: EventListener): void;
103}
104
105function parseFontFamilies(value: string): string[] {
106 const families: string[] = [];
107 let start = 0;
108 let quote = "";
109 let escaped = false;
110 for (let index = 0; index < value.length; index++) {
111 const char = value[index]!;
112 if (escaped) escaped = false;
113 else if (char === "\\") escaped = true;
114 else if (quote !== "") {
115 if (char === quote) quote = "";
116 } else if (char === '"' || char === "'") quote = char;
117 else if (char === ",") {
118 families.push(value.slice(start, index).trim());
119 start = index + 1;
120 }
121 }
122 families.push(value.slice(start).trim());
123 return families.filter((family) => family !== "");
124}
125
126function normalizeFontFamily(value: string): string {
127 const family = value.trim();
128 const quote = family[0];
129 const unquoted =
130 family.length >= 2 && (quote === '"' || quote === "'") && family.at(-1) === quote
131 ? family.slice(1, -1)
132 : family;
133 return unquoted.replace(/\\(.)/g, "$1").toLowerCase();
134}
135
136function sameCells(left: readonly CellSnapshot[] | undefined, right: readonly CellSnapshot[]) {
137 return (
138 left !== undefined &&
139 left.length === right.length &&
140 left.every((cell, index) => {
141 const other = right[index]!;
142 return (
143 cell.text === other.text &&
144 cell.width === other.width &&
145 cell.spacer === other.spacer &&
146 cell.protected === other.protected &&
147 cell.semantic === other.semantic &&
148 cell.hasOsc8 === other.hasOsc8 &&
149 cell.osc8Uri === other.osc8Uri &&
150 cell.fg === other.fg &&
151 cell.bg === other.bg &&
152 cell.underlineColor === other.underlineColor &&
153 cell.bold === other.bold &&
154 cell.italic === other.italic &&
155 cell.faint === other.faint &&
156 cell.blink === other.blink &&
157 cell.inverse === other.inverse &&
158 cell.invisible === other.invisible &&
159 cell.strikethrough === other.strikethrough &&
160 cell.overline === other.overline &&
161 cell.underline === other.underline
162 );
163 })
164 );
165}
166
167function sameRowMetadata(left: RowSnapshot | undefined, right: RowSnapshot): boolean {
168 return (
169 left !== undefined &&
170 left.wrap === right.wrap &&
171 left.wrapContinuation === right.wrapContinuation &&
172 left.semanticPrompt === right.semanticPrompt &&
173 left.hyperlink === right.hyperlink
174 );
175}
176
177export interface TerminalLinkOptions extends DetectLinksOptions {
178 /**
179 * Called on click (or ctrl/cmd-click, per `requireModifier`). Without it,
180 * links are detected and styled but inert — the embedder decides what a
181 * path, URL, or OSC 8 URI means.
182 */
183 onActivate?: (link: DetectedLink, event: MouseEvent) => void;
184 /** Require ctrl/cmd like an editor, rather than plain click. Default false. */
185 requireModifier?: boolean;
186}
187
188export interface TerminalViewOptions {
189 /** Let the host claim application shortcuts before VT encoding. */
190 onBeforeKey?: (event: KeyboardEvent) => boolean;
191 /** Hidden terminals keep their grid and dirty rows until shown. */
192 visible?: boolean;
193 /** All terminal-bound input: keystrokes, paste, and query responses. */
194 onData?: (bytes: Uint8Array) => void;
195 /** Wheel scrollback is enabled unless the alternate screen has the viewport. */
196 scrollback?: boolean;
197 /** Render the terminal-owned scrollbar and reserve its gutter. Default true. */
198 scrollbar?: boolean;
199 /** Render OSC 8 links and detect URLs/`path:line:col`. Off when omitted. */
200 links?: TerminalLinkOptions;
201 /**
202 * Forward mouse events to programs that ask for them (vim, htop, lazygit).
203 * Only active while the program has tracking on, so ordinary output keeps
204 * native selection. Default true.
205 */
206 mouse?: boolean;
207 /**
208 * Pin the running command's line at the top while scrolled inside its
209 * output. Needs shell integration to have anything to pin; inert otherwise.
210 * Default true.
211 */
212 stickyScroll?: boolean;
213 /**
214 * Paint typed characters speculatively, before the pty echoes them, to hide
215 * round-trip latency on a remote host. Off by default, and inert without
216 * shell integration: prediction only runs inside a proven readline region.
217 * See [`../predict/engine.ts`](../predict/engine.ts).
218 */
219 predict?: PredictOptions | PredictionMode | boolean;
220}
221
222const num = (v: number) => `#${v.toString(16).padStart(6, "0")}`;
223
224/**
225 * A row as it would look with speculative glyphs applied, plus the indices of
226 * the cells that are speculative. Only overlaid columns allocate a derived
227 * cell; every other entry stays the canonical reference the run grouper expects.
228 *
229 * The row is extended with blank cells when a prediction lands past the
230 * canonical default tail, which is the normal case: you type into empty space.
231 */
232function applyOverlay(
233 row: RowSnapshot,
234 overlay: ReadonlyMap<number, string>,
235): { row: RowSnapshot; predicted: Set<number> } {
236 const cells = [...row.cells];
237 const predicted = new Set<number>();
238 // Cell indices are not columns — wide glyphs advance two — so walk the row
239 // once and record where each column's cell lives.
240 const cellAtColumn = new Map<number, number>();
241 let column = 0;
242 for (let index = 0; index < cells.length; index++) {
243 cellAtColumn.set(column, index);
244 column += Math.max(1, cells[index]!.width);
245 }
246 const blank = (): CellSnapshot => ({
247 text: "",
248 width: 1,
249 spacer: "none",
250 protected: false,
251 semantic: "input",
252 hasOsc8: false,
253 osc8Uri: null,
254 fg: -1,
255 bg: -1,
256 underlineColor: -1,
257 bold: false,
258 italic: false,
259 faint: false,
260 blink: false,
261 inverse: false,
262 invisible: false,
263 strikethrough: false,
264 overline: false,
265 underline: 0,
266 });
267 for (const [col, text] of [...overlay].sort((a, b) => a[0] - b[0])) {
268 let index = cellAtColumn.get(col);
269 if (index === undefined) {
270 // Past the trimmed tail: pad with blanks up to the predicted column.
271 while (column <= col) {
272 cellAtColumn.set(column, cells.length);
273 cells.push(blank());
274 column++;
275 }
276 index = cellAtColumn.get(col);
277 if (index === undefined) continue;
278 }
279 const base = cells[index]!;
280 // Inherit the canonical cell's presentation so a prediction adopts the
281 // prompt's colors rather than inventing its own.
282 cells[index] = { ...base, text, width: 1, spacer: "none" };
283 predicted.add(index);
284 }
285 return { row: { ...row, cells }, predicted };
286}
287
288export class TerminalView {
289 readonly element: HTMLElement;
290 readonly input: HTMLTextAreaElement;
291 /** Resolves after the first font-ready or fallback geometry transaction. */
292 readonly ready: Promise<void>;
293 private terminal: Terminal;
294 private frameReader: FrameReader;
295 private options: TerminalViewOptions;
296 private viewportEl: HTMLElement;
297 private rowsHost: HTMLElement;
298 private cursorEl: HTMLElement;
299 private metricProbe: HTMLElement;
300 private scrollbarEl: HTMLElement | null = null;
301 private scrollbarThumb: HTMLElement | null = null;
302 private stickyEl: HTMLElement | null = null;
303 /** Painted rows keyed in Ghostty's absolute scrollbar/screen row space. */
304 private rowEls = new Map<number, HTMLElement>();
305 private rowSnapshots = new Map<number, RowSnapshot>();
306 private rowLinks = new Map<number, DetectedLink[]>();
307 /** Last cell a motion event was reported for; suppresses per-pixel reports. */
308 private lastMouseCell = "";
309 private predictor: PredictionEngine;
310 /**
311 * Rows currently carrying a speculative glyph. Load-bearing: `rowSnapshots`
312 * stays canonical, so the next real frame's `sameCells` check would consider
313 * an overlaid row unchanged and leave a retracted prediction on screen. These
314 * rows repaint unconditionally.
315 */
316 private overlaidRows = new Set<number>();
317 /** The last cursor the emulator reported, in absolute row space. */
318 private realCursor: { row: number; col: number; wide: boolean; className: string } | null = null;
319 private cursorUnderline = false;
320 private firstRowNow = 0;
321 /** Which command the sticky header currently shows; avoids per-frame DOM writes. */
322 private stickyCommandId: number | null = null;
323 private frameQueued = false;
324 private frameToken = 0;
325 private automaticMetricRefreshPending = false;
326 private contentFramePending = false;
327 private synchronizedOutputTimeout: ReturnType<typeof setTimeout> | null = null;
328 private needFull = true;
329 private frameForeground = Number.NaN;
330 private frameBackground = Number.NaN;
331 private frameCursorColor = Number.NaN;
332 private cellW = 8;
333 private cellH = 17;
334 /** Visual viewport top. Ghostty itself remains on an adjacent integer row. */
335 private scrollPosition: number | null = null;
336 private scrollMax = 0;
337 private terminalViewportRevision: number;
338 private scrollbarDrag: { pointerId: number; y: number; offset: number } | null = null;
339 private scrollbarTrackHeight: number | null = null;
340 private glyphFits = new Map<string, GlyphFit>();
341 private detach: Array<() => void> = [];
342 private observer: ResizeObserver | null = null;
343 private metricObserver: ResizeObserver | null = null;
344 private completedMetricResize: { width: number; height: number } | null = null;
345 private resizeCallback: ((size: { cols: number; rows: number }) => void) | null = null;
346 private initialized = false;
347 private visible: boolean;
348 private selectingOutput = false;
349 private disposedView = false;
350 private initialTabIndex: string | null;
351 private initialClasses: Set<string>;
352 private initialRootStyles: Array<{ name: string; value: string; priority: string }>;
353
354 constructor(container: HTMLElement, terminal: Terminal, options: TerminalViewOptions = {}) {
355 this.terminal = terminal;
356 this.frameReader = terminal.acquireFrameReader();
357 this.terminalViewportRevision = terminal.viewportRevision;
358 this.options = options;
359 this.visible = options.visible !== false;
360 this.predictor = new PredictionEngine(resolvePredictOptions(options.predict));
361 this.element = container;
362 this.initialTabIndex = container.getAttribute("tabindex");
363 this.initialClasses = new Set(
364 ROOT_CLASSES.filter((name) => container.classList.contains(name)),
365 );
366 this.initialRootStyles = ROOT_STYLE_PROPERTIES.map((name) => ({
367 name,
368 value: container.style.getPropertyValue(name),
369 priority: container.style.getPropertyPriority(name),
370 }));
371 try {
372 this.input = container.ownerDocument.createElement("textarea");
373 this.viewportEl = container.ownerDocument.createElement("div");
374 this.rowsHost = container.ownerDocument.createElement("div");
375 this.cursorEl = container.ownerDocument.createElement("div");
376 this.metricProbe = container.ownerDocument.createElement("span");
377 } catch (error) {
378 this.frameReader.dispose();
379 throw error;
380 }
381
382 try {
383 container.classList.add("clo-term");
384 container.classList.add(FONT_LOADING_CLASS);
385 container.classList.toggle("clo-term-has-scrollbar", options.scrollbar !== false);
386 container.tabIndex = 0;
387 this.input.className = "clo-term-input";
388 this.input.tabIndex = -1;
389 this.input.setAttribute("aria-label", "Terminal input");
390 this.input.autocomplete = "off";
391 this.input.spellcheck = false;
392 container.appendChild(this.input);
393
394 this.viewportEl.className = "clo-term-viewport";
395 this.viewportEl.style.touchAction = "pan-x pinch-zoom";
396 container.appendChild(this.viewportEl);
397
398 this.rowsHost.className = "clo-term-rows";
399 this.viewportEl.appendChild(this.rowsHost);
400
401 this.cursorEl.className = "clo-term-cursor";
402 this.rowsHost.appendChild(this.cursorEl);
403
404 this.metricProbe.className = "clo-term-measure";
405 this.metricProbe.textContent = "W".repeat(32);
406 container.appendChild(this.metricProbe);
407
408 if (options.scrollbar !== false) {
409 this.scrollbarEl = container.ownerDocument.createElement("div");
410 this.scrollbarEl.className = "clo-term-scrollbar";
411 this.scrollbarEl.role = "scrollbar";
412 this.scrollbarEl.tabIndex = 0;
413 this.scrollbarEl.setAttribute("aria-orientation", "vertical");
414 this.scrollbarEl.setAttribute("aria-label", "Terminal scrollback");
415 this.scrollbarThumb = container.ownerDocument.createElement("div");
416 this.scrollbarThumb.className = "clo-term-scrollbar-thumb";
417 this.scrollbarEl.appendChild(this.scrollbarThumb);
418 container.appendChild(this.scrollbarEl);
419 this.bindScrollbar();
420 }
421
422 if (options.stickyScroll !== false) {
423 this.stickyEl = container.ownerDocument.createElement("div");
424 this.stickyEl.className = "clo-term-sticky";
425 this.stickyEl.style.display = "none";
426 // Clicking the pinned header jumps back to where the command started —
427 // the affordance that makes it navigation rather than decoration.
428 this.stickyEl.addEventListener("click", () => this.jumpToSticky());
429 container.appendChild(this.stickyEl);
430 }
431
432 if (typeof ResizeObserver !== "undefined") {
433 this.metricObserver = new ResizeObserver((entries) => {
434 if (!this.initialized) return;
435 const rect = entries.find((entry) => entry.target === this.metricProbe)?.contentRect;
436 if (
437 rect !== undefined &&
438 rect.width === this.completedMetricResize?.width &&
439 rect.height === this.completedMetricResize.height
440 ) {
441 this.completedMetricResize = null;
442 return;
443 }
444 this.completedMetricResize = null;
445 this.queueAutomaticMetricRefresh();
446 });
447 this.metricObserver.observe(this.metricProbe);
448 this.observer = new ResizeObserver(() => {
449 if (!this.initialized) return;
450 const resized = this.resizeToFit();
451 this.syncViewportGeometry();
452 this.syncMouseGeometry();
453 if (resized) this.needFull = true;
454 this.queueFrame();
455 });
456 this.observer.observe(this.element);
457 }
458 this.bindInput();
459 // ***Before the font gate, not inside it***: a program asks for the
460 // background in its first bytes — codex does — and an answer that waits on
461 // a webfont arrives after it has already decided how to draw.
462 this.publishThemeColors();
463 this.scheduleFrame();
464
465 const fonts = container.ownerDocument.fonts as FontFaceSet | undefined;
466 if (
467 fonts === undefined ||
468 typeof fonts.load !== "function" ||
469 typeof fonts.check !== "function"
470 ) {
471 this.initialize();
472 this.ready = Promise.resolve();
473 } else {
474 this.ready = this.loadInitialFonts(fonts)
475 .then(
476 () => this.initialize(),
477 (error: unknown) => this.initializeFallback(error),
478 )
479 .then(() => this.bindFontCompletion(fonts));
480 }
481 } catch (error) {
482 this.dispose();
483 throw error;
484 }
485 }
486
487 private async loadInitialFonts(fonts: FontFaceSet): Promise<void> {
488 const style = getComputedStyle(this.element);
489 const families = parseFontFamilies(style.fontFamily);
490 const primary = families[0];
491 if (primary === undefined) throw new Error("terminal primary font family is unavailable");
492 const requests = [
493 { descriptor: `normal 400 ${style.fontSize} ${primary}`, sample: PRIMARY_FONT_SAMPLE },
494 { descriptor: `normal 600 ${style.fontSize} ${primary}`, sample: PRIMARY_FONT_SAMPLE },
495 { descriptor: `italic 400 ${style.fontSize} ${primary}`, sample: PRIMARY_FONT_SAMPLE },
496 { descriptor: `italic 600 ${style.fontSize} ${primary}`, sample: PRIMARY_FONT_SAMPLE },
497 ];
498 const symbol = families.find(
499 (family) => normalizeFontFamily(family) === SYMBOL_FONT_FAMILY.toLowerCase(),
500 );
501 if (symbol !== undefined) {
502 requests.push({
503 descriptor: `normal 400 ${style.fontSize} ${symbol}`,
504 sample: SYMBOL_FONT_SAMPLE,
505 });
506 }
507 const loaded = await Promise.allSettled(
508 requests.map(({ descriptor, sample }) => fonts.load(descriptor, sample)),
509 );
510 const unavailable = requests.filter(({ descriptor, sample }, index) => {
511 const result = loaded[index]!;
512 return (
513 result.status === "rejected" ||
514 result.value.length === 0 ||
515 !fonts.check(descriptor, sample)
516 );
517 });
518 if (unavailable.length > 0) {
519 const rejection = loaded.find((result) => result.status === "rejected");
520 if (rejection !== undefined) throw rejection.reason;
521 throw new Error(
522 `terminal font faces unavailable: ${unavailable.map(({ descriptor }) => descriptor).join(", ")}`,
523 );
524 }
525 }
526
527 private initializeFallback(error: unknown): void {
528 if (this.disposedView) return;
529 this.element.dispatchEvent(
530 new CustomEvent(FONT_ERROR_EVENT, {
531 bubbles: true,
532 detail: { error },
533 }),
534 );
535 this.initialize();
536 }
537
538 private initialize(): void {
539 if (this.initialized || this.disposedView) return;
540 this.measure();
541 this.resizeToFit(false);
542 this.syncViewportGeometry();
543 if (this.visible) this.resizeCallback?.({ cols: this.terminal.cols, rows: this.terminal.rows });
544 if (this.disposedView) return;
545 this.syncMouseGeometry();
546 this.initialized = true;
547 this.needFull = true;
548 this.element.classList.remove(FONT_LOADING_CLASS);
549 this.queueFrame();
550 }
551
552 private bindFontCompletion(fonts: FontFaceSet): void {
553 if (this.disposedView) return;
554 // Some DOM libraries have not yet specialized FontFaceSet's EventTarget
555 // overload even though browsers expose FontFaceSetLoadEvent.fontfaces.
556 const source = fonts as unknown as FontCompletionSource;
557 if (
558 typeof source.addEventListener !== "function" ||
559 typeof source.removeEventListener !== "function"
560 ) {
561 return;
562 }
563 const onLoadingDone: EventListener = (event) => {
564 if (!this.initialized || this.disposedView) return;
565 const completed = (event as FontCompletionEvent).fontfaces;
566 if (completed === undefined) return;
567 const families = new Set(
568 parseFontFamilies(getComputedStyle(this.element).fontFamily).map(normalizeFontFamily),
569 );
570 if (!completed.some((face) => families.has(normalizeFontFamily(face.family)))) return;
571 this.queueAutomaticMetricRefresh();
572 };
573 source.addEventListener("loadingdone", onLoadingDone);
574 this.detach.push(() => source.removeEventListener("loadingdone", onLoadingDone));
575 }
576
577 /** Call after every terminal.write() batch; coalesces to one paint per frame. */
578 scheduleFrame(): void {
579 this.contentFramePending = true;
580 if (!this.visible) return;
581 if (this.suspendSynchronizedOutput()) return;
582 this.queueFrame();
583 }
584
585 private suspendSynchronizedOutput(): boolean {
586 if (!this.terminal.synchronizedOutput) return false;
587 this.synchronizedOutputTimeout ??= setTimeout(() => {
588 this.synchronizedOutputTimeout = null;
589 if (this.disposedView || !this.terminal.synchronizedOutput) return;
590 this.terminal.resetSynchronizedOutput();
591 this.scheduleFrame();
592 }, SYNCHRONIZED_OUTPUT_TIMEOUT_MS);
593 return true;
594 }
595
596 private queueFrame(): void {
597 if (!this.initialized || this.disposedView || !this.visible || this.frameQueued) return;
598 this.frameQueued = true;
599 const raf =
600 typeof requestAnimationFrame === "function"
601 ? requestAnimationFrame
602 : (fn: () => void) => setTimeout(fn, 16);
603 const token = ++this.frameToken;
604 raf(() => {
605 if (token !== this.frameToken) return;
606 this.frameQueued = false;
607 this.paint();
608 });
609 }
610
611 private queueAutomaticMetricRefresh(): void {
612 this.automaticMetricRefreshPending = true;
613 this.scheduleFrame();
614 }
615
616 /** Compute the cols/rows that fit the container at current metrics. */
617 private availableSize(): { width: number; height: number } {
618 const rect = this.element.getBoundingClientRect();
619 const style = getComputedStyle(this.element);
620 const px = (value: string) => Number.parseFloat(value) || 0;
621 const paddingX = px(style.paddingLeft) + px(style.paddingRight);
622 const paddingY = px(style.paddingTop) + px(style.paddingBottom);
623 const borderX = px(style.borderLeftWidth) + px(style.borderRightWidth);
624 const borderY = px(style.borderTopWidth) + px(style.borderBottomWidth);
625 const width =
626 this.element.clientWidth > 0
627 ? this.element.clientWidth - paddingX
628 : rect.width - paddingX - borderX;
629 const height =
630 this.element.clientHeight > 0
631 ? this.element.clientHeight - paddingY
632 : rect.height - paddingY - borderY;
633 return { width, height };
634 }
635
636 /** Compute the cols/rows that fit the container at current metrics. */
637 fit(): { cols: number; rows: number } {
638 if (!this.visible) return { cols: this.terminal.cols, rows: this.terminal.rows };
639 const { width, height } = this.availableSize();
640 const availableWidth = Math.max(0, width - (this.scrollbarEl === null ? 0 : SCROLLBAR_GUTTER));
641 const cols =
642 width > 0 ? Math.max(2, Math.floor(availableWidth / this.cellW)) : this.terminal.cols;
643 const rows = height > 0 ? Math.max(1, Math.floor(height / this.cellH)) : this.terminal.rows;
644 return { cols, rows };
645 }
646
647 /** Receive geometry changes (wire to pty.resize); fitting itself is intrinsic. */
648 observeResize(onResize: (size: { cols: number; rows: number }) => void): void {
649 this.resizeCallback = onResize;
650 if (this.initialized && this.visible)
651 onResize({ cols: this.terminal.cols, rows: this.terminal.rows });
652 }
653
654 private resizeToFit(report = true): boolean {
655 if (!this.visible) return false;
656 const size = this.fit();
657 if (size.cols === this.terminal.cols && size.rows === this.terminal.rows) return false;
658 this.settleFractionalScroll();
659 // Reflow moves rows and columns underneath every outstanding prediction,
660 // and the latency estimate belongs to a geometry that no longer exists.
661 this.predictor.reset();
662 this.overlaidRows.clear();
663 this.terminal.resize(size.cols, size.rows);
664 if (report) this.resizeCallback?.(size);
665 return true;
666 }
667
668 private syncViewportGeometry(): void {
669 const { width, height } = this.availableSize();
670 const availableWidth = Math.max(0, width - (this.scrollbarEl === null ? 0 : SCROLLBAR_GUTTER));
671 this.viewportEl.style.width = `${
672 width > 0
673 ? Math.min(this.terminal.cols * this.cellW, availableWidth)
674 : this.terminal.cols * this.cellW
675 }px`;
676 this.viewportEl.style.height = `${
677 height > 0
678 ? Math.min(this.terminal.rows * this.cellH, height)
679 : this.terminal.rows * this.cellH
680 }px`;
681 }
682
683 paint(): void {
684 if (!this.visible) return;
685 if (!this.initialized || this.disposedView) return;
686 if (this.frameQueued) {
687 this.frameQueued = false;
688 this.frameToken++;
689 }
690 if (this.suspendSynchronizedOutput()) return;
691 if (this.synchronizedOutputTimeout !== null) {
692 clearTimeout(this.synchronizedOutputTimeout);
693 this.synchronizedOutputTimeout = null;
694 }
695 if (this.automaticMetricRefreshPending) {
696 this.automaticMetricRefreshPending = false;
697 if (this.refreshMetricTransaction(false)) {
698 this.completedMetricResize = { width: this.cellW * 32, height: this.cellH };
699 }
700 }
701 const contentFrame = this.contentFramePending;
702 const snapshot = this.frameReader.snapshot({ full: this.needFull });
703 this.needFull = false;
704 this.applyFrame(snapshot);
705 if (contentFrame) this.refreshFractionalBoundary();
706 this.contentFramePending = false;
707 }
708
709 /**
710 * The predictive-echo engine, for diagnostics and tests. Read-only in
711 * practice: input drives it, and reaching in to mutate it desynchronizes the
712 * renderer's overlaid-row bookkeeping.
713 */
714 get predictions(): PredictionEngine {
715 return this.predictor;
716 }
717
718 /** Read renderer-owned state for diagnostics, forcing the next paint to rebuild its row cache. */
719 inspectFrame(options: { full?: boolean } = { full: true }): FrameSnapshot {
720 if (this.disposedView) throw new Error("terminal view is disposed");
721 if (this.terminal.synchronizedOutput) {
722 throw new Error("terminal frame is suppressed by synchronized output");
723 }
724 const frame = this.frameReader.snapshot(options);
725 this.needFull = true;
726 return frame;
727 }
728
729 /** Refresh the one visible row outside Ghostty's adjacent integer viewport. */
730 private refreshFractionalBoundary(): void {
731 const position = this.scrollPosition;
732 if (position === null || Number.isInteger(position)) return;
733 const current = this.terminal.scrollbar().offset;
734 const firstRow = Math.floor(position);
735 const other = current === firstRow ? firstRow + 1 : firstRow;
736 const boundary = current === firstRow ? firstRow + this.terminal.rows : firstRow;
737
738 this.terminal.scrollToRow(other);
739 const frame = this.frameReader.snapshot({ full: true });
740 const offset = this.terminal.scrollbar().offset;
741 const row = frame.dirtyRows.find((candidate) => offset + candidate.index === boundary);
742 if (row !== undefined) {
743 let el = this.rowEls.get(boundary);
744 if (el === undefined) {
745 el = this.element.ownerDocument.createElement("div");
746 el.className = "clo-term-row";
747 el.dataset["screenRow"] = String(boundary);
748 this.rowEls.set(boundary, el);
749 const next = this.rowEls.get(boundary + 1);
750 if (next === undefined) this.rowsHost.appendChild(el);
751 else next.before(el);
752 }
753 const previous = this.rowSnapshots.get(boundary);
754 const cellsChanged = !sameCells(previous?.cells, row.cells);
755 const metadataChanged = !sameRowMetadata(previous, row);
756 this.rowSnapshots.set(boundary, row);
757 if (metadataChanged) this.applyRowMetadata(el, row);
758 if (cellsChanged) {
759 this.fitGlyphs(
760 this.paintRow(row, frame, boundary, el, this.predictor.overlayFor(boundary)),
761 );
762 }
763 }
764 this.terminal.scrollToRow(current);
765 }
766
767 private applyFrame(frame: FrameSnapshot): void {
768 const doc = this.element.ownerDocument;
769 const rootStyle = this.element.style;
770 const cursorColor = frame.cursor?.color ?? -1;
771 const colorsChanged =
772 frame.foreground !== this.frameForeground || frame.background !== this.frameBackground;
773 if (frame.foreground !== this.frameForeground) {
774 if (frame.foreground === -1) rootStyle.removeProperty("--clo-term-effective-fg");
775 else rootStyle.setProperty("--clo-term-effective-fg", num(frame.foreground));
776 }
777 if (frame.background !== this.frameBackground) {
778 if (frame.background === -1) rootStyle.removeProperty("--clo-term-effective-bg");
779 else rootStyle.setProperty("--clo-term-effective-bg", num(frame.background));
780 }
781 if (cursorColor !== this.frameCursorColor) {
782 if (cursorColor === -1) rootStyle.removeProperty("--clo-term-effective-cursor");
783 else rootStyle.setProperty("--clo-term-effective-cursor", num(cursorColor));
784 }
785 const scrollbar = this.terminal.scrollbar();
786 this.terminalViewportRevision = this.terminal.viewportRevision;
787 const wasAtBottom = this.scrollPosition !== null && this.scrollPosition === this.scrollMax;
788 if (
789 this.scrollPosition === null ||
790 Number.isInteger(this.scrollPosition) ||
791 (wasAtBottom && scrollbar.offset === scrollbar.total - scrollbar.len)
792 ) {
793 this.scrollPosition = scrollbar.offset;
794 }
795 this.scrollMax = Math.max(0, scrollbar.total - scrollbar.len);
796
797 const position = Math.max(0, Math.min(this.scrollMax, this.scrollPosition));
798 this.scrollPosition = position;
799 const firstRow = Math.floor(position);
800 this.firstRowNow = firstRow;
801 const fraction = position - firstRow;
802 const lastRow = firstRow + frame.rows - 1 + (fraction > 0 ? 1 : 0);
803
804 // Reconcile before painting so this frame renders the overlay the emulator
805 // state actually justifies, rather than one frame of stale speculation.
806 const wasOverlaid = new Set(this.overlaidRows);
807 this.reconcilePredictions(frame, scrollbar.offset);
808
809 const previousRows = new Map<number, RowSnapshot | undefined>();
810 for (const row of frame.dirtyRows) {
811 const absoluteRow = scrollbar.offset + row.index;
812 previousRows.set(absoluteRow, this.rowSnapshots.get(absoluteRow));
813 this.rowSnapshots.set(absoluteRow, row);
814 }
815 const fittedGlyphs: FittedGlyph[] = [];
816 const paintedRows = new Set<number>();
817 for (const row of frame.dirtyRows) {
818 const absoluteRow = scrollbar.offset + row.index;
819 let el = this.rowEls.get(absoluteRow);
820 const created = el === undefined;
821 if (el === undefined) {
822 el = doc.createElement("div");
823 el.className = "clo-term-row";
824 el.dataset["screenRow"] = String(absoluteRow);
825 this.rowEls.set(absoluteRow, el);
826 }
827 const previous = previousRows.get(absoluteRow);
828 const cellsChanged = !sameCells(previous?.cells, row.cells);
829 const metadataChanged = !sameRowMetadata(previous, row);
830 if (created || metadataChanged) this.applyRowMetadata(el, row);
831 const overlay = this.predictor.overlayFor(absoluteRow);
832 // An overlaid row must repaint even when its canonical cells are
833 // unchanged: `rowSnapshots` never carries the speculation, so `sameCells`
834 // cannot see a prediction being added or retracted.
835 const overlayChanged = overlay !== undefined || wasOverlaid.has(absoluteRow);
836 if (created || cellsChanged || colorsChanged || overlayChanged) {
837 fittedGlyphs.push(...this.paintRow(row, frame, absoluteRow, el, overlay));
838 paintedRows.add(absoluteRow);
839 }
840 }
841 // Rows that carried speculation but are not in this frame still have to
842 // lose it; nothing else would ever repaint them.
843 for (const absoluteRow of wasOverlaid) {
844 if (paintedRows.has(absoluteRow)) continue;
845 const snapshot = this.rowSnapshots.get(absoluteRow);
846 const el = this.rowEls.get(absoluteRow);
847 if (snapshot === undefined || el === undefined) continue;
848 fittedGlyphs.push(
849 ...this.paintRow(snapshot, frame, absoluteRow, el, this.predictor.overlayFor(absoluteRow)),
850 );
851 paintedRows.add(absoluteRow);
852 }
853 this.overlaidRows = new Set(this.predictor.overlaidRows());
854 if (colorsChanged) {
855 for (const [absoluteRow, row] of this.rowSnapshots) {
856 if (absoluteRow < firstRow || absoluteRow > lastRow || paintedRows.has(absoluteRow))
857 continue;
858 const el = this.rowEls.get(absoluteRow);
859 if (el !== undefined) {
860 fittedGlyphs.push(
861 ...this.paintRow(row, frame, absoluteRow, el, this.predictor.overlayFor(absoluteRow)),
862 );
863 }
864 }
865 }
866 this.frameForeground = frame.foreground;
867 this.frameBackground = frame.background;
868 this.frameCursorColor = cursorColor;
869
870 for (const [screenRow, el] of this.rowEls) {
871 if (screenRow < firstRow || screenRow > lastRow) {
872 el.remove();
873 this.rowEls.delete(screenRow);
874 this.rowSnapshots.delete(screenRow);
875 this.rowLinks.delete(screenRow);
876 }
877 }
878 this.element.classList.toggle(
879 "clo-term-has-blink",
880 frame.cursor?.blinking === true ||
881 [...this.rowSnapshots.values()].some((row) => row.cells.some((cell) => cell.blink)),
882 );
883 let previous = this.cursorEl;
884 for (let screenRow = firstRow; screenRow <= lastRow; screenRow++) {
885 const el = this.rowEls.get(screenRow);
886 if (el !== undefined) {
887 if (previous.nextElementSibling !== el) previous.after(el);
888 previous = el;
889 }
890 }
891 this.rowsHost.style.transform = fraction === 0 ? "" : `translateY(${-fraction * this.cellH}px)`;
892
893 this.fitGlyphs(fittedGlyphs);
894 this.paintCursor(frame, scrollbar.offset, firstRow);
895 this.paintSticky(position);
896 this.paintScrollbar(scrollbar, position);
897 }
898
899 private now(): number {
900 const clock = globalThis.performance;
901 return clock !== undefined && typeof clock.now === "function" ? clock.now() : Date.now();
902 }
903
904 /** True only while the shell says a prompt is accepting input and has not executed. */
905 private editing(): boolean {
906 return this.terminal.shell.current?.state === "editing";
907 }
908
909 /** Fold this frame's real rows into the engine, retiring or dropping speculation. */
910 private reconcilePredictions(frame: FrameSnapshot, viewportTop: number): void {
911 if (!this.predictor.enabled) return;
912 const rows = new Map<number, readonly string[]>();
913 for (const row of frame.dirtyRows) {
914 rows.set(viewportTop + row.index, columnTexts(row.cells, frame.cols));
915 }
916 this.predictor.reconcile(
917 {
918 rows,
919 cursor:
920 frame.cursor === null ? null : { row: viewportTop + frame.cursor.y, col: frame.cursor.x },
921 editing: this.editing(),
922 },
923 this.now(),
924 );
925 }
926
927 /** Where a keystroke would land right now, as the last painted frame saw it. */
928 private predictionSite(): PredictionSite | null {
929 const cursor = this.realCursor;
930 if (cursor === null) return null;
931 return {
932 row: cursor.row,
933 col: cursor.col,
934 cols: this.terminal.cols,
935 editing: this.editing(),
936 };
937 }
938
939 /**
940 * Repaint rows whose speculation changed, without a frame.
941 *
942 * This is the one paint path that must never touch the frame reader: dirty
943 * snapshots are destructive, so consuming one here would swallow real pty
944 * output that had not been painted yet. It repaints from the canonical
945 * `rowSnapshots` cache instead, which is exactly what the last frame left.
946 */
947 private repaintPredictedRows(rows: readonly number[]): void {
948 if (rows.length === 0 || !this.initialized || this.disposedView) return;
949 const colors = { foreground: this.frameForeground, background: this.frameBackground };
950 const fittedGlyphs: FittedGlyph[] = [];
951 for (const absoluteRow of rows) {
952 const snapshot = this.rowSnapshots.get(absoluteRow);
953 const el = this.rowEls.get(absoluteRow);
954 if (snapshot === undefined || el === undefined) continue;
955 const overlay = this.predictor.overlayFor(absoluteRow);
956 if (overlay === undefined) this.overlaidRows.delete(absoluteRow);
957 else this.overlaidRows.add(absoluteRow);
958 fittedGlyphs.push(...this.paintRow(snapshot, colors, absoluteRow, el, overlay));
959 }
960 this.fitGlyphs(fittedGlyphs);
961 this.placeCursor();
962 }
963
964 /** Fold a keystroke into the engine and paint whatever it changed. */
965 private predictKey(event: KeyboardEvent): void {
966 if (!this.predictor.enabled) return;
967 const site = this.predictionSite();
968 if (site === null) return;
969 const now = this.now();
970 const bare = !event.ctrlKey && !event.metaKey && !event.altKey;
971 const rows =
972 bare && event.key === "Backspace"
973 ? this.predictor.backspace(site, now)
974 : bare && isPredictableText(event.key)
975 ? this.predictor.type(event.key, site, now)
976 : this.predictor.interrupt();
977 this.repaintPredictedRows(rows);
978 }
979
980 /** Drop speculation that no longer has a defensible basis. */
981 private dropPredictions(): void {
982 if (!this.predictor.enabled) return;
983 this.repaintPredictedRows(this.predictor.interrupt());
984 }
985
986 /**
987 * Pin the command whose output the viewport is inside. Recomputed per frame
988 * rather than cached: scrolling does not dirty rows, so there is no cheaper
989 * signal, and the work is one pass over a bounded command list.
990 */
991 private paintSticky(viewportTop: number): void {
992 const el = this.stickyEl;
993 if (el === null) return;
994 const command = stickyCommand(this.terminal.shell.commands, viewportTop);
995 if (command === null || command.commandLine === null) {
996 el.style.display = "none";
997 this.stickyCommandId = null;
998 return;
999 }
1000 if (this.stickyCommandId !== command.id) {
1001 this.stickyCommandId = command.id;
1002 el.textContent = command.commandLine;
1003 }
1004 el.className =
1005 command.exitCode !== null && command.exitCode !== 0
1006 ? "clo-term-sticky failed"
1007 : "clo-term-sticky";
1008 el.style.display = "block";
1009 el.style.height = `${this.cellH}px`;
1010 }
1011
1012 private jumpToSticky(): void {
1013 const command = stickyCommand(
1014 this.terminal.shell.commands,
1015 this.scrollPosition ?? this.terminal.scrollbar().offset,
1016 );
1017 if (command === null) return;
1018 const row = stickyTarget(command);
1019 if (row === null) return;
1020 this.scrollToRow(row);
1021 }
1022
1023 private paintScrollbar(scrollbar: Scrollbar, position = scrollbar.offset): void {
1024 const track = this.scrollbarEl;
1025 const thumb = this.scrollbarThumb;
1026 if (track === null || thumb === null) return;
1027 const maxOffset = Math.max(0, scrollbar.total - scrollbar.len);
1028 track.hidden = maxOffset === 0;
1029 track.setAttribute("aria-valuemin", "0");
1030 track.setAttribute("aria-valuemax", String(maxOffset));
1031 track.setAttribute("aria-valuenow", String(position));
1032 if (maxOffset === 0) {
1033 thumb.removeAttribute("style");
1034 return;
1035 }
1036
1037 const trackHeight =
1038 this.scrollbarTrackHeight ?? Math.max(0, this.terminal.rows * this.cellH - 6);
1039 const thumbHeight = Math.min(
1040 trackHeight,
1041 Math.max(24, trackHeight * (scrollbar.len / scrollbar.total)),
1042 );
1043 const travel = trackHeight - thumbHeight;
1044 thumb.style.height = `${thumbHeight}px`;
1045 thumb.style.transform = `translateY(${travel * (position / maxOffset)}px)`;
1046 }
1047
1048 private scrollBy(rows: number, fractional: boolean): boolean {
1049 if (rows === 0) return false;
1050 const scrollbar = this.terminal.scrollbar();
1051 const maxOffset = Math.max(0, scrollbar.total - scrollbar.len);
1052 if (maxOffset === 0) {
1053 this.resetFractionalScroll();
1054 return false;
1055 }
1056
1057 const current = this.scrollPosition ?? scrollbar.offset;
1058 let target = Math.max(0, Math.min(maxOffset, current + rows));
1059 if (!fractional) target = Math.max(0, Math.min(maxOffset, Math.round(target)));
1060 const nearest = Math.round(target);
1061 if (Math.abs(target - nearest) < 1e-9) target = nearest;
1062 if (target === current) return true;
1063
1064 this.scrollPosition = target;
1065 this.scrollMax = maxOffset;
1066 const firstRow = Math.floor(target);
1067 const fraction = target - firstRow;
1068 const anchor = fraction === 0 ? firstRow : rows > 0 ? firstRow + 1 : firstRow;
1069 const missingBoundary = rows > 0 ? firstRow : firstRow + scrollbar.len;
1070 if (fraction !== 0 && !this.rowEls.has(missingBoundary)) {
1071 const prime = rows > 0 ? firstRow : firstRow + 1;
1072 this.terminal.scrollToRow(prime);
1073 this.applyFrame(this.frameReader.snapshot({ full: true }));
1074 }
1075 const beforeAnchor = this.terminal.scrollbar().offset;
1076 this.terminal.scrollToRow(anchor);
1077 if (anchor !== beforeAnchor) this.needFull = true;
1078 this.queueFrame();
1079 return true;
1080 }
1081
1082 private wheelRows(event: WheelEvent): { rows: number; fractional: boolean } {
1083 if (event.deltaMode === 1) return { rows: event.deltaY, fractional: false };
1084 if (event.deltaMode === 2) {
1085 return { rows: event.deltaY * this.terminal.scrollbar().len, fractional: false };
1086 }
1087 return { rows: event.deltaY / this.cellH, fractional: true };
1088 }
1089
1090 private resetFractionalScroll(): void {
1091 this.scrollPosition = null;
1092 this.rowsHost.style.transform = "";
1093 }
1094
1095 /** Input follows the live prompt, but an already-live viewport needs no paint. */
1096 private scrollToBottomForInput(): boolean {
1097 if (
1098 this.terminalViewportRevision === this.terminal.viewportRevision &&
1099 this.scrollPosition !== null &&
1100 this.scrollPosition === this.scrollMax
1101 ) {
1102 return false;
1103 }
1104 const scrollbar = this.terminal.scrollbar();
1105 const bottom = Math.max(0, scrollbar.total - scrollbar.len);
1106 if (scrollbar.offset === bottom && (this.scrollPosition ?? scrollbar.offset) === bottom) {
1107 this.terminalViewportRevision = this.terminal.viewportRevision;
1108 return false;
1109 }
1110 this.resetFractionalScroll();
1111 this.terminal.scrollToBottom();
1112 this.terminalViewportRevision = this.terminal.viewportRevision;
1113 this.needFull = true;
1114 return true;
1115 }
1116
1117 private settleFractionalScroll(paintNow = false): void {
1118 const position = this.scrollPosition;
1119 if (position === null || Number.isInteger(position)) return;
1120 const target = Math.round(position);
1121 this.scrollPosition = target;
1122 this.terminal.scrollToRow(target);
1123 this.rowsHost.style.transform = "";
1124 this.needFull = true;
1125 if (paintNow) this.paint();
1126 }
1127
1128 private scrollToRow(row: number): void {
1129 const target = Math.max(0, Math.round(row));
1130 this.scrollPosition = target;
1131 this.terminal.scrollToRow(target);
1132 this.needFull = true;
1133 this.scheduleFrame();
1134 }
1135
1136 private scrollToPosition(row: number): void {
1137 const scrollbar = this.terminal.scrollbar();
1138 const maxOffset = Math.max(0, scrollbar.total - scrollbar.len);
1139 const current = this.scrollPosition ?? scrollbar.offset;
1140 const target = Math.max(0, Math.min(maxOffset, row));
1141 this.scrollBy(target - current, true);
1142 }
1143
1144 private scrollbarGeometry(): { maxOffset: number; travel: number; thumbHeight: number } | null {
1145 const track = this.scrollbarEl;
1146 if (track === null) return null;
1147 const scrollbar = this.terminal.scrollbar();
1148 const maxOffset = Math.max(0, scrollbar.total - scrollbar.len);
1149 const trackHeight =
1150 this.scrollbarTrackHeight ?? Math.max(0, this.terminal.rows * this.cellH - 6);
1151 const thumbHeight = Math.min(
1152 trackHeight,
1153 Math.max(24, trackHeight * (scrollbar.len / scrollbar.total)),
1154 );
1155 return { maxOffset, travel: trackHeight - thumbHeight, thumbHeight };
1156 }
1157
1158 private bindScrollbar(): void {
1159 const track = this.scrollbarEl!;
1160 const thumb = this.scrollbarThumb!;
1161 const stopMouse = (event: Event) => event.stopPropagation();
1162 const onPointerDown = (event: PointerEvent) => {
1163 event.preventDefault();
1164 event.stopPropagation();
1165 const geometry = this.scrollbarGeometry();
1166 if (geometry === null || geometry.maxOffset === 0) return;
1167 const rect = track.getBoundingClientRect();
1168 if (event.target === thumb) {
1169 this.scrollbarDrag = {
1170 pointerId: event.pointerId,
1171 y: event.clientY,
1172 offset: this.scrollPosition ?? this.terminal.scrollbar().offset,
1173 };
1174 track.setPointerCapture?.(event.pointerId);
1175 return;
1176 }
1177 const top = Math.max(
1178 0,
1179 Math.min(geometry.travel, event.clientY - rect.top - geometry.thumbHeight / 2),
1180 );
1181 this.scrollToPosition(
1182 geometry.travel === 0 ? 0 : (top / geometry.travel) * geometry.maxOffset,
1183 );
1184 };
1185 const onPointerMove = (event: PointerEvent) => {
1186 const drag = this.scrollbarDrag;
1187 if (drag === null || drag.pointerId !== event.pointerId) return;
1188 event.preventDefault();
1189 event.stopPropagation();
1190 const geometry = this.scrollbarGeometry();
1191 if (geometry === null || geometry.travel === 0) return;
1192 const rows = ((event.clientY - drag.y) / geometry.travel) * geometry.maxOffset;
1193 this.scrollToPosition(drag.offset + rows);
1194 };
1195 const endDrag = (event: PointerEvent) => {
1196 if (this.scrollbarDrag?.pointerId !== event.pointerId) return;
1197 event.stopPropagation();
1198 this.scrollbarDrag = null;
1199 track.releasePointerCapture?.(event.pointerId);
1200 };
1201 const onKeyDown = (event: KeyboardEvent) => {
1202 const scrollbar = this.terminal.scrollbar();
1203 const maxOffset = Math.max(0, scrollbar.total - scrollbar.len);
1204 const position = this.scrollPosition ?? scrollbar.offset;
1205 let target: number | null = null;
1206 if (event.key === "ArrowUp") target = position - 1;
1207 if (event.key === "ArrowDown") target = position + 1;
1208 if (event.key === "PageUp") target = position - scrollbar.len;
1209 if (event.key === "PageDown") target = position + scrollbar.len;
1210 if (event.key === "Home") target = 0;
1211 if (event.key === "End") target = maxOffset;
1212 if (target === null) return;
1213 event.preventDefault();
1214 event.stopPropagation();
1215 this.scrollToRow(target);
1216 };
1217 const onWheel = (event: WheelEvent) => {
1218 event.stopPropagation();
1219 const wheel = this.wheelRows(event);
1220 if (this.scrollBy(wheel.rows, wheel.fractional)) event.preventDefault();
1221 };
1222
1223 track.addEventListener("pointerdown", onPointerDown);
1224 track.addEventListener("pointermove", onPointerMove);
1225 track.addEventListener("pointerup", endDrag);
1226 track.addEventListener("pointercancel", endDrag);
1227 track.addEventListener("lostpointercapture", endDrag);
1228 track.addEventListener("keydown", onKeyDown);
1229 track.addEventListener("wheel", onWheel, { passive: false });
1230 track.addEventListener("mousedown", stopMouse);
1231 track.addEventListener("mouseup", stopMouse);
1232 track.addEventListener("mousemove", stopMouse);
1233 track.addEventListener("contextmenu", stopMouse);
1234 this.detach.push(() => {
1235 track.removeEventListener("pointerdown", onPointerDown);
1236 track.removeEventListener("pointermove", onPointerMove);
1237 track.removeEventListener("pointerup", endDrag);
1238 track.removeEventListener("pointercancel", endDrag);
1239 track.removeEventListener("lostpointercapture", endDrag);
1240 track.removeEventListener("keydown", onKeyDown);
1241 track.removeEventListener("wheel", onWheel);
1242 track.removeEventListener("mousedown", stopMouse);
1243 track.removeEventListener("mouseup", stopMouse);
1244 track.removeEventListener("mousemove", stopMouse);
1245 track.removeEventListener("contextmenu", stopMouse);
1246 });
1247 }
1248
1249 private detectRowLinks(row: RowSnapshot, absoluteRow: number): DetectedLink[] {
1250 const rows = [{ row, absoluteRow }];
1251 while (rows.length < 64 && rows[0]!.row.wrapContinuation) {
1252 const index = rows[0]!.absoluteRow - 1;
1253 const previous = this.rowSnapshots.get(index);
1254 if (!previous?.wrap) break;
1255 rows.unshift({ row: previous, absoluteRow: index });
1256 }
1257 while (rows.length < 64 && rows.at(-1)!.row.wrap) {
1258 const index = rows.at(-1)!.absoluteRow + 1;
1259 const next = this.rowSnapshots.get(index);
1260 if (!next?.wrapContinuation) break;
1261 rows.push({ row: next, absoluteRow: index });
1262 }
1263 const texts = rows.map(({ row }) => rowText(row.cells).text);
1264 const index = rows.findIndex((entry) => entry.absoluteRow === absoluteRow);
1265 const start = texts.slice(0, index).join("").length;
1266 const end = start + texts[index]!.length;
1267 return detectLinks(texts.join(""), this.options.links).flatMap((link) => {
1268 if (link.start >= end || link.end <= start) return [];
1269 return [
1270 { ...link, start: Math.max(0, link.start - start), end: Math.min(end, link.end) - start },
1271 ];
1272 });
1273 }
1274
1275 private paintRow(
1276 canonical: RowSnapshot,
1277 frame: Pick<FrameSnapshot, "foreground" | "background">,
1278 absoluteRow: number,
1279 el: HTMLElement,
1280 overlay?: ReadonlyMap<number, string>,
1281 ): FittedGlyph[] {
1282 const doc = this.element.ownerDocument;
1283 const fragment = doc.createDocumentFragment();
1284 const fittedGlyphs: FittedGlyph[] = [];
1285 // Speculation never reaches the canonical snapshot: it is applied to a
1286 // derived copy here and nowhere else, so `rowSnapshots` keeps describing
1287 // what the emulator actually holds.
1288 const overlaid = overlay === undefined ? null : applyOverlay(canonical, overlay);
1289 const row = overlaid?.row ?? canonical;
1290 const predictedCells = overlaid?.predicted;
1291
1292 // Links are resolved per row and folded into run grouping, so a run never
1293 // straddles a link boundary and the DOM stays one span list.
1294 let links: DetectedLink[] = [];
1295 let cellLinks: number[] | undefined;
1296 if (this.options.links) {
1297 const { cellAt } = rowText(row.cells);
1298 links = this.detectRowLinks(row, absoluteRow);
1299 cellLinks = linkCells(row.cells, links, cellAt);
1300 let textOffset = 0;
1301 let oscLink = -1;
1302 let previousUri: string | null = null;
1303 for (let index = 0; index < row.cells.length; index++) {
1304 const cell = row.cells[index]!;
1305 const cellText = cell.spacer === "head" ? "" : cell.text === "" ? " " : cell.text;
1306 if (cell.spacer === "head") {
1307 cellLinks[index] = -1;
1308 oscLink = -1;
1309 previousUri = null;
1310 continue;
1311 }
1312 if (cell.hasOsc8 && cell.osc8Uri !== null) {
1313 if (cell.osc8Uri !== previousUri) {
1314 oscLink = links.length;
1315 links.push({
1316 kind: "osc8",
1317 start: textOffset,
1318 end: textOffset + cellText.length,
1319 text: cellText,
1320 target: cell.osc8Uri,
1321 line: null,
1322 column: null,
1323 });
1324 } else {
1325 const link = links[oscLink]!;
1326 link.end += cellText.length;
1327 link.text += cellText;
1328 }
1329 cellLinks[index] = oscLink;
1330 } else {
1331 oscLink = -1;
1332 if (cell.hasOsc8) cellLinks[index] = -1;
1333 }
1334 previousUri = cell.osc8Uri;
1335 textOffset += cellText.length;
1336 }
1337 if (links.length === 0) cellLinks = undefined;
1338 }
1339 this.rowLinks.set(absoluteRow, links);
1340
1341 for (const run of cellsToRuns(row.cells, cellLinks, predictedCells)) {
1342 const span = doc.createElement("span");
1343 const style = run.cells[0]!;
1344 let cls = "clo-term-run";
1345 if (run.predicted) cls += this.predictor.underline ? " predicted underlined" : " predicted";
1346 if (style.bold) cls += " bold";
1347 if (style.italic) cls += " italic";
1348 if (style.faint) cls += " faint";
1349 if (style.blink) cls += " blink";
1350 if (style.invisible) cls += " invisible";
1351 if (style.strikethrough) cls += " strikethrough";
1352 if (style.overline) cls += " overline";
1353 if (style.underline > 0) cls += ` underline-${Math.min(style.underline, 5)}`;
1354 let fg = style.fg;
1355 let bg = style.bg;
1356 if (style.inverse) {
1357 // OSC-effective and explicit sides swap inline; sides without either
1358 // use the inverse CSS chain for host theme and built-in fallbacks.
1359 cls += " inverse";
1360 const swappedFg = bg === -1 ? (frame.background === -1 ? -1 : frame.background) : bg;
1361 const swappedBg = fg === -1 ? (frame.foreground === -1 ? -1 : frame.foreground) : fg;
1362 fg = swappedFg;
1363 bg = swappedBg;
1364 }
1365 if (run.link >= 0) {
1366 cls += " clo-term-link";
1367 span.dataset["link"] = String(run.link);
1368 span.dataset["row"] = String(absoluteRow);
1369 span.dataset["linkKind"] = links[run.link]!.kind;
1370 }
1371 if (style.invisible) span.style.color = "transparent";
1372 else if (fg !== -1) span.style.color = num(fg);
1373 if (style.underlineColor !== -1) {
1374 const color = num(style.underlineColor);
1375 span.style.setProperty(
1376 "--clo-term-decoration-color",
1377 style.faint ? `color-mix(in srgb, ${color} 55%, transparent)` : color,
1378 );
1379 }
1380 const compact =
1381 style.underlineColor === -1 &&
1382 !style.bold &&
1383 !style.italic &&
1384 !style.faint &&
1385 !style.blink &&
1386 !style.inverse &&
1387 !style.invisible &&
1388 !style.strikethrough &&
1389 !style.overline &&
1390 style.underline === 0 &&
1391 run.link < 0 &&
1392 run.cells.every((cell) => {
1393 const text = cellText(cell);
1394 return (
1395 cell.width === 1 &&
1396 cell.spacer === "none" &&
1397 !cell.protected &&
1398 cell.semantic === "output" &&
1399 !cell.hasOsc8 &&
1400 cell.osc8Uri === null &&
1401 text.length === 1 &&
1402 text.charCodeAt(0) >= 0x20 &&
1403 text.charCodeAt(0) <= 0x7e
1404 );
1405 });
1406 if (compact) {
1407 span.className = "clo-term-run clo-term-cell clo-term-compact";
1408 if (bg !== -1) span.style.backgroundColor = num(bg);
1409 if (run.columns > 1) {
1410 span.dataset["columns"] = String(run.columns);
1411 span.style.setProperty("--clo-term-cell-columns", String(run.columns));
1412 }
1413 span.textContent = run.text;
1414 fragment.appendChild(span);
1415 continue;
1416 }
1417 span.className = cls;
1418 for (const runCell of run.cells) {
1419 const cell = doc.createElement("span");
1420 cell.className = "clo-term-cell";
1421 cell.dataset["columns"] = String(runCell.width);
1422 cell.dataset["spacer"] = runCell.spacer;
1423 cell.dataset["protected"] = String(runCell.protected);
1424 cell.dataset["semantic"] = runCell.semantic;
1425 if (runCell.spacer === "head") {
1426 cell.classList.add("clo-term-spacer-head");
1427 cell.setAttribute("aria-hidden", "true");
1428 }
1429 cell.style.setProperty("--clo-term-cell-columns", String(runCell.width));
1430 if (bg !== -1) cell.style.backgroundColor = num(bg);
1431 const glyph = doc.createElement("span");
1432 glyph.className = "clo-term-glyph";
1433 const ink = doc.createElement("span");
1434 ink.className = "clo-term-ink";
1435 const text = cellText(runCell);
1436 ink.textContent = text;
1437 glyph.appendChild(ink);
1438 cell.appendChild(glyph);
1439 const codepoint = runCell.width === 1 ? privateUseBaseCodepoint(text) : null;
1440 const sprite =
1441 codepoint === null || text !== String.fromCodePoint(codepoint)
1442 ? undefined
1443 : POWERLINE_SPRITES.get(codepoint);
1444 if (sprite !== undefined) {
1445 cell.classList.add("clo-term-powerline-cell");
1446 glyph.classList.add("clo-term-powerline", `clo-term-powerline-${sprite}`);
1447 } else if (runCell.width === 2 || codepoint !== null) {
1448 const mode: GlyphFitMode =
1449 runCell.width === 2
1450 ? "wide"
1451 : (codepoint! >= 0xe0b0 && codepoint! <= 0xe0bf) ||
1452 codepoint === 0xe0d2 ||
1453 codepoint === 0xe0d4
1454 ? "powerline"
1455 : "symbol";
1456 cell.classList.add("clo-term-fitted-cell");
1457 glyph.classList.add(mode === "wide" ? "clo-term-wide-glyph" : `clo-term-${mode}-glyph`);
1458 const key = `${mode}\0${style.bold ? 1 : 0}${style.italic ? 1 : 0}\0${text}`;
1459 const cache = text.length <= GLYPH_FIT_CACHE_TEXT_LIMIT;
1460 const fit = cache ? this.glyphFits.get(key) : undefined;
1461 if (fit === undefined) {
1462 fittedGlyphs.push({
1463 element: glyph,
1464 key,
1465 cache,
1466 mode,
1467 target: this.cellW * runCell.width,
1468 });
1469 } else applyGlyphFit(glyph, fit);
1470 }
1471 span.appendChild(cell);
1472 }
1473 fragment.appendChild(span);
1474 }
1475 el.replaceChildren(fragment);
1476 return fittedGlyphs;
1477 }
1478
1479 private applyRowMetadata(el: HTMLElement, row: RowSnapshot): void {
1480 el.dataset["wrap"] = String(row.wrap);
1481 el.dataset["wrapContinuation"] = String(row.wrapContinuation);
1482 el.dataset["semanticPrompt"] = row.semanticPrompt;
1483 el.dataset["hyperlink"] = String(row.hyperlink);
1484 }
1485
1486 private fitGlyphs(glyphs: readonly FittedGlyph[]): void {
1487 if (glyphs.length === 0) return;
1488 const groups = new Map<string, FittedGlyph[]>();
1489 for (const glyph of glyphs) {
1490 const group = groups.get(glyph.key);
1491 if (group === undefined) groups.set(glyph.key, [glyph]);
1492 else group.push(glyph);
1493 }
1494 const unique = [...groups.values()];
1495 // Read one representative per grapheme/style before writing transforms, so
1496 // a cold dirty viewport pays one layout and no duplicate measurements.
1497 const widths = unique.map((group) => group[0]!.element.getBoundingClientRect().width);
1498 for (let index = 0; index < unique.length; index++) {
1499 const width = widths[index]!;
1500 if (width > 0) {
1501 const group = unique[index]!;
1502 const representative = group[0]!;
1503 const scaleX =
1504 representative.mode === "symbol"
1505 ? Math.min(1, representative.target / width)
1506 : representative.target / width;
1507 const fit = {
1508 scaleX,
1509 scaleY: representative.mode === "powerline" ? 1 : scaleX,
1510 marginLeft: (representative.target - width) / 2,
1511 };
1512 if (group[0]!.cache) {
1513 if (this.glyphFits.size >= GLYPH_FIT_CACHE_LIMIT) {
1514 const oldest = this.glyphFits.keys().next().value as string | undefined;
1515 if (oldest !== undefined) this.glyphFits.delete(oldest);
1516 }
1517 this.glyphFits.set(group[0]!.key, fit);
1518 }
1519 for (const glyph of group) applyGlyphFit(glyph.element, fit);
1520 }
1521 }
1522 }
1523
1524 private paintCursor(frame: FrameSnapshot, viewportTop: number, firstRow: number): void {
1525 const cursor = frame.cursor;
1526 if (cursor === null) {
1527 this.realCursor = null;
1528 this.placeCursor();
1529 return;
1530 }
1531 // Ghostty reports a wide cursor at its spacer tail; paint from the glyph head.
1532 this.realCursor = {
1533 row: viewportTop + cursor.y,
1534 col: cursor.wide ? Math.max(0, cursor.x - 1) : cursor.x,
1535 wide: cursor.wide,
1536 className: `clo-term-cursor style-${cursor.style}${cursor.blinking ? " blinking" : ""}`,
1537 };
1538 this.cursorUnderline = cursor.style === "underline";
1539 this.placeCursor(firstRow);
1540 }
1541
1542 /**
1543 * Position the cursor element. The speculative cursor wins while predictions
1544 * are outstanding — without it the caret sits behind the glyphs the user just
1545 * typed, which reads as more broken than not predicting at all.
1546 */
1547 private placeCursor(firstRow = this.firstRowNow): void {
1548 const real = this.realCursor;
1549 if (real === null) {
1550 this.cursorEl.className = "clo-term-cursor";
1551 this.cursorEl.removeAttribute("style");
1552 this.cursorEl.style.display = "none";
1553 return;
1554 }
1555 const predicted = this.predictor.cursor;
1556 const at = predicted === null ? real : { row: predicted.row, col: predicted.col };
1557 const wide = predicted === null && real.wide;
1558 const style = this.cursorEl.style;
1559 style.display = "block";
1560 style.left = `${at.col * this.cellW}px`;
1561 style.top = `${
1562 this.cursorUnderline
1563 ? (at.row - firstRow + 1) * this.cellH - 2
1564 : (at.row - firstRow) * this.cellH
1565 }px`;
1566 this.input.style.left = style.left;
1567 this.input.style.top = `${Math.max(0, Math.min(this.terminal.rows - 1, at.row - firstRow)) * this.cellH}px`;
1568 style.width = `${(wide ? 2 : 1) * this.cellW}px`;
1569 style.height = `${this.cellH}px`;
1570 this.cursorEl.className = `${real.className}${
1571 this.element.contains(this.element.ownerDocument.activeElement) ? "" : " blurred"
1572 }`;
1573 }
1574
1575 /**
1576 * The mouse encoder works in surface pixels, so it needs our cell metrics to
1577 * resolve a click to a cell. Re-sent whenever geometry changes; without it
1578 * every event would report cell 0,0.
1579 */
1580 private syncMouseGeometry(): void {
1581 const rect = this.viewportEl.getBoundingClientRect();
1582 this.scrollbarTrackHeight = Math.max(0, rect.height - 6);
1583 if (this.options.mouse === false) return;
1584 this.terminal.setMouseGeometry({
1585 width: rect.width || this.terminal.cols * this.cellW,
1586 height: rect.height || this.terminal.rows * this.cellH,
1587 cellWidth: this.cellW,
1588 cellHeight: this.cellH,
1589 });
1590 }
1591
1592 /**
1593 * Hand the core the theme's own colors: fore/background and cursor so it can
1594 * answer `OSC 10/11/12`, and the 16 ANSI slots it resolves indexed colors
1595 * through.
1596 *
1597 * **The browser resolves them, this does not parse CSS**: a token holds
1598 * anything CSS accepts — a `var()` chain into the product's palette,
1599 * `color-mix()`, a keyword — so the value is spent on the metric probe's
1600 * `color` and read back. An unset token answers nothing, and neither does a
1601 * transparent one: a see-through terminal has no background to report. The
1602 * theme token is read rather than the painted background, which already
1603 * carries a program's override and would be handed straight back as the
1604 * default.
1605 *
1606 * Three spellings come back: `rgb()` from a browser, the authored hex from
1607 * happy-dom, and whatever space a `color-mix()` was mixed in, which is what
1608 * the canvas is here to read.
1609 */
1610 private publishThemeColors(): void {
1611 const probe = this.metricProbe;
1612 const tokens = getComputedStyle(this.element);
1613 const context =
1614 probe.ownerDocument
1615 .createElement("canvas")
1616 .getContext?.("2d", { willReadFrequently: true }) ?? null;
1617 const resolve = (token: string): number | null => {
1618 // The computed value of a custom property is its substituted token stream,
1619 // so a `var()` chain into the product's palette is already followed here;
1620 // what is left may still be `color-mix()` or a keyword, which is what the
1621 // probe is for.
1622 const declared = tokens.getPropertyValue(token).trim();
1623 if (declared === "") return null;
1624 probe.style.color = "";
1625 probe.style.color = declared;
1626 const value = getComputedStyle(probe).color.trim();
1627 probe.style.color = "";
1628 const rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(value);
1629 if (rgb !== null) {
1630 if (rgb[4] !== undefined && Number(rgb[4]) === 0) return null;
1631 return (Number(rgb[1]) << 16) | (Number(rgb[2]) << 8) | Number(rgb[3]);
1632 }
1633 const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value);
1634 if (hex !== null) {
1635 const digits = hex[1]!;
1636 const full =
1637 digits.length === 3
1638 ? digits
1639 .split("")
1640 .map((digit) => digit + digit)
1641 .join("")
1642 : digits;
1643 return Number.parseInt(full, 16);
1644 }
1645 // ***A computed color is not always `rgb()`***: `color-mix()` keeps the
1646 // space it was mixed in, and a host's palette is written in one. Canvas is
1647 // the only parser that agrees with the pixels, and it is the one notation
1648 // reader here that never has to learn a color space.
1649 if (context === null) return null;
1650 context.clearRect(0, 0, 1, 1);
1651 context.fillStyle = value;
1652 context.fillRect(0, 0, 1, 1);
1653 const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
1654 if (alpha === 0) return null;
1655 return (red! << 16) | (green! << 8) | blue!;
1656 };
1657 this.terminal.setDefaultColors({
1658 foreground: resolve("--clo-term-fg"),
1659 background: resolve("--clo-term-bg"),
1660 cursor: resolve("--clo-term-cursor"),
1661 });
1662 this.terminal.setDefaultPalette(ANSI_TOKENS.map(resolve));
1663 }
1664
1665 private measure(): boolean {
1666 const rect = this.metricProbe.getBoundingClientRect();
1667 let cellW = this.cellW;
1668 let cellH = this.cellH;
1669 if (rect.width > 0) {
1670 // Ghostty's mouse geometry uses integer pixels; paint the same grid it encodes.
1671 cellW = Math.max(1, Math.round(rect.width / 32));
1672 cellH = Math.max(1, Math.round(rect.height));
1673 }
1674 const changed = cellW !== this.cellW || cellH !== this.cellH;
1675 if (changed) {
1676 this.cellW = cellW;
1677 this.cellH = cellH;
1678 this.resetFractionalScroll();
1679 this.glyphFits.clear();
1680 }
1681 this.rowsHost.style.setProperty("--clo-term-cell-w", `${this.cellW}px`);
1682 this.rowsHost.style.setProperty("--clo-term-cell-h", `${this.cellH}px`);
1683 return changed;
1684 }
1685
1686 setVisible(visible: boolean): void {
1687 if (this.visible === visible) return;
1688 this.visible = visible;
1689 if (visible) this.refreshMetrics();
1690 else if (this.frameQueued) {
1691 this.frameToken++;
1692 this.frameQueued = false;
1693 }
1694 }
1695
1696 /** Re-read font metrics after changing metric-affecting terminal CSS. */
1697 refreshMetrics(): void {
1698 this.refreshMetricTransaction();
1699 }
1700
1701 private refreshMetricTransaction(schedule = true): boolean {
1702 if (this.disposedView) return false;
1703 if (!this.initialized) {
1704 this.scheduleFrame();
1705 return false;
1706 }
1707 this.publishThemeColors();
1708 const changed = this.measure();
1709 // Fallback CJK/emoji fonts can change while primary `W` metrics remain
1710 // identical, so every explicit/font-ready refresh invalidates ink fitting.
1711 this.glyphFits.clear();
1712 this.rowSnapshots.clear();
1713 this.resizeToFit();
1714 this.syncViewportGeometry();
1715 this.syncMouseGeometry();
1716 this.needFull = true;
1717 if (schedule) this.scheduleFrame();
1718 return changed;
1719 }
1720
1721 /** Textarea focus clears native output ranges; selection gestures keep the root focused. */
1722 focus(): void {
1723 if (this.disposedView) return;
1724 const selection = this.element.ownerDocument.getSelection();
1725 const outputSelected =
1726 selection !== null &&
1727 !selection.isCollapsed &&
1728 (this.rowsHost.contains(selection.anchorNode) || this.rowsHost.contains(selection.focusNode));
1729 const target = this.selectingOutput || outputSelected ? this.element : this.input;
1730 target.focus({ preventScroll: true });
1731 }
1732
1733 private bindInput(): void {
1734 const el = this.element;
1735 const emit = (bytes: Uint8Array | null) => {
1736 if (bytes !== null && bytes.length > 0) this.options.onData?.(bytes);
1737 };
1738
1739 let composing = false;
1740 let compositionInputPending = false;
1741 const onCompositionStart = () => {
1742 this.input.classList.add("composing");
1743 composing = true;
1744 compositionInputPending = false;
1745 };
1746 const onCompositionEnd = (event: CompositionEvent) => {
1747 this.input.classList.remove("composing");
1748 composing = false;
1749 compositionInputPending = true;
1750 this.input.value = "";
1751 if (!this.input.readOnly && event.data !== "") emit(new TextEncoder().encode(event.data));
1752 };
1753 const onInput = () => {
1754 if (composing) return;
1755 const text = this.input.value;
1756 this.input.value = "";
1757 if (compositionInputPending) {
1758 compositionInputPending = false;
1759 return;
1760 }
1761 if (!this.input.readOnly && text !== "") emit(new TextEncoder().encode(text));
1762 };
1763 const onKeyDown = (event: KeyboardEvent) => {
1764 if (composing || this.input.readOnly || event.defaultPrevented) return;
1765 if (this.options.onBeforeKey?.(event) === false) return;
1766 if (
1767 event.isComposing ||
1768 event.keyCode === 229 ||
1769 event.key === "Process" ||
1770 event.key === "Dead"
1771 ) {
1772 this.input.focus({ preventScroll: true });
1773 return;
1774 }
1775 compositionInputPending = false;
1776 const bytes = this.terminal.encodeKey(event);
1777 if (bytes !== null) {
1778 this.input.focus({ preventScroll: true });
1779 event.preventDefault();
1780 const viewportMoved = this.scrollToBottomForInput();
1781 emit(bytes);
1782 if (viewportMoved) this.scheduleFrame();
1783 this.predictKey(event);
1784 } else if (
1785 event.key.toLowerCase() === "v" &&
1786 (event.metaKey || (event.ctrlKey && event.shiftKey))
1787 ) {
1788 this.input.focus({ preventScroll: true });
1789 }
1790 };
1791 const onPaste = (event: ClipboardEvent) => {
1792 if (this.input.readOnly) return;
1793 const text = event.clipboardData?.getData("text");
1794 if (text !== undefined && text !== "") {
1795 this.input.focus({ preventScroll: true });
1796 event.preventDefault();
1797 const viewportMoved = this.scrollToBottomForInput();
1798 emit(this.terminal.encodePaste(text));
1799 if (viewportMoved) this.scheduleFrame();
1800 // Bracketed paste is one opaque blob to the line editor; we have no
1801 // model of what it does with it.
1802 this.dropPredictions();
1803 }
1804 };
1805 // Mouse reporting is claimed by the program, not by us: shift is the
1806 // universal terminal escape hatch for "let me select anyway", and we honor
1807 // it so a full-screen app can never take selection away entirely.
1808 const mouseClaimed = (event: MouseEvent | WheelEvent) =>
1809 this.options.mouse !== false && !event.shiftKey && this.terminal.mouseTracking();
1810 let mouseGestureClaimed = false;
1811
1812 const position = (event: MouseEvent | WheelEvent) => {
1813 const rect = this.viewportEl.getBoundingClientRect();
1814 return {
1815 x: (Number.isFinite(event.clientX) ? event.clientX : rect.left) - rect.left,
1816 y: (Number.isFinite(event.clientY) ? event.clientY : rect.top) - rect.top,
1817 };
1818 };
1819
1820 const forward = (
1821 event: MouseEvent,
1822 action: "press" | "release" | "motion",
1823 button: number | null,
1824 ) => {
1825 this.settleFractionalScroll(true);
1826 const bytes = this.terminal.encodeMouse({
1827 action,
1828 button,
1829 ...position(event),
1830 ctrlKey: event.ctrlKey,
1831 altKey: event.altKey,
1832 metaKey: event.metaKey,
1833 shiftKey: event.shiftKey,
1834 });
1835 if (bytes !== null) {
1836 event.preventDefault();
1837 emit(bytes);
1838 // A program taking mouse input is not a line editor echoing keystrokes.
1839 this.dropPredictions();
1840 }
1841 };
1842
1843 const onMouseDown = (event: MouseEvent) => {
1844 mouseGestureClaimed = mouseClaimed(event);
1845 if (mouseGestureClaimed) {
1846 this.selectingOutput = false;
1847 this.input.focus({ preventScroll: true });
1848 this.lastMouseCell = "";
1849 forward(event, "press", event.button);
1850 return;
1851 }
1852 if (event.button !== 0 || event.target === this.input) return;
1853 this.selectingOutput = true;
1854 this.element.focus({ preventScroll: true });
1855 };
1856 const onMouseUp = (event: MouseEvent) => {
1857 if (this.selectingOutput || (!mouseGestureClaimed && !mouseClaimed(event))) return;
1858 this.lastMouseCell = "";
1859 forward(event, "release", event.button);
1860 };
1861 const finishOutputSelection = (event: MouseEvent) => {
1862 if (event.button !== 0 || !this.selectingOutput) return;
1863 this.selectingOutput = false;
1864 if (el.ownerDocument.activeElement === el) this.focus();
1865 };
1866 const cancelOutputSelection = (event: Event) => {
1867 if (event.type === "blur" && event.target !== event.currentTarget) return;
1868 this.selectingOutput = false;
1869 };
1870 const onMouseMove = (event: MouseEvent) => {
1871 if (this.selectingOutput || !mouseClaimed(event)) return;
1872 this.settleFractionalScroll(true);
1873 // Report once per cell crossed, not once per pixel of travel. libghostty
1874 // has an option for this that is inert at the pinned vendor commit, so
1875 // the guard lives here; without it a slow drag writes to the pty on
1876 // every mousemove.
1877 const at = position(event);
1878 const cell = `${Math.floor(at.x / this.cellW)},${Math.floor(at.y / this.cellH)}`;
1879 if (cell === this.lastMouseCell) return;
1880 this.lastMouseCell = cell;
1881 // buttons is a bitmask of held buttons; 0 means a bare hover, which only
1882 // "any" tracking reports. The encoder decides — we just report honestly.
1883 const held = event.buttons & 1 ? 0 : event.buttons & 4 ? 1 : event.buttons & 2 ? 2 : null;
1884 forward(event, "motion", held);
1885 };
1886 const onContextMenu = (event: MouseEvent) => {
1887 // Without this a right-click reported to the program also opens the
1888 // browser menu over it.
1889 if (mouseClaimed(event)) event.preventDefault();
1890 };
1891
1892 const onWheel = (event: WheelEvent) => {
1893 if (mouseClaimed(event)) {
1894 this.settleFractionalScroll(true);
1895 const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX);
1896 const button = vertical
1897 ? event.deltaY < 0
1898 ? 3
1899 : event.deltaY > 0
1900 ? 4
1901 : null
1902 : event.deltaX < 0
1903 ? 5
1904 : event.deltaX > 0
1905 ? 6
1906 : null;
1907 if (button !== null) {
1908 // Programs receive wheel axes as VT button 4-7 presses.
1909 const bytes = this.terminal.encodeMouse({
1910 action: "press",
1911 button,
1912 ...position(event),
1913 ctrlKey: event.ctrlKey,
1914 altKey: event.altKey,
1915 metaKey: event.metaKey,
1916 shiftKey: event.shiftKey,
1917 });
1918 if (bytes !== null) {
1919 event.preventDefault();
1920 emit(bytes);
1921 return;
1922 }
1923 }
1924 }
1925 if (this.options.scrollback === false) return;
1926 const wheel = this.wheelRows(event);
1927 if (this.scrollBy(wheel.rows, wheel.fractional)) event.preventDefault();
1928 };
1929 const onFocus = () => {
1930 this.focus();
1931 this.scheduleFrame();
1932 };
1933 const onFocusChange = () => this.scheduleFrame();
1934 const activateLink = (event: MouseEvent, target: EventTarget | null) => {
1935 const links = this.options.links;
1936 if (!links?.onActivate) return;
1937 const claimed = mouseGestureClaimed || mouseClaimed(event);
1938 mouseGestureClaimed = false;
1939 if (claimed) return;
1940 if (links.requireModifier === true && !event.ctrlKey && !event.metaKey) return;
1941 const linkElement =
1942 target instanceof Element ? target.closest<HTMLElement>(".clo-term-link") : null;
1943 const index = linkElement?.dataset["link"];
1944 const rowIndex = linkElement?.dataset["row"];
1945 if (index === undefined || rowIndex === undefined) return;
1946 let link = this.rowLinks.get(Number(rowIndex))?.[Number(index)];
1947 if (link === undefined) return;
1948 const row = this.rowSnapshots.get(Number(rowIndex));
1949 if (row && link.kind !== "osc8") {
1950 const displayed = link;
1951 link = this.detectRowLinks(row, Number(rowIndex)).find(
1952 (candidate) => candidate.start <= displayed.start && candidate.end >= displayed.end,
1953 );
1954 if (!link) return;
1955 }
1956 event.preventDefault();
1957 links.onActivate(link, event);
1958 };
1959
1960 const onClick = (event: MouseEvent) => {
1961 if ("pointerType" in event && event.pointerType === "touch") return;
1962 activateLink(event, event.target);
1963 };
1964 let touch: {
1965 pointerId: number;
1966 startX: number;
1967 startY: number;
1968 lastY: number;
1969 remainder: number;
1970 moved: boolean;
1971 target: EventTarget | null;
1972 } | null = null;
1973 const onTouchStart = (event: PointerEvent) => {
1974 if (event.pointerType !== "touch") return;
1975 event.preventDefault();
1976 if (!event.isPrimary || touch !== null) return;
1977 this.selectingOutput = false;
1978 touch = {
1979 pointerId: event.pointerId,
1980 startX: event.clientX,
1981 startY: event.clientY,
1982 lastY: event.clientY,
1983 remainder: 0,
1984 moved: false,
1985 target: event.target,
1986 };
1987 this.viewportEl.setPointerCapture?.(event.pointerId);
1988 };
1989 const onTouchMove = (event: PointerEvent) => {
1990 if (touch?.pointerId !== event.pointerId || event.pointerType !== "touch") return;
1991 event.preventDefault();
1992 touch.moved ||= Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) > 4;
1993 if (!touch.moved) return;
1994 const delta = (touch.lastY - event.clientY) / this.cellH;
1995 touch.lastY = event.clientY;
1996 if (!mouseClaimed(event) && !this.terminal.alternateScreen) {
1997 if (this.options.scrollback !== false) this.scrollBy(delta, true);
1998 return;
1999 }
2000 const total = touch.remainder + delta;
2001 const rows = Math.trunc(total);
2002 touch.remainder = total - rows;
2003 for (let index = 0; index < Math.abs(rows); index++) {
2004 if (mouseClaimed(event)) forward(event, "press", rows < 0 ? 3 : 4);
2005 else {
2006 const key = rows < 0 ? "ArrowUp" : "ArrowDown";
2007 emit(
2008 this.terminal.encodeKey({
2009 code: key,
2010 key,
2011 ctrlKey: event.ctrlKey,
2012 altKey: event.altKey,
2013 metaKey: event.metaKey,
2014 shiftKey: event.shiftKey,
2015 }),
2016 );
2017 }
2018 }
2019 };
2020 const onTouchEnd = (event: PointerEvent) => {
2021 if (touch?.pointerId !== event.pointerId || event.pointerType !== "touch") return;
2022 const gesture = touch;
2023 touch = null;
2024 event.preventDefault();
2025 if (this.viewportEl.hasPointerCapture?.(event.pointerId)) {
2026 this.viewportEl.releasePointerCapture(event.pointerId);
2027 }
2028 if (event.type !== "pointerup" || gesture.moved) return;
2029 if (mouseClaimed(event)) {
2030 forward(event, "press", 0);
2031 forward(event, "release", 0);
2032 } else {
2033 activateLink(event, gesture.target);
2034 this.element.ownerDocument.getSelection()?.removeAllRanges();
2035 }
2036 this.focus();
2037 };
2038
2039 this.detach.push(() => {
2040 this.viewportEl.removeEventListener("pointerdown", onTouchStart);
2041 this.viewportEl.removeEventListener("pointermove", onTouchMove);
2042 this.viewportEl.removeEventListener("pointerup", onTouchEnd);
2043 this.viewportEl.removeEventListener("pointercancel", onTouchEnd);
2044 this.viewportEl.removeEventListener("lostpointercapture", onTouchEnd);
2045 el.removeEventListener("click", onClick);
2046 el.removeEventListener("mousedown", onMouseDown);
2047 el.ownerDocument.removeEventListener("mouseup", finishOutputSelection);
2048 el.ownerDocument.removeEventListener("pointercancel", cancelOutputSelection);
2049 el.ownerDocument.defaultView?.removeEventListener("blur", cancelOutputSelection);
2050 el.removeEventListener("mouseup", onMouseUp);
2051 el.removeEventListener("mousemove", onMouseMove);
2052 el.removeEventListener("contextmenu", onContextMenu);
2053 el.removeEventListener("keydown", onKeyDown);
2054 el.removeEventListener("paste", onPaste);
2055 el.removeEventListener("wheel", onWheel);
2056 el.removeEventListener("focus", onFocus);
2057 this.input.removeEventListener("compositionstart", onCompositionStart);
2058 this.input.removeEventListener("compositionend", onCompositionEnd);
2059 this.input.removeEventListener("input", onInput);
2060 this.input.removeEventListener("focus", onFocusChange);
2061 this.input.removeEventListener("blur", onFocusChange);
2062 el.removeEventListener("blur", onFocusChange);
2063 });
2064 this.viewportEl.addEventListener("pointerdown", onTouchStart);
2065 this.viewportEl.addEventListener("pointermove", onTouchMove);
2066 this.viewportEl.addEventListener("pointerup", onTouchEnd);
2067 this.viewportEl.addEventListener("pointercancel", onTouchEnd);
2068 this.viewportEl.addEventListener("lostpointercapture", onTouchEnd);
2069 el.addEventListener("click", onClick);
2070 el.addEventListener("mousedown", onMouseDown);
2071 el.ownerDocument.addEventListener("mouseup", finishOutputSelection);
2072 el.ownerDocument.addEventListener("pointercancel", cancelOutputSelection);
2073 el.ownerDocument.defaultView?.addEventListener("blur", cancelOutputSelection);
2074 el.addEventListener("mouseup", onMouseUp);
2075 el.addEventListener("mousemove", onMouseMove);
2076 el.addEventListener("contextmenu", onContextMenu);
2077 el.addEventListener("keydown", onKeyDown);
2078 el.addEventListener("paste", onPaste);
2079 el.addEventListener("wheel", onWheel, { passive: false });
2080 el.addEventListener("focus", onFocus);
2081 this.input.addEventListener("compositionstart", onCompositionStart);
2082 this.input.addEventListener("compositionend", onCompositionEnd);
2083 this.input.addEventListener("input", onInput);
2084 this.input.addEventListener("focus", onFocusChange);
2085 this.input.addEventListener("blur", onFocusChange);
2086 el.addEventListener("blur", onFocusChange);
2087 }
2088
2089 dispose(): void {
2090 if (this.disposedView) return;
2091 this.disposedView = true;
2092 this.predictor.reset();
2093 this.overlaidRows.clear();
2094 this.frameReader.dispose();
2095 if (this.synchronizedOutputTimeout !== null) {
2096 clearTimeout(this.synchronizedOutputTimeout);
2097 this.synchronizedOutputTimeout = null;
2098 }
2099 this.observer?.disconnect();
2100 this.metricObserver?.disconnect();
2101 for (const fn of this.detach) fn();
2102 this.rowsHost.remove();
2103 this.viewportEl.remove();
2104 this.cursorEl.remove();
2105 this.stickyEl?.remove();
2106 this.scrollbarEl?.remove();
2107 this.metricProbe.remove();
2108 this.input.remove();
2109 for (const name of ROOT_CLASSES) {
2110 if (this.initialClasses.has(name)) this.element.classList.add(name);
2111 else this.element.classList.remove(name);
2112 }
2113 for (const { name, value, priority } of this.initialRootStyles) {
2114 if (value === "") this.element.style.removeProperty(name);
2115 else this.element.style.setProperty(name, value, priority);
2116 }
2117 if (this.initialTabIndex === null) this.element.removeAttribute("tabindex");
2118 else this.element.setAttribute("tabindex", this.initialTabIndex);
2119 }
2120}