1514 lines · 55.8 KB · TypeScript
RawDownload
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.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t