diff --git a/lib/buffer.ts b/lib/buffer.ts index e510ec2..05f7517 100644 --- a/lib/buffer.ts +++ b/lib/buffer.ts @@ -177,7 +177,10 @@ export class Buffer implements IBuffer { if (this.bufferType === 'normal' && y < scrollbackLength) { // Accessing scrollback - const scrollbackOffset = scrollbackLength - y - 1; // Most recent = 0 + // WASM getScrollbackLine: offset 0 = oldest, offset (length-1) = newest + // Buffer coords: y=0 = oldest, y=(length-1) = newest + // So scrollbackOffset = y directly! + const scrollbackOffset = y; cells = wasmTerm.getScrollbackLine(scrollbackOffset); // TODO: We'd need WASM API to check if scrollback line is wrapped // For now, assume not wrapped @@ -364,4 +367,28 @@ export class BufferCell implements IBufferCell { isFaint(): number { return (this.cell.flags & CellFlags.FAINT) !== 0 ? 1 : 0; } + + /** + * Get hyperlink ID for this cell (0 = no link) + * Used by link detection system + */ + getHyperlinkId(): number { + return this.cell.hyperlink_id; + } + + /** + * Get the Unicode codepoint for this cell + * Used by link detection system + */ + getCodepoint(): number { + return this.cell.codepoint; + } + + /** + * Check if cell has dim/faint attribute + * Added for IBufferCell compatibility + */ + isDim(): boolean { + return (this.cell.flags & CellFlags.FAINT) !== 0; + } } diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 366c6f4..cbf6095 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -192,4 +192,12 @@ export interface IBufferCell { isInvisible(): number; /** Whether cell has faint/dim style */ isFaint(): number; + + // Link detection support + /** Get hyperlink ID for this cell (0 = no link) */ + getHyperlinkId(): number; + /** Get the Unicode codepoint for this cell */ + getCodepoint(): number; + /** Whether cell has dim/faint attribute (boolean version) */ + isDim(): boolean; } diff --git a/lib/link-detector.ts b/lib/link-detector.ts new file mode 100644 index 0000000..408f9c3 --- /dev/null +++ b/lib/link-detector.ts @@ -0,0 +1,240 @@ +/** + * Link detection and caching system + * + * The LinkDetector coordinates between multiple link providers and caches + * results for performance. It uses hyperlink_id for intelligent caching + * since the same hyperlink_id always represents the same link. + */ + +import type { IBufferCellPosition, ILink, ILinkProvider } from './types'; + +/** + * Manages link detection across multiple providers with intelligent caching + */ +export class LinkDetector { + private providers: ILinkProvider[] = []; + + // Cache links by hyperlink_id for fast lookups + // Key format: `h${hyperlinkId}` for OSC 8 links + // Key format: `r${row}:${startX}-${endX}` for regex links (future) + private linkCache = new Map(); + + // Track which rows have been scanned to avoid redundant provider calls + private scannedRows = new Set(); + + // Terminal instance for buffer access + constructor(private terminal: ITerminalForLinkDetector) {} + + /** + * Register a link provider + */ + registerProvider(provider: ILinkProvider): void { + this.providers.push(provider); + this.invalidateCache(); // New provider may detect different links + } + + /** + * Get link at the specified buffer position + * @param col Column (0-based) + * @param row Absolute row in buffer (0-based) + * @returns Link at position, or undefined if none + */ + async getLinkAt(col: number, row: number): Promise { + // First, check if this cell has a hyperlink_id (fast path for OSC 8) + const line = this.terminal.buffer.active.getLine(row); + if (!line || col < 0 || col >= line.length) { + return undefined; + } + + const cell = line.getCell(col); + if (!cell) { + return undefined; + } + const hyperlinkId = cell.getHyperlinkId(); + + if (hyperlinkId > 0) { + // Fast path: check cache by hyperlink_id + const cacheKey = `h${hyperlinkId}`; + if (this.linkCache.has(cacheKey)) { + return this.linkCache.get(cacheKey); + } + } + + // Slow path: scan this row if not already scanned + if (!this.scannedRows.has(row)) { + await this.scanRow(row); + } + + // Check cache again (hyperlinkId or position-based) + if (hyperlinkId > 0) { + const cacheKey = `h${hyperlinkId}`; + const link = this.linkCache.get(cacheKey); + if (link) return link; + } + + // Check if any cached link contains this position + for (const link of this.linkCache.values()) { + if (this.isPositionInLink(col, row, link)) { + return link; + } + } + + return undefined; + } + + /** + * Scan a row for links using all registered providers + */ + private async scanRow(row: number): Promise { + this.scannedRows.add(row); + + const allLinks: ILink[] = []; + + // Query all providers + for (const provider of this.providers) { + const links = await new Promise((resolve) => { + provider.provideLinks(row, resolve); + }); + + if (links) { + allLinks.push(...links); + } + } + + // Cache all discovered links + for (const link of allLinks) { + this.cacheLink(link); + } + } + + /** + * Cache a link for fast lookup + */ + private cacheLink(link: ILink): void { + // Try to get hyperlink_id for this link + const { start } = link.range; + const line = this.terminal.buffer.active.getLine(start.y); + if (line) { + const cell = line.getCell(start.x); + if (!cell) { + // Fallback: cache by position range + const { start: s, end: e } = link.range; + const cacheKey = `r${s.y}:${s.x}-${e.x}`; + this.linkCache.set(cacheKey, link); + return; + } + const hyperlinkId = cell.getHyperlinkId(); + + if (hyperlinkId > 0) { + // Cache by hyperlink_id (best case - stable across rows) + this.linkCache.set(`h${hyperlinkId}`, link); + return; + } + } + + // Fallback: cache by position range + // Format: r${row}:${startX}-${endX} + const { start: s, end: e } = link.range; + const cacheKey = `r${s.y}:${s.x}-${e.x}`; + this.linkCache.set(cacheKey, link); + } + + /** + * Check if a position is within a link's range + */ + private isPositionInLink(col: number, row: number, link: ILink): boolean { + const { start, end } = link.range; + + // Check if row is in range + if (row < start.y || row > end.y) { + return false; + } + + // Single-line link + if (start.y === end.y) { + return col >= start.x && col <= end.x; + } + + // Multi-line link + if (row === start.y) { + return col >= start.x; // First line: from start.x to end of line + } else if (row === end.y) { + return col <= end.x; // Last line: from start of line to end.x + } else { + return true; // Middle line: entire line is part of link + } + } + + /** + * Invalidate cache when terminal content changes + * Should be called on terminal write, resize, or clear + */ + invalidateCache(): void { + this.linkCache.clear(); + this.scannedRows.clear(); + } + + /** + * Invalidate cache for specific rows + * Used when only part of the terminal changed + */ + invalidateRows(startRow: number, endRow: number): void { + // Remove scanned markers + for (let row = startRow; row <= endRow; row++) { + this.scannedRows.delete(row); + } + + // Remove cached links in this range + // This is conservative - we remove any link that touches these rows + const toDelete: string[] = []; + for (const [key, link] of this.linkCache.entries()) { + const { start, end } = link.range; + if ( + (start.y >= startRow && start.y <= endRow) || + (end.y >= startRow && end.y <= endRow) || + (start.y < startRow && end.y > endRow) + ) { + toDelete.push(key); + } + } + + for (const key of toDelete) { + this.linkCache.delete(key); + } + } + + /** + * Dispose and cleanup + */ + dispose(): void { + this.linkCache.clear(); + this.scannedRows.clear(); + + // Dispose all providers + for (const provider of this.providers) { + provider.dispose?.(); + } + this.providers = []; + } +} + +/** + * Minimal terminal interface required by LinkDetector + * Keeps coupling low and testing easy + */ +export interface ITerminalForLinkDetector { + buffer: { + active: { + getLine(y: number): + | { + length: number; + getCell(x: number): + | { + getHyperlinkId(): number; + } + | undefined; + } + | undefined; + }; + }; +} diff --git a/lib/providers/osc8-link-provider.ts b/lib/providers/osc8-link-provider.ts new file mode 100644 index 0000000..b487500 --- /dev/null +++ b/lib/providers/osc8-link-provider.ts @@ -0,0 +1,216 @@ +/** + * OSC 8 Hyperlink Provider + * + * Detects hyperlinks created with OSC 8 escape sequences. + * Supports multi-line links that wrap across lines. + * + * OSC 8 format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07 + * + * The Ghostty WASM automatically assigns hyperlink_id to cells, + * so we just need to scan for contiguous regions with the same ID. + */ + +import type { IBufferRange, ILink, ILinkProvider } from '../types'; + +/** + * OSC 8 Hyperlink Provider + * + * Detects OSC 8 hyperlinks by scanning for hyperlink_id in cells. + * Automatically handles multi-line links since Ghostty WASM preserves + * hyperlink_id across wrapped lines. + */ +export class OSC8LinkProvider implements ILinkProvider { + constructor(private terminal: ITerminalForOSC8Provider) {} + + /** + * Provide all OSC 8 links on the given row + * Note: This may return links that span multiple rows + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + const links: ILink[] = []; + const visitedIds = new Set(); + + const line = this.terminal.buffer.active.getLine(y); + if (!line) { + callback(undefined); + return; + } + + // Scan through this line looking for hyperlink_id + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) continue; + + const hyperlinkId = cell.getHyperlinkId(); + + // Skip cells without links or already processed links + if (hyperlinkId === 0 || visitedIds.has(hyperlinkId)) { + continue; + } + + visitedIds.add(hyperlinkId); + + // Find the full extent of this link (may span multiple lines) + const range = this.findLinkRange(hyperlinkId, y, x); + + // Get the URI from WASM + if (!this.terminal.wasmTerm) continue; + const uri = this.terminal.wasmTerm.getHyperlinkUri(hyperlinkId); + + if (uri) { + links.push({ + text: uri, + range, + activate: (event) => { + // Open link if Ctrl/Cmd is pressed + if (event.ctrlKey || event.metaKey) { + window.open(uri, '_blank', 'noopener,noreferrer'); + } + }, + }); + } + } + + callback(links.length > 0 ? links : undefined); + } + + /** + * Find the full extent of a link by scanning for contiguous cells + * with the same hyperlink_id. Handles multi-line links. + */ + private findLinkRange(hyperlinkId: number, startY: number, startX: number): IBufferRange { + const buffer = this.terminal.buffer.active; + + // Find the start of the link (scan backwards) + let minY = startY; + let minX = startX; + + // Scan backwards on current line + while (minX > 0) { + const line = buffer.getLine(minY); + if (!line) break; + + const cell = line.getCell(minX - 1); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; + minX--; + } + + // If at start of line, check if link continues from previous line + if (minX === 0 && minY > 0) { + let prevY = minY - 1; + + while (prevY >= 0) { + const prevLine = buffer.getLine(prevY); + if (!prevLine || prevLine.length === 0) break; + + // Check if last cell of previous line has same hyperlink_id + const lastCell = prevLine.getCell(prevLine.length - 1); + if (!lastCell || lastCell.getHyperlinkId() !== hyperlinkId) break; + + // Link continues from previous line - find where it starts + minY = prevY; + minX = 0; + + // Scan backwards on this line + for (let x = prevLine.length - 1; x >= 0; x--) { + const cell = prevLine.getCell(x); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) { + minX = x + 1; + break; + } + } + + // If entire line is part of link, continue to previous line + if (minX === 0) { + prevY--; + } else { + break; + } + } + } + + // Find the end of the link (scan forwards) + let maxY = startY; + let maxX = startX; + + // Scan forwards on current line + const currentLine = buffer.getLine(maxY); + if (currentLine) { + while (maxX < currentLine.length - 1) { + const cell = currentLine.getCell(maxX + 1); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; + maxX++; + } + + // If at end of line, check if link continues to next line + if (maxX === currentLine.length - 1) { + let nextY = maxY + 1; + const maxBuffer = buffer.length; + + while (nextY < maxBuffer) { + const nextLine = buffer.getLine(nextY); + if (!nextLine || nextLine.length === 0) break; + + // Check if first cell of next line has same hyperlink_id + const firstCell = nextLine.getCell(0); + if (!firstCell || firstCell.getHyperlinkId() !== hyperlinkId) break; + + // Link continues to next line - find where it ends + maxY = nextY; + maxX = 0; + + // Scan forwards on this line + for (let x = 0; x < nextLine.length; x++) { + const cell = nextLine.getCell(x); + if (!cell) break; + if (cell.getHyperlinkId() !== hyperlinkId) { + maxX = x - 1; + break; + } + maxX = x; + } + + // If entire line is part of link, continue to next line + if (maxX === nextLine.length - 1) { + nextY++; + } else { + break; + } + } + } + } + + return { + start: { x: minX, y: minY }, + end: { x: maxX, y: maxY }, + }; + } + + dispose(): void { + // No resources to clean up + } +} + +/** + * Minimal terminal interface required by OSC8LinkProvider + */ +export interface ITerminalForOSC8Provider { + buffer: { + active: { + length: number; + getLine(y: number): + | { + length: number; + getCell(x: number): + | { + getHyperlinkId(): number; + } + | undefined; + } + | undefined; + }; + }; + wasmTerm?: { + getHyperlinkUri(id: number): string | null; + }; +} diff --git a/lib/renderer.ts b/lib/renderer.ts index c3e3290..bc5d061 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -12,7 +12,7 @@ import type { ITheme } from './interfaces'; import type { SelectionManager } from './selection-manager'; -import type { GhosttyCell } from './types'; +import type { GhosttyCell, ILink } from './types'; import { CellFlags } from './types'; // Interface for objects that can be rendered @@ -104,6 +104,10 @@ export class CanvasRenderer { // Selection manager (for rendering selection overlay) private selectionManager?: SelectionManager; + // Link rendering state + private hoveredHyperlinkId: number = 0; + private previousHoveredHyperlinkId: number = 0; + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { this.canvas = canvas; const ctx = canvas.getContext('2d', { alpha: false }); @@ -305,6 +309,48 @@ export class CanvasRenderer { } } + // Track rows with hyperlinks that need redraw when hover changes + const hyperlinkRows = new Set(); + const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId; + + if (hyperlinkChanged) { + // Find rows containing the old or new hovered hyperlink + // Must check the correct buffer based on viewportY (scrollback vs screen) + for (let y = 0; y < dims.rows; y++) { + let line: GhosttyCell[] | null = null; + + // Same logic as rendering: fetch from scrollback or screen + if (viewportY > 0) { + if (y < viewportY && scrollbackProvider) { + // This row is from scrollback + const scrollbackOffset = scrollbackLength - viewportY + y; + line = scrollbackProvider.getScrollbackLine(scrollbackOffset); + } else { + // This row is from visible screen + const screenRow = y - viewportY; + line = buffer.getLine(screenRow); + } + } else { + // At bottom - fetch from visible screen + line = buffer.getLine(y); + } + + if (line) { + for (const cell of line) { + if ( + cell.hyperlink_id === this.hoveredHyperlinkId || + cell.hyperlink_id === this.previousHoveredHyperlinkId + ) { + hyperlinkRows.add(y); + break; // Found hyperlink in this row + } + } + } + } + // Update previous state + this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; + } + // Track if anything was actually rendered let anyLinesRendered = false; @@ -312,7 +358,9 @@ export class CanvasRenderer { for (let y = 0; y < dims.rows; y++) { // When scrolled, always force render all lines since we're showing scrollback const needsRender = - viewportY > 0 ? true : forceAll || buffer.isRowDirty(y) || selectionRows.has(y); + viewportY > 0 + ? true + : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y); if (!needsRender) { continue; @@ -355,6 +403,8 @@ export class CanvasRenderer { this.renderSelection(dims.cols); } + // Link underlines are drawn during cell rendering (see renderCell) + // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { this.renderCursor(cursor.x, cursor.y); @@ -472,6 +522,22 @@ export class CanvasRenderer { this.ctx.lineTo(cellX + cellWidth, strikeY); this.ctx.stroke(); } + + // Draw hyperlink underline + if (cell.hyperlink_id > 0) { + const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId; + + // Only show underline when hovered (cleaner look) + if (isHovered) { + const underlineY = cellY + this.metrics.baseline + 2; + this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, underlineY); + this.ctx.lineTo(cellX + cellWidth, underlineY); + this.ctx.stroke(); + } + } } /** @@ -674,6 +740,27 @@ export class CanvasRenderer { this.selectionManager = manager; } + /** + * Set the currently hovered hyperlink ID for rendering underlines + */ + public setHoveredHyperlinkId(hyperlinkId: number): void { + this.hoveredHyperlinkId = hyperlinkId; + } + + /** + * Get character cell width (for coordinate conversion) + */ + public get charWidth(): number { + return this.metrics.width; + } + + /** + * Get character cell height (for coordinate conversion) + */ + public get charHeight(): number { + return this.metrics.height; + } + /** * Clear entire canvas */ diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index e4505e6..5d54815 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -620,7 +620,7 @@ describe('Scroll Events', () => { expect(positions[2]).toBe(6); }); - // Note: onRender event is deferred to Phase 3 for proper dirty tracking + // Note: onRender event implementation uses dirty tracking for performance // implementation. Firing it every frame causes performance issues. test('onCursorMove should fire when cursor moves', async () => { diff --git a/lib/terminal.ts b/lib/terminal.ts index 25d7661..8ecf794 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -26,8 +26,11 @@ import type { ITerminalCore, ITerminalOptions, } from './interfaces'; +import { LinkDetector } from './link-detector'; +import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; +import type { ILink, ILinkProvider } from './types'; // ============================================================================ // Terminal Class @@ -50,12 +53,18 @@ export class Terminal implements ITerminalCore { // Components (created on open()) private ghostty?: Ghostty; - private wasmTerm?: GhosttyTerminal; + public wasmTerm?: GhosttyTerminal; // Made public for link providers private renderer?: CanvasRenderer; private inputHandler?: InputHandler; private selectionManager?: SelectionManager; private canvas?: HTMLCanvasElement; + // Link detection system + private linkDetector?: LinkDetector; + private currentHoveredLink?: ILink; + private mouseMoveThrottleTimeout?: number; + private pendingMouseMove?: MouseEvent; + // Event emitters private dataEmitter = new EventEmitter(); private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); @@ -241,6 +250,17 @@ export class Terminal implements ITerminalCore { } }); + // Initialize link detection system + this.linkDetector = new LinkDetector(this); + + // Register OSC 8 hyperlink provider + this.linkDetector.registerProvider(new OSC8LinkProvider(this)); + + // Setup mouse event handling for links + parent.addEventListener('mousemove', this.handleMouseMove); + parent.addEventListener('mouseleave', this.handleMouseLeave); + parent.addEventListener('click', this.handleClick); + // Setup wheel event handling for scrolling (Phase 2) // Use capture phase to ensure we get the event before browser scrolling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); @@ -282,6 +302,9 @@ export class Terminal implements ITerminalCore { // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); + // Invalidate link cache (content changed) + this.linkDetector?.invalidateCache(); + // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) if (this.viewportY !== 0) { this.scrollToBottom(); @@ -425,8 +448,11 @@ export class Terminal implements ITerminalCore { */ focus(): void { if (this.isOpen && this.element) { - // Focus the container element to receive keyboard events - // Use setTimeout to ensure DOM is fully ready + // Focus immediately for immediate keyboard/wheel event handling + this.element.focus(); + + // Also schedule a delayed focus as backup to ensure it sticks + // (some browsers may need this if DOM isn't fully settled) setTimeout(() => { this.element?.focus(); }, 0); @@ -531,6 +557,31 @@ export class Terminal implements ITerminalCore { this.customWheelEventHandler = customWheelEventHandler; } + // ========================================================================== + // Link Detection Methods + // ========================================================================== + + /** + * Register a custom link provider + * Multiple providers can be registered to detect different types of links + * + * @example + * ```typescript + * term.registerLinkProvider({ + * provideLinks(y, callback) { + * // Detect URLs, file paths, etc. + * callback(detectedLinks); + * } + * }); + * ``` + */ + public registerLinkProvider(provider: ILinkProvider): void { + if (!this.linkDetector) { + throw new Error('Terminal must be opened before registering link providers'); + } + this.linkDetector.registerProvider(provider); + } + // ========================================================================== // Phase 2: Scrolling Methods // ========================================================================== @@ -625,6 +676,13 @@ export class Terminal implements ITerminalCore { this.animationFrameId = undefined; } + // Clear mouse move throttle timeout + if (this.mouseMoveThrottleTimeout) { + clearTimeout(this.mouseMoveThrottleTimeout); + this.mouseMoveThrottleTimeout = undefined; + } + this.pendingMouseMove = undefined; + // Dispose addons for (const addon of this.addons) { addon.dispose(); @@ -667,8 +725,7 @@ export class Terminal implements ITerminalCore { this.renderer!.render(this.wasmTerm!, false, this.viewportY, this); // Note: onRender event is intentionally not fired in the render loop - // to avoid performance issues. It will be added in Phase 3 with - // proper dirty tracking. For now, consumers can use requestAnimationFrame + // to avoid performance issues. For now, consumers can use requestAnimationFrame // if they need frame-by-frame updates. this.animationFrameId = requestAnimationFrame(loop); @@ -723,9 +780,18 @@ export class Terminal implements ITerminalCore { this.canvas = undefined; } - // Remove wheel event listener + // Remove event listeners if (this.element) { this.element.removeEventListener('wheel', this.handleWheel); + this.element.removeEventListener('mousemove', this.handleMouseMove); + this.element.removeEventListener('mouseleave', this.handleMouseLeave); + this.element.removeEventListener('click', this.handleClick); + } + + // Dispose link detector + if (this.linkDetector) { + this.linkDetector.dispose(); + this.linkDetector = undefined; } // Free WASM terminal @@ -752,6 +818,196 @@ export class Terminal implements ITerminalCore { } } + /** + * Handle mouse move for link hover detection + * Throttled to avoid blocking scroll events + */ + private handleMouseMove = (e: MouseEvent): void => { + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Throttle to ~60fps (16ms) to avoid blocking scroll/other events + if (this.mouseMoveThrottleTimeout) { + this.pendingMouseMove = e; + return; + } + + this.processMouseMove(e); + + this.mouseMoveThrottleTimeout = window.setTimeout(() => { + this.mouseMoveThrottleTimeout = undefined; + if (this.pendingMouseMove) { + const pending = this.pendingMouseMove; + this.pendingMouseMove = undefined; + this.processMouseMove(pending); + } + }, 16); + }; + + /** + * Process mouse move for link detection (internal, called by throttled handler) + */ + private processMouseMove(e: MouseEvent): void { + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Convert mouse coordinates to terminal cell position + const rect = this.canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); + const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + + // Get hyperlink_id directly from the cell at this position + // Must account for viewportY (scrollback position) + const viewportRow = y; // Row in the viewport (0 to rows-1) + let hyperlinkId = 0; + + // When scrolled, fetch from scrollback or screen based on position + let line: GhosttyCell[] | null = null; + if (this.viewportY > 0) { + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (viewportRow < this.viewportY) { + // Mouse is over scrollback content + const scrollbackOffset = scrollbackLength - this.viewportY + viewportRow; + line = this.wasmTerm.getScrollbackLine(scrollbackOffset); + } else { + // Mouse is over screen content (bottom part of viewport) + const screenRow = viewportRow - this.viewportY; + line = this.wasmTerm.getLine(screenRow); + } + } else { + // At bottom - just use screen buffer + line = this.wasmTerm.getLine(viewportRow); + } + + if (line && x >= 0 && x < line.length) { + hyperlinkId = line[x].hyperlink_id; + } + + // Update renderer for underline rendering + const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; + if (hyperlinkId !== previousHyperlinkId) { + this.renderer.setHoveredHyperlinkId(hyperlinkId); + + // The 60fps render loop will pick up the change automatically + // No need to force a render - this keeps performance smooth + } + + // Check if there's a link at this position (for click handling and cursor) + // Buffer API expects absolute buffer coordinates (including scrollback) + // When scrolled, we need to adjust the buffer row based on viewportY + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let bufferRow: number; + + if (this.viewportY > 0) { + // When scrolled, the buffer row depends on where in the viewport we are + if (viewportRow < this.viewportY) { + // Mouse is over scrollback content + bufferRow = scrollbackLength - this.viewportY + viewportRow; + } else { + // Mouse is over screen content (bottom part of viewport) + const screenRow = viewportRow - this.viewportY; + bufferRow = scrollbackLength + screenRow; + } + } else { + // At bottom - buffer row is scrollback + screen row + bufferRow = scrollbackLength + viewportRow; + } + + // Make async call non-blocking - don't await + this.linkDetector + .getLinkAt(x, bufferRow) + .then((link) => { + // Update hover state for cursor changes and click handling + if (link !== this.currentHoveredLink) { + // Notify old link we're leaving + this.currentHoveredLink?.hover?.(false); + + // Update current link + this.currentHoveredLink = link; + + // Notify new link we're entering + link?.hover?.(true); + + // Update cursor style + if (this.element) { + this.element.style.cursor = link ? 'pointer' : 'text'; + } + } + }) + .catch((err) => { + console.warn('Link detection error:', err); + }); + } + + /** + * Handle mouse leave to clear link hover + */ + private handleMouseLeave = (): void => { + // Clear hyperlink underline + if (this.renderer && this.wasmTerm) { + const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; + if (previousHyperlinkId > 0) { + this.renderer.setHoveredHyperlinkId(0); + + // The 60fps render loop will pick up the change automatically + } + } + + if (this.currentHoveredLink) { + // Notify link we're leaving + this.currentHoveredLink.hover?.(false); + + // Clear hovered link + this.currentHoveredLink = undefined; + + // Reset cursor + if (this.element) { + this.element.style.cursor = 'text'; + } + } + }; + + /** + * Handle mouse click for link activation + */ + private handleClick = async (e: MouseEvent): Promise => { + // For more reliable clicking, detect the link at click time + // rather than relying on cached hover state (avoids async races) + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Get click position + const rect = this.canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); + const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + + // Calculate buffer row (same logic as processMouseMove) + const viewportRow = y; + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let bufferRow: number; + + if (this.viewportY > 0) { + if (viewportRow < this.viewportY) { + bufferRow = scrollbackLength - this.viewportY + viewportRow; + } else { + const screenRow = viewportRow - this.viewportY; + bufferRow = scrollbackLength + screenRow; + } + } else { + bufferRow = scrollbackLength + viewportRow; + } + + // Get the link at this position + const link = await this.linkDetector.getLinkAt(x, bufferRow); + + if (link) { + // Activate link + link.activate(e); + + // Prevent default action if modifier key held + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + } + } + }; + /** * Handle wheel events for scrolling (Phase 2) */ diff --git a/lib/types.ts b/lib/types.ts index 2573e7b..bf08393 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -444,3 +444,159 @@ export interface TerminalConfig { fg_color: RGB; // Default foreground color bg_color: RGB; // Default background color } + +// ============================================================================ +// Link Detection System +// ============================================================================ + +/** + * Represents a coordinate in the terminal buffer + */ +export interface IBufferCellPosition { + x: number; // Column (0-based) + y: number; // Row (0-based, absolute buffer position) +} + +/** + * Represents a range in the terminal buffer + * Can span multiple lines for wrapped links + */ +export interface IBufferRange { + start: IBufferCellPosition; + end: IBufferCellPosition; // Inclusive +} + +/** + * Represents a detected link in the terminal + */ +export interface ILink { + /** The URL or text of the link */ + text: string; + + /** The range of the link in the buffer (may span multiple lines) */ + range: IBufferRange; + + /** Called when the link is activated (clicked with modifier) */ + activate(event: MouseEvent): void; + + /** Optional: called when mouse enters/leaves the link */ + hover?(isHovered: boolean): void; + + /** Optional: called to clean up resources */ + dispose?(): void; +} + +/** + * Provides link detection for a specific type of link + * Examples: OSC 8 hyperlinks, URL regex detection + */ +export interface ILinkProvider { + /** + * Provide links for a given row + * @param y Absolute row in buffer (0-based) + * @param callback Called with detected links (or undefined if none) + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; + + /** Optional: called when terminal is disposed */ + dispose?(): void; +} + +/** + * Simplified buffer line interface for link providers + */ +export interface IBufferLine { + /** Number of cells in this line */ + length: number; + + /** Get cell at position */ + getCell(x: number): IBufferCell; + + /** Get text content of the line */ + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; +} + +/** + * Simplified buffer cell interface for link providers + */ +export interface IBufferCell { + /** Get the character codepoint */ + getCodepoint(): number; + + /** Get the hyperlink ID (0 = no link) */ + getHyperlinkId(): number; + + /** Get the width of the character (1 or 2 for wide chars) */ + getWidth(): number; + + /** Check if cell has specific flags */ + isBold(): boolean; + isItalic(): boolean; + isDim(): boolean; +} + +/** + * Simplified terminal buffer interface for link providers + */ +export interface IBuffer { + /** Number of rows in the buffer (viewport + scrollback) */ + length: number; + + /** Get line at absolute buffer position */ + getLine(y: number): IBufferLine; +} + +/** + * Terminal buffer manager (active vs alternate screen) + */ +export interface IBufferManager { + /** Currently active buffer */ + active: IBuffer; + + /** Normal screen buffer */ + normal: IBuffer; + + /** Alternate screen buffer (for fullscreen apps) */ + alternate: IBuffer; +} + +/** + * Event system interface (xterm.js compatible) + */ +export type IEvent = (listener: (data: T) => void) => IDisposable; + +export interface IDisposable { + dispose(): void; +} + +/** + * Event emitter for custom events + */ +export class EventEmitter { + private listeners: Array<(data: T) => void> = []; + + /** Subscribe to events */ + public readonly event: IEvent = (listener: (data: T) => void): IDisposable => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index !== -1) { + this.listeners.splice(index, 1); + } + }, + }; + }; + + /** Emit an event to all listeners */ + public fire(data: T): void { + for (const listener of this.listeners) { + listener(data); + } + } + + /** Remove all listeners */ + public dispose(): void { + this.listeners = []; + } +}