| 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 | |
| 6 | import type { |
| 7 | CellSnapshot, |
| 8 | FrameReader, |
| 9 | FrameSnapshot, |
| 10 | RowSnapshot, |
| 11 | Scrollbar, |
| 12 | Terminal, |
| 13 | } from "../ghostty/terminal.ts"; |
| 14 | import { detectLinks, type DetectedLink, type DetectLinksOptions } from "../links.ts"; |
| 15 | import { |
| 16 | isPredictableText, |
| 17 | PredictionEngine, |
| 18 | resolvePredictOptions, |
| 19 | type PredictionMode, |
| 20 | type PredictionSite, |
| 21 | type PredictOptions, |
| 22 | } from "../predict/engine.ts"; |
| 23 | import { columnTexts } from "../predict/frame.ts"; |
| 24 | import { cellsToRuns, cellText, linkCells, rowText } from "../runs.ts"; |
| 25 | import { stickyCommand, stickyTarget } from "../shell/sticky.ts"; |
| 26 | |
| 27 | type GlyphFitMode = "wide" | "symbol" | "powerline"; |
| 28 | |
| 29 | interface FittedGlyph { |
| 30 | element: HTMLElement; |
| 31 | key: string; |
| 32 | cache: boolean; |
| 33 | mode: GlyphFitMode; |
| 34 | target: number; |
| 35 | } |
| 36 | |
| 37 | interface GlyphFit { |
| 38 | scaleX: number; |
| 39 | scaleY: number; |
| 40 | marginLeft: number; |
| 41 | } |
| 42 | |
| 43 | const GLYPH_FIT_CACHE_LIMIT = 512; |
| 44 | const GLYPH_FIT_CACHE_TEXT_LIMIT = 64; |
| 45 | // Matches terminal.css: 10px track + 3px right inset. |
| 46 | const SCROLLBAR_GUTTER = 13; |
| 47 | const FONT_LOADING_CLASS = "clo-term-font-loading"; |
| 48 | const FONT_ERROR_EVENT = "clo-terminal-font-error"; |
| 49 | const SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000; |
| 50 | const SYMBOL_FONT_FAMILY = "__clo_terminal_symbols_3_4_0"; |
| 51 | const ROOT_CLASSES = [ |
| 52 | "clo-term", |
| 53 | "clo-term-has-scrollbar", |
| 54 | "clo-term-has-blink", |
| 55 | FONT_LOADING_CLASS, |
| 56 | ] as const; |
| 57 | const 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. */ |
| 63 | const ANSI_HUES = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]; |
| 64 | const ANSI_TOKENS = [ |
| 65 | ...ANSI_HUES.map((hue) => `--clo-term-ansi-${hue}`), |
| 66 | ...ANSI_HUES.map((hue) => `--clo-term-ansi-bright-${hue}`), |
| 67 | ]; |
| 68 | const PRIMARY_FONT_SAMPLE = "W"; |
| 69 | const SYMBOL_FONT_SAMPLE = "\uE0A3"; |
| 70 | const 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 | |
| 81 | function 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 | |
| 91 | function applyGlyphFit(element: HTMLElement, fit: GlyphFit): void { |
| 92 | element.style.marginLeft = `${fit.marginLeft}px`; |
| 93 | element.style.transform = `scale(${fit.scaleX}, ${fit.scaleY})`; |
| 94 | } |
| 95 | |
| 96 | interface FontCompletionEvent extends Event { |
| 97 | readonly fontfaces: readonly FontFace[]; |
| 98 | } |
| 99 | |
| 100 | interface FontCompletionSource { |
| 101 | addEventListener(type: "loadingdone", listener: EventListener): void; |
| 102 | removeEventListener(type: "loadingdone", listener: EventListener): void; |
| 103 | } |
| 104 | |
| 105 | function 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 | |
| 126 | function 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 | |
| 136 | function 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 | |
| 167 | function 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 | |
| 177 | export 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 | |
| 188 | export 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 | |
| 222 | const 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 | */ |
| 232 | function 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 | |
| 288 | export 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 | } |