import { isMacPlatform } from "../../lib/utils";import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links";import { GhosttyTerminalCore, type GhosttyScrollbar, type GhosttySnapshot, type GhosttyTheme,} from "./core";import { measureGhosttyCell, renderGhosttySnapshot, terminalGridSize, type GhosttyCellMetrics,} from "./renderer";import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";import { isMonospaceFamily } from "../../appearanceFonts"; export const DEFAULT_TERMINAL_FONT_SIZE = 12;const MIN_TERMINAL_FONT_SIZE = 6;const MAX_TERMINAL_FONT_SIZE = 32;// The glyph fallbacks only supply symbols the text faces are missing (powerline// separators, devicons, and other private-use prompt symbols), so shells// configured for a locally installed Nerd Font keep their prompt glyphs no// matter which text face is active.const TERMINAL_GLYPH_FALLBACKS = '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace';// The platform's own monospace faces; concrete names only, because an// unknown keyword (like ui-monospace) makes canvas font shorthand parsing// reject the whole string.export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS;const CONTENT_PADDING = 4;const MIN_SCROLLBAR_THUMB_HEIGHT = 18;/** Half a blink cycle: the visible and hidden phases are equally long. */const CURSOR_BLINK_INTERVAL_MS = 500; /** Requested terminal font; omitted fields fall back to the defaults. */export interface GhosttyTerminalFont { readonly family?: string; readonly size?: number;} let symbolsFontLoad: Promise<void> | null = null; /** * Register the bundled symbols-only Nerd Font once per page. It loads lazily * with the first terminal, and because it carries no regular text glyphs it * composes with any text face without changing metrics — prompt symbols and * devicons render even on machines without a locally installed Nerd Font. */function ensureTerminalSymbolsFont(): Promise<void> { if (symbolsFontLoad !== null) return symbolsFontLoad; symbolsFontLoad = (async () => { try { const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`); document.fonts.add(await face.load()); } catch { // Locally installed fallback faces still apply. } })(); return symbolsFontLoad;} function quoteTerminalFontFamilies(list: string): string { return list .split(",") .map((name) => { const bare = name.trim(); if (bare.length === 0) return ""; if (/^(['"]).*\1$/.test(bare)) return bare; if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; return `"${bare.replaceAll('"', "")}"`; }) .filter((name) => name.length > 0) .join(", ");} export function terminalFontFamily(family?: string): string { // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes // the whole canvas font string invalid and the assignment silently no-ops. const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; // The grid places the cursor and selection on one cell advance, so a // proportional face would draw its text narrower than its own cells. Refuse // it here rather than render a ragged grid with a stranded cursor. if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;} /** * Grids narrower than a classic 80-column terminal wrap command output hard, * so the rendered font size follows the canvas width: the preference is the * ceiling, and the size slides down (to a legibility floor) until a full-width * grid fits. A widening pane slides it back up toward the preference. */const MIN_TERMINAL_FIT_COLUMNS = 80;const MIN_TERMINAL_FIT_FONT_SIZE = 8; export function fittedTerminalFontSize( cellWidthAt: (size: number) => number, requested: number, mountWidth: number,): number { const available = mountWidth - CONTENT_PADDING * 2; if (available <= 0) return requested; const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE); const fits = (cellWidth: number) => cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS; let cellWidth = cellWidthAt(requested); if (cellWidth <= 0 || fits(cellWidth)) return requested; // The advance scales linearly with size for monospace faces: jump close to // the fitting size, then settle the remaining rounding one step at a time. const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS; let size = Math.max( floor, Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)), ); while (size > floor) { cellWidth = cellWidthAt(size); if (cellWidth <= 0 || fits(cellWidth)) break; size -= 1; } return size;} export function terminalFontSize(size?: number): number { if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));} /** * Whether the cursor should keep toggling. An unfocused surface draws a steady * hollow cursor instead of blinking, and a reduced-motion reader gets a steady * cursor too rather than a permanently animating element. */export function shouldBlinkTerminalCursor(state: { readonly focused: boolean; readonly cursorBlinking: boolean; readonly cursorVisible: boolean; readonly reducedMotion: boolean;}): boolean { return state.focused && state.cursorBlinking && state.cursorVisible && !state.reducedMotion;} /** * Vertical origin of the grid inside the mount. While content is shorter than * the viewport the grid sits at the top like a fresh terminal. Once scrollback * exists the prompt lives on the bottom row, so the grid anchors to the bottom * edge instead: the sub-row remainder moves above row 0 and resizing within a * row boundary keeps the prompt pinned instead of snapping up and down. */export function terminalContentOriginY( mountHeight: number, padding: number, rows: number, cellHeight: number, anchorBottom: boolean,): number { if (!anchorBottom) return padding; const slack = mountHeight - padding * 2 - rows * cellHeight; return padding + Math.max(0, slack);} export interface TerminalScrollbarGeometry { readonly thumbHeight: number; readonly thumbTop: number; readonly maxOffset: number;} export function terminalScrollbarGeometry( state: GhosttyScrollbar, trackHeight: number,): TerminalScrollbarGeometry | null { const total = Math.max(0, state.total); const len = Math.max(0, Math.min(state.len, total)); const maxOffset = Math.max(0, total - len); if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null; const thumbHeight = Math.min( trackHeight, Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total), ); const travel = Math.max(0, trackHeight - thumbHeight); const offset = Math.max(0, Math.min(state.offset, maxOffset)); return { thumbHeight, thumbTop: travel * (offset / maxOffset), maxOffset, };} export function terminalScrollbarOffsetAtPointer( state: GhosttyScrollbar, trackHeight: number, pointerY: number, pointerOffset: number,): number { const geometry = terminalScrollbarGeometry(state, trackHeight); if (geometry === null) return 0; const travel = Math.max(0, trackHeight - geometry.thumbHeight); if (travel === 0) return 0; const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel)); return Math.round((thumbTop / travel) * geometry.maxOffset);} function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { const text = row.cells.map((cell) => cell.text || " ").join(""); return trimRight ? text.trimEnd() : text;} function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { let offset = 0; for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { offset += row.cells[cellIndex]?.text.length || 1; } return offset;} export function terminalLinkAtPosition( rows: GhosttySnapshot["rowData"], rowIndex: number, column: number,): string | null { const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { const row = rows[index]; if (!row) return null; return { isWrapped: row.isWrapContinuation, translateToString: (trimRight = false) => terminalRowText(row, trimRight), }; }); if (!wrappedLine) return null; // Only viewport rows are available: a wrapped line whose head scrolled above // the viewport would resolve a truncated match into a wrong link. const firstSegment = wrappedLine.segments[0]; if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) { return null; } const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1); const row = rows[rowIndex]; if (!segment || !row) return null; const lastSegment = wrappedLine.segments.at(-1); const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; // Ghostty's soft-wrap flag is authoritative: when the last collected row // still wraps onward, its continuation is outside the viewport. const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; const offset = segment.startIndex + terminalColumnOffset(row, column); for (const match of extractTerminalLinks(wrappedLine.text)) { if (offset >= match.start && offset < match.end) { // A truncated tail must not activate as a complete link. if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; return match.text; } } return null;} export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { return terminalLinkAtPosition([row], 0, column);} export function isTerminalCopyShortcut( event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, platform = navigator.platform,) { if (event.key.toLowerCase() !== "c") return false; return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;} export function isTerminalPasteShortcut( event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, platform = navigator.platform,) { if (event.key.toLowerCase() !== "v") return false; return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;} export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean { return ( event.inputType === "" || event.inputType === "insertCompositionText" || event.inputType === "insertFromComposition" );} export function isTerminalAltGraphText( event: Pick<KeyboardEvent, "getModifierState" | "key">,): boolean { return event.getModifierState("AltGraph") && [...event.key].length === 1;} export function shouldReportTerminalMouse( tracking: boolean, event: Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">,): boolean { return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey;} export function terminalWheelDeltaRows( event: Pick<WheelEvent, "deltaY" | "deltaMode">, cellHeight: number, viewportRows: number, remainder: number,): { readonly rows: number; readonly remainder: number } { // deltaMode: 0 pixels, 1 lines, 2 pages. const pixels = event.deltaMode === 1 ? event.deltaY * cellHeight : event.deltaMode === 2 ? event.deltaY * viewportRows * cellHeight : event.deltaY; const total = remainder + pixels / cellHeight; const rows = Math.trunc(total); return { rows, remainder: total - rows };} export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { if (rows === 0) return ""; const sequence = rows < 0 ? applicationCursorKeys ? "\u001bOA" : "\u001b[A" : applicationCursorKeys ? "\u001bOB" : "\u001b[B"; return sequence.repeat(Math.abs(rows));} export function isTerminalLinkPointerGesture( event: Pick<MouseEvent, "ctrlKey" | "metaKey">, platform = navigator.platform,): boolean { return isMacPlatform(platform) ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;} export function ghosttyMouseButton(button: number): number | null { switch (button) { case 0: return 1; case 1: return 3; case 2: return 2; case 3: return 4; case 4: return 5; default: return null; }} export interface TerminalSelectionClickSequence { readonly count: number; readonly time: number; readonly x: number; readonly y: number;} export function advanceTerminalSelectionClickSequence( previous: TerminalSelectionClickSequence | null, event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">,): TerminalSelectionClickSequence { const repeats = previous !== null && event.timeStamp - previous.time <= 500 && Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; return { count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, time: event.timeStamp, x: event.clientX, y: event.clientY, };} export interface GhosttySelectionPosition { readonly start: { readonly x: number; readonly y: number }; readonly end: { readonly x: number; readonly y: number };} export interface GhosttyTerminalSurfaceOptions { readonly theme: GhosttyTheme; readonly font?: GhosttyTerminalFont; readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; readonly onCopy: (text: string) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void;} export class GhosttyTerminalSurface { readonly canvas: HTMLCanvasElement; readonly input: HTMLTextAreaElement; readonly scrollbar: HTMLDivElement; cols = 1; rows = 1; private readonly mount: HTMLElement; private readonly context: CanvasRenderingContext2D; private readonly core: GhosttyTerminalCore; private readonly options: GhosttyTerminalSurfaceOptions; private metrics: GhosttyCellMetrics; private fontFamily: string; private fontSize: number; private requestedFontSize: number; private fontEpoch = 0; private readonly resizeObserver: ResizeObserver; private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; private frame = 0; private cursorTimer: number | null = null; private compositionInputToSuppress: string | null = null; private compositionSuppressionTimer: number | null = null; private cursorOn = true; private renderedCursorY: number | null = null; private forceFullRender = true; private scrollbarDirty = true; private scrollbarState: GhosttyScrollbar | null = null; private scrollbarPointerId: number | null = null; private scrollbarPointerOffset = 0; private disposed = false; private resizeNotifyTimer: number | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; private selectionEnd: { x: number; y: number } | null = null; private selectionAnchorScreen: { x: number; y: number } | null = null; private selectionEndScreen: { x: number; y: number } | null = null; private selectionMode: "cell" | "word" | "line" = "cell"; // Word/line selection base in screen coordinates so streaming output cannot // shift the origin of a drag selection. private selectionBase: { start: { x: number; y: number }; end: { x: number; y: number }; } | null = null; private selectionScrollTimer: number | null = null; private selectionScrollDelta = 0; private selectionPointer: { x: number; y: number } | null = null; private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; private focused = false; private resizeNotified = false; private canvasConfigured = false; private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set<string>(); private pasteShortcutToken = 0; private wheelRemainder = 0; private dprMedia: MediaQueryList | null = null; // Read live on every blink decision, and watched so that dropping the // preference restarts a blink cycle that has no timer left to notice it. private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)"); private inputLeft = -1; private inputTop = -1; private constructor( mount: HTMLElement, canvas: HTMLCanvasElement, input: HTMLTextAreaElement, scrollbar: HTMLDivElement, scrollbarThumb: HTMLDivElement, context: CanvasRenderingContext2D, core: GhosttyTerminalCore, metrics: GhosttyCellMetrics, options: GhosttyTerminalSurfaceOptions, ) { this.mount = mount; this.canvas = canvas; this.input = input; this.scrollbar = scrollbar; this.scrollbarThumb = scrollbarThumb; this.context = context; this.core = core; this.metrics = metrics; this.options = options; this.theme = options.theme; this.fontFamily = terminalFontFamily(options.font?.family); this.fontSize = terminalFontSize(options.font?.size); this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); this.installEvents(); this.watchDevicePixelRatio(); this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); document.fonts.addEventListener("loadingdone", this.onFontsLoaded); this.resizeObserver.observe(mount); } static async create( mount: HTMLElement, options: GhosttyTerminalSurfaceOptions, ): Promise<GhosttyTerminalSurface> { const canvas = document.createElement("canvas");Lines 1–500 of 1514. Load more
import { isMacPlatform } from "../../lib/utils"; import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; import { GhosttyTerminalCore, type GhosttyScrollbar, type GhosttySnapshot, type GhosttyTheme, } from "./core"; import { measureGhosttyCell, renderGhosttySnapshot, terminalGridSize, type GhosttyCellMetrics, } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; const MAX_TERMINAL_FONT_SIZE = 32; // The glyph fallbacks only supply symbols the text faces are missing (powerline // separators, devicons, and other private-use prompt symbols), so shells // configured for a locally installed Nerd Font keep their prompt glyphs no // matter which text face is active. const TERMINAL_GLYPH_FALLBACKS = '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; // The platform's own monospace faces; concrete names only, because an // unknown keyword (like ui-monospace) makes canvas font shorthand parsing // reject the whole string. export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; /** Requested terminal font; omitted fields fall back to the defaults. */ export interface GhosttyTerminalFont { readonly family?: string; readonly size?: number; } let symbolsFontLoad: Promise<void> | null = null; /** * Register the bundled symbols-only Nerd Font once per page. It loads lazily * with the first terminal, and because it carries no regular text glyphs it * composes with any text face without changing metrics — prompt symbols and * devicons render even on machines without a locally installed Nerd Font. */ function ensureTerminalSymbolsFont(): Promise<void> { if (symbolsFontLoad !== null) return symbolsFontLoad; symbolsFontLoad = (async () => { try { const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`); document.fonts.add(await face.load()); } catch { // Locally installed fallback faces still apply. } })(); return symbolsFontLoad; } function quoteTerminalFontFamilies(list: string): string { return list .split(",") .map((name) => { const bare = name.trim(); if (bare.length === 0) return ""; if (/^(['"]).*\1$/.test(bare)) return bare; if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; return `"${bare.replaceAll('"', "")}"`; }) .filter((name) => name.length > 0) .join(", "); } export function terminalFontFamily(family?: string): string { // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes // the whole canvas font string invalid and the assignment silently no-ops. const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); if (custom.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; // The grid places the cursor and selection on one cell advance, so a // proportional face would draw its text narrower than its own cells. Refuse // it here rather than render a ragged grid with a stranded cursor. if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; } /** * Grids narrower than a classic 80-column terminal wrap command output hard, * so the rendered font size follows the canvas width: the preference is the * ceiling, and the size slides down (to a legibility floor) until a full-width * grid fits. A widening pane slides it back up toward the preference. */ const MIN_TERMINAL_FIT_COLUMNS = 80; const MIN_TERMINAL_FIT_FONT_SIZE = 8; export function fittedTerminalFontSize( cellWidthAt: (size: number) => number, requested: number, mountWidth: number, ): number { const available = mountWidth - CONTENT_PADDING * 2; if (available <= 0) return requested; const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE); const fits = (cellWidth: number) => cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS; let cellWidth = cellWidthAt(requested); if (cellWidth <= 0 || fits(cellWidth)) return requested; // The advance scales linearly with size for monospace faces: jump close to // the fitting size, then settle the remaining rounding one step at a time. const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS; let size = Math.max( floor, Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)), ); while (size > floor) { cellWidth = cellWidthAt(size); if (cellWidth <= 0 || fits(cellWidth)) break; size -= 1; } return size; } export function terminalFontSize(size?: number): number { if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); } /** * Whether the cursor should keep toggling. An unfocused surface draws a steady * hollow cursor instead of blinking, and a reduced-motion reader gets a steady * cursor too rather than a permanently animating element. */ export function shouldBlinkTerminalCursor(state: { readonly focused: boolean; readonly cursorBlinking: boolean; readonly cursorVisible: boolean; readonly reducedMotion: boolean; }): boolean { return state.focused && state.cursorBlinking && state.cursorVisible && !state.reducedMotion; } /** * Vertical origin of the grid inside the mount. While content is shorter than * the viewport the grid sits at the top like a fresh terminal. Once scrollback * exists the prompt lives on the bottom row, so the grid anchors to the bottom * edge instead: the sub-row remainder moves above row 0 and resizing within a * row boundary keeps the prompt pinned instead of snapping up and down. */ export function terminalContentOriginY( mountHeight: number, padding: number, rows: number, cellHeight: number, anchorBottom: boolean, ): number { if (!anchorBottom) return padding; const slack = mountHeight - padding * 2 - rows * cellHeight; return padding + Math.max(0, slack); } export interface TerminalScrollbarGeometry { readonly thumbHeight: number; readonly thumbTop: number; readonly maxOffset: number; } export function terminalScrollbarGeometry( state: GhosttyScrollbar, trackHeight: number, ): TerminalScrollbarGeometry | null { const total = Math.max(0, state.total); const len = Math.max(0, Math.min(state.len, total)); const maxOffset = Math.max(0, total - len); if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null; const thumbHeight = Math.min( trackHeight, Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total), ); const travel = Math.max(0, trackHeight - thumbHeight); const offset = Math.max(0, Math.min(state.offset, maxOffset)); return { thumbHeight, thumbTop: travel * (offset / maxOffset), maxOffset, }; } export function terminalScrollbarOffsetAtPointer( state: GhosttyScrollbar, trackHeight: number, pointerY: number, pointerOffset: number, ): number { const geometry = terminalScrollbarGeometry(state, trackHeight); if (geometry === null) return 0; const travel = Math.max(0, trackHeight - geometry.thumbHeight); if (travel === 0) return 0; const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel)); return Math.round((thumbTop / travel) * geometry.maxOffset); } function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { const text = row.cells.map((cell) => cell.text || " ").join(""); return trimRight ? text.trimEnd() : text; } function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { let offset = 0; for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { offset += row.cells[cellIndex]?.text.length || 1; } return offset; } export function terminalLinkAtPosition( rows: GhosttySnapshot["rowData"], rowIndex: number, column: number, ): string | null { const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { const row = rows[index]; if (!row) return null; return { isWrapped: row.isWrapContinuation, translateToString: (trimRight = false) => terminalRowText(row, trimRight), }; }); if (!wrappedLine) return null; // Only viewport rows are available: a wrapped line whose head scrolled above // the viewport would resolve a truncated match into a wrong link. const firstSegment = wrappedLine.segments[0]; if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) { return null; } const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1); const row = rows[rowIndex]; if (!segment || !row) return null; const lastSegment = wrappedLine.segments.at(-1); const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; // Ghostty's soft-wrap flag is authoritative: when the last collected row // still wraps onward, its continuation is outside the viewport. const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; const offset = segment.startIndex + terminalColumnOffset(row, column); for (const match of extractTerminalLinks(wrappedLine.text)) { if (offset >= match.start && offset < match.end) { // A truncated tail must not activate as a complete link. if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; return match.text; } } return null; } export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { return terminalLinkAtPosition([row], 0, column); } export function isTerminalCopyShortcut( event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, platform = navigator.platform, ) { if (event.key.toLowerCase() !== "c") return false; return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } export function isTerminalPasteShortcut( event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, platform = navigator.platform, ) { if (event.key.toLowerCase() !== "v") return false; return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean { return ( event.inputType === "" || event.inputType === "insertCompositionText" || event.inputType === "insertFromComposition" ); } export function isTerminalAltGraphText( event: Pick<KeyboardEvent, "getModifierState" | "key">, ): boolean { return event.getModifierState("AltGraph") && [...event.key].length === 1; } export function shouldReportTerminalMouse( tracking: boolean, event: Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">, ): boolean { return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; } export function terminalWheelDeltaRows( event: Pick<WheelEvent, "deltaY" | "deltaMode">, cellHeight: number, viewportRows: number, remainder: number, ): { readonly rows: number; readonly remainder: number } { // deltaMode: 0 pixels, 1 lines, 2 pages. const pixels = event.deltaMode === 1 ? event.deltaY * cellHeight : event.deltaMode === 2 ? event.deltaY * viewportRows * cellHeight : event.deltaY; const total = remainder + pixels / cellHeight; const rows = Math.trunc(total); return { rows, remainder: total - rows }; } export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { if (rows === 0) return ""; const sequence = rows < 0 ? applicationCursorKeys ? "\u001bOA" : "\u001b[A" : applicationCursorKeys ? "\u001bOB" : "\u001b[B"; return sequence.repeat(Math.abs(rows)); } export function isTerminalLinkPointerGesture( event: Pick<MouseEvent, "ctrlKey" | "metaKey">, platform = navigator.platform, ): boolean { return isMacPlatform(platform) ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; } export function ghosttyMouseButton(button: number): number | null { switch (button) { case 0: return 1; case 1: return 3; case 2: return 2; case 3: return 4; case 4: return 5; default: return null; } } export interface TerminalSelectionClickSequence { readonly count: number; readonly time: number; readonly x: number; readonly y: number; } export function advanceTerminalSelectionClickSequence( previous: TerminalSelectionClickSequence | null, event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">, ): TerminalSelectionClickSequence { const repeats = previous !== null && event.timeStamp - previous.time <= 500 && Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; return { count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, time: event.timeStamp, x: event.clientX, y: event.clientY, }; } export interface GhosttySelectionPosition { readonly start: { readonly x: number; readonly y: number }; readonly end: { readonly x: number; readonly y: number }; } export interface GhosttyTerminalSurfaceOptions { readonly theme: GhosttyTheme; readonly font?: GhosttyTerminalFont; readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; readonly onCopy: (text: string) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; } export class GhosttyTerminalSurface { readonly canvas: HTMLCanvasElement; readonly input: HTMLTextAreaElement; readonly scrollbar: HTMLDivElement; cols = 1; rows = 1; private readonly mount: HTMLElement; private readonly context: CanvasRenderingContext2D; private readonly core: GhosttyTerminalCore; private readonly options: GhosttyTerminalSurfaceOptions; private metrics: GhosttyCellMetrics; private fontFamily: string; private fontSize: number; private requestedFontSize: number; private fontEpoch = 0; private readonly resizeObserver: ResizeObserver; private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; private frame = 0; private cursorTimer: number | null = null; private compositionInputToSuppress: string | null = null; private compositionSuppressionTimer: number | null = null; private cursorOn = true; private renderedCursorY: number | null = null; private forceFullRender = true; private scrollbarDirty = true; private scrollbarState: GhosttyScrollbar | null = null; private scrollbarPointerId: number | null = null; private scrollbarPointerOffset = 0; private disposed = false; private resizeNotifyTimer: number | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; private selectionEnd: { x: number; y: number } | null = null; private selectionAnchorScreen: { x: number; y: number } | null = null; private selectionEndScreen: { x: number; y: number } | null = null; private selectionMode: "cell" | "word" | "line" = "cell"; // Word/line selection base in screen coordinates so streaming output cannot // shift the origin of a drag selection. private selectionBase: { start: { x: number; y: number }; end: { x: number; y: number }; } | null = null; private selectionScrollTimer: number | null = null; private selectionScrollDelta = 0; private selectionPointer: { x: number; y: number } | null = null; private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; private focused = false; private resizeNotified = false; private canvasConfigured = false; private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set<string>(); private pasteShortcutToken = 0; private wheelRemainder = 0; private dprMedia: MediaQueryList | null = null; // Read live on every blink decision, and watched so that dropping the // preference restarts a blink cycle that has no timer left to notice it. private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)"); private inputLeft = -1; private inputTop = -1; private constructor( mount: HTMLElement, canvas: HTMLCanvasElement, input: HTMLTextAreaElement, scrollbar: HTMLDivElement, scrollbarThumb: HTMLDivElement, context: CanvasRenderingContext2D, core: GhosttyTerminalCore, metrics: GhosttyCellMetrics, options: GhosttyTerminalSurfaceOptions, ) { this.mount = mount; this.canvas = canvas; this.input = input; this.scrollbar = scrollbar; this.scrollbarThumb = scrollbarThumb; this.context = context; this.core = core; this.metrics = metrics; this.options = options; this.theme = options.theme; this.fontFamily = terminalFontFamily(options.font?.family); this.fontSize = terminalFontSize(options.font?.size); this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); this.installEvents(); this.watchDevicePixelRatio(); this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); document.fonts.addEventListener("loadingdone", this.onFontsLoaded); this.resizeObserver.observe(mount); } static async create( mount: HTMLElement, options: GhosttyTerminalSurfaceOptions, ): Promise<GhosttyTerminalSurface> { const canvas = document.createElement("canvas");