diff --git a/lib/input-handler.ts b/lib/input-handler.ts index d5bf827..4d5157b 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -158,6 +158,20 @@ const KEY_MAP: Record = { * Attaches keyboard event listeners to a container and converts * keyboard events to terminal input data */ +/** + * Mouse tracking configuration + */ +export interface MouseTrackingConfig { + /** Check if any mouse tracking mode is enabled */ + hasMouseTracking: () => boolean; + /** Check if SGR extended mouse mode is enabled (mode 1006) */ + hasSgrMouseMode: () => boolean; + /** Get cell dimensions for pixel to cell conversion */ + getCellDimensions: () => { width: number; height: number }; + /** Get canvas/container offset for accurate position calculation */ + getCanvasOffset: () => { left: number; top: number }; +} + export class InputHandler { private encoder: KeyEncoder; private container: HTMLElement; @@ -166,14 +180,21 @@ export class InputHandler { private onKeyCallback?: (keyEvent: IKeyEvent) => void; private customKeyEventHandler?: (event: KeyboardEvent) => boolean; private getModeCallback?: (mode: number) => boolean; + private onCopyCallback?: () => boolean; + private mouseConfig?: MouseTrackingConfig; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; private pasteListener: ((e: ClipboardEvent) => void) | null = null; private compositionStartListener: ((e: CompositionEvent) => void) | null = null; private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null; private compositionEndListener: ((e: CompositionEvent) => void) | null = null; + private mousedownListener: ((e: MouseEvent) => void) | null = null; + private mouseupListener: ((e: MouseEvent) => void) | null = null; + private mousemoveListener: ((e: MouseEvent) => void) | null = null; + private wheelListener: ((e: WheelEvent) => void) | null = null; private isComposing = false; private isDisposed = false; + private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting /** * Create a new InputHandler @@ -184,6 +205,8 @@ export class InputHandler { * @param onKey - Optional callback for raw key events * @param customKeyEventHandler - Optional custom key event handler * @param getMode - Optional callback to query terminal mode state (for application cursor mode) + * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection) + * @param mouseConfig - Optional mouse tracking configuration */ constructor( ghostty: Ghostty, @@ -192,7 +215,9 @@ export class InputHandler { onBell: () => void, onKey?: (keyEvent: IKeyEvent) => void, customKeyEventHandler?: (event: KeyboardEvent) => boolean, - getMode?: (mode: number) => boolean + getMode?: (mode: number) => boolean, + onCopy?: () => boolean, + mouseConfig?: MouseTrackingConfig ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; @@ -201,6 +226,8 @@ export class InputHandler { this.onKeyCallback = onKey; this.customKeyEventHandler = customKeyEventHandler; this.getModeCallback = getMode; + this.onCopyCallback = onCopy; + this.mouseConfig = mouseConfig; // Attach event listeners this.attach(); @@ -246,6 +273,19 @@ export class InputHandler { this.compositionEndListener = this.handleCompositionEnd.bind(this); this.container.addEventListener('compositionend', this.compositionEndListener); + + // Mouse event listeners (for terminal mouse tracking) + this.mousedownListener = this.handleMouseDown.bind(this); + this.container.addEventListener('mousedown', this.mousedownListener); + + this.mouseupListener = this.handleMouseUp.bind(this); + this.container.addEventListener('mouseup', this.mouseupListener); + + this.mousemoveListener = this.handleMouseMove.bind(this); + this.container.addEventListener('mousemove', this.mousemoveListener); + + this.wheelListener = this.handleWheel.bind(this); + this.container.addEventListener('wheel', this.wheelListener, { passive: false }); } /** @@ -327,11 +367,15 @@ export class InputHandler { return; } - // Allow Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) - // SelectionManager handles the actual copying + // Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) // Note: Ctrl+C on all platforms sends interrupt signal (0x03) if (event.metaKey && event.code === 'KeyC') { - // Let browser/SelectionManager handle copy + // Try to copy selection via callback + // If there's a selection and copy succeeds, prevent default + // If no selection, let it fall through (browser may have other text selected) + if (this.onCopyCallback && this.onCopyCallback()) { + event.preventDefault(); + } return; } @@ -562,6 +606,199 @@ export class InputHandler { } } + // ========================================================================== + // Mouse Event Handling (for terminal mouse tracking) + // ========================================================================== + + /** + * Convert pixel coordinates to terminal cell coordinates + */ + private pixelToCell(event: MouseEvent): { col: number; row: number } | null { + if (!this.mouseConfig) return null; + + const dims = this.mouseConfig.getCellDimensions(); + const offset = this.mouseConfig.getCanvasOffset(); + + if (dims.width <= 0 || dims.height <= 0) return null; + + const x = event.clientX - offset.left; + const y = event.clientY - offset.top; + + // Convert to 1-based cell coordinates (terminal uses 1-based) + const col = Math.floor(x / dims.width) + 1; + const row = Math.floor(y / dims.height) + 1; + + // Clamp to valid range (at least 1) + return { + col: Math.max(1, col), + row: Math.max(1, row), + }; + } + + /** + * Get modifier flags for mouse event + */ + private getMouseModifiers(event: MouseEvent): number { + let mods = 0; + if (event.shiftKey) mods |= 4; + if (event.metaKey) mods |= 8; // Meta (Cmd on Mac) + if (event.ctrlKey) mods |= 16; + return mods; + } + + /** + * Encode mouse event as SGR sequence + * SGR format: \x1b[ + */ + private encodeMouseX10( + button: number, + col: number, + row: number, + modifiers: number + ): string { + // X10 format adds 32 to all values and encodes as characters + // Button encoding: 0=left, 1=middle, 2=right, 3=release + const btn = button + modifiers + 32; + const colChar = String.fromCharCode(Math.min(col + 32, 255)); + const rowChar = String.fromCharCode(Math.min(row + 32, 255)); + return `\x1b[M${String.fromCharCode(btn)}${colChar}${rowChar}`; + } + + /** + * Send mouse event to terminal + */ + private sendMouseEvent( + button: number, + col: number, + row: number, + isRelease: boolean, + event: MouseEvent + ): void { + const modifiers = this.getMouseModifiers(event); + + // Check if SGR extended mode is enabled (mode 1006) + const useSGR = this.mouseConfig?.hasSgrMouseMode?.() ?? true; + + let sequence: string; + if (useSGR) { + sequence = this.encodeMouseSGR(button, col, row, isRelease, modifiers); + } else { + // X10/normal mode doesn't support release events directly + // Button 3 means release in X10 mode + const x10Button = isRelease ? 3 : button; + sequence = this.encodeMouseX10(x10Button, col, row, modifiers); + } + + this.onDataCallback(sequence); + } + + /** + * Handle mousedown event + */ + private handleMouseDown(event: MouseEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + // Map browser button to terminal button + // event.button: 0=left, 1=middle, 2=right + // Terminal: 0=left, 1=middle, 2=right + const button = event.button; + + // Track pressed buttons for motion events + this.mouseButtonsPressed |= 1 << button; + + this.sendMouseEvent(button, cell.col, cell.row, false, event); + + // Don't prevent default - let SelectionManager handle selection + // Only prevent if we actually handled the event + // event.preventDefault(); + } + + /** + * Handle mouseup event + */ + private handleMouseUp(event: MouseEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + const button = event.button; + + // Clear pressed button + this.mouseButtonsPressed &= ~(1 << button); + + this.sendMouseEvent(button, cell.col, cell.row, true, event); + } + + /** + * Handle mousemove event + */ + private handleMouseMove(event: MouseEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + // Check if button motion mode or any-event tracking is enabled + // Mode 1002 = button motion, Mode 1003 = any motion + const hasButtonMotion = this.getModeCallback?.(1002) ?? false; + const hasAnyMotion = this.getModeCallback?.(1003) ?? false; + + if (!hasButtonMotion && !hasAnyMotion) return; + + // In button motion mode, only report if a button is pressed + if (hasButtonMotion && !hasAnyMotion && this.mouseButtonsPressed === 0) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + // Determine which button to report (or 32 for motion with no button) + let button = 32; // Motion flag + if (this.mouseButtonsPressed & 1) button += 0; // Left + else if (this.mouseButtonsPressed & 2) button += 1; // Middle + else if (this.mouseButtonsPressed & 4) button += 2; // Right + + this.sendMouseEvent(button, cell.col, cell.row, false, event); + } + + /** + * Handle wheel event (scroll) + */ + private handleWheel(event: WheelEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + // Wheel events: button 64 = scroll up, button 65 = scroll down + const button = event.deltaY < 0 ? 64 : 65; + + this.sendMouseEvent(button, cell.col, cell.row, false, event); + + // Prevent default scrolling when mouse tracking is active + event.preventDefault(); + } + /** * Dispose the InputHandler and remove event listeners */ @@ -598,6 +835,26 @@ export class InputHandler { this.compositionEndListener = null; } + if (this.mousedownListener) { + this.container.removeEventListener('mousedown', this.mousedownListener); + this.mousedownListener = null; + } + + if (this.mouseupListener) { + this.container.removeEventListener('mouseup', this.mouseupListener); + this.mouseupListener = null; + } + + if (this.mousemoveListener) { + this.container.removeEventListener('mousemove', this.mousemoveListener); + this.mousemoveListener = null; + } + + if (this.wheelListener) { + this.container.removeEventListener('wheel', this.wheelListener); + this.wheelListener = null; + } + this.isDisposed = true; } diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index b54fc84..d88c66d 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -216,6 +216,21 @@ export class SelectionManager { ); } + /** + * Copy the current selection to clipboard + * @returns true if there was text to copy, false otherwise + */ + copySelection(): boolean { + if (!this.hasSelection()) return false; + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + return true; + } + return false; + } + /** * Clear the selection */ @@ -841,26 +856,72 @@ export class SelectionManager { /** * Copy text to clipboard + * + * Strategy (modern APIs first): + * 1. Try ClipboardItem API (works in Safari and modern browsers) + * - Safari requires the ClipboardItem to be created synchronously within user gesture + * 2. Try navigator.clipboard.writeText (modern async API, may fail in Safari) + * 3. Fall back to execCommand (legacy, for older browsers) */ - private async copyToClipboard(text: string): Promise { - // First try: modern async clipboard API - if (navigator.clipboard && navigator.clipboard.writeText) { + private copyToClipboard(text: string): void { + // First try: ClipboardItem API (modern, Safari-compatible) + // Safari allows this because we create the ClipboardItem synchronously + // within the user gesture, even though the write is async + if (navigator.clipboard && typeof ClipboardItem !== 'undefined') { try { - await navigator.clipboard.writeText(text); + const blob = new Blob([text], { type: 'text/plain' }); + const clipboardItem = new ClipboardItem({ + 'text/plain': blob, + }); + navigator.clipboard.write([clipboardItem]).catch((err) => { + console.warn('ClipboardItem write failed, trying writeText:', err); + // Try writeText as fallback + this.copyWithWriteText(text); + }); return; } catch (err) { - // Clipboard API failed (common in non-HTTPS or non-focused contexts) - // Fall through to legacy method + // ClipboardItem not supported or failed, fall through } } - // Second try: legacy execCommand method via textarea + // Second try: basic async writeText (works in Chrome, may fail in Safari) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch((err) => { + console.warn('Clipboard writeText failed, trying execCommand:', err); + // Fall back to execCommand + this.copyWithExecCommand(text); + }); + return; + } + + // Third try: legacy execCommand fallback + this.copyWithExecCommand(text); + } + + /** + * Copy using navigator.clipboard.writeText + */ + private copyWithWriteText(text: string): void { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch((err) => { + console.warn('Clipboard writeText failed, trying execCommand:', err); + this.copyWithExecCommand(text); + }); + } else { + this.copyWithExecCommand(text); + } + } + + /** + * Copy using legacy execCommand (fallback for older browsers) + */ + private copyWithExecCommand(text: string): void { const previouslyFocused = document.activeElement as HTMLElement; try { // Position textarea offscreen but in a way that allows selection const textarea = this.textarea; textarea.value = text; - textarea.style.position = 'fixed'; // Avoid scrolling to bottom + textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '0'; textarea.style.width = '1px'; @@ -880,11 +941,11 @@ export class SelectionManager { } if (!success) { - console.error('❌ execCommand copy failed'); + console.warn('execCommand copy failed'); } } catch (err) { - console.error('❌ Fallback copy failed:', err); - // Still try to restore focus even on error + console.warn('execCommand copy threw:', err); + // Restore focus on error if (previouslyFocused) { previouslyFocused.focus(); } diff --git a/lib/terminal.ts b/lib/terminal.ts index dda74e9..bba9c55 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -19,7 +19,7 @@ import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; import { getGhostty } from './index'; -import { InputHandler } from './input-handler'; +import { InputHandler, type MouseTrackingConfig } from './input-handler'; import type { IBufferNamespace, IBufferRange, @@ -421,6 +421,23 @@ export class Terminal implements ITerminalCore { // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) this.renderer.resize(this.cols, this.rows); + // Create mouse tracking configuration + const canvas = this.canvas; + const renderer = this.renderer; + const wasmTerm = this.wasmTerm; + const mouseConfig: MouseTrackingConfig = { + hasMouseTracking: () => wasmTerm?.hasMouseTracking() ?? false, + hasSgrMouseMode: () => wasmTerm?.getMode(1006, false) ?? true, // SGR extended mode + getCellDimensions: () => ({ + width: renderer.charWidth, + height: renderer.charHeight, + }), + getCanvasOffset: () => { + const rect = canvas.getBoundingClientRect(); + return { left: rect.left, top: rect.top }; + }, + }; + // Create input handler this.inputHandler = new InputHandler( this.ghostty!, @@ -445,7 +462,12 @@ export class Terminal implements ITerminalCore { (mode: number) => { // Query terminal mode state (e.g., mode 1 for application cursor mode) return this.wasmTerm?.getMode(mode, false) ?? false; - } + }, + () => { + // Handle Cmd+C copy - returns true if there was a selection to copy + return this.copySelection(); + }, + mouseConfig ); // Create selection manager (pass textarea for context menu positioning) @@ -755,6 +777,14 @@ export class Terminal implements ITerminalCore { this.selectionManager?.clearSelection(); } + /** + * Copy the current selection to clipboard + * @returns true if there was text to copy, false otherwise + */ + public copySelection(): boolean { + return this.selectionManager?.copySelection() || false; + } + /** * Select all text in the terminal */