From 899e8d18ab8e01e6e59659be404872e39ae20895 Mon Sep 17 00:00:00 2001 From: tobilg Date: Sun, 7 Dec 2025 14:27:06 +0100 Subject: [PATCH 1/2] Fixes for copying on Safari & Firefox --- lib/input-handler.ts | 16 ++++++-- lib/selection-manager.ts | 83 ++++++++++++++++++++++++++++++++++------ lib/terminal.ts | 12 ++++++ 3 files changed, 96 insertions(+), 15 deletions(-) diff --git a/lib/input-handler.ts b/lib/input-handler.ts index 6e3bd5f..a6acb3e 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -166,6 +166,7 @@ export class InputHandler { private onKeyCallback?: (keyEvent: IKeyEvent) => void; private customKeyEventHandler?: (event: KeyboardEvent) => boolean; private getModeCallback?: (mode: number) => boolean; + private onCopyCallback?: () => boolean; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; private pasteListener: ((e: ClipboardEvent) => void) | null = null; @@ -184,6 +185,7 @@ 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) */ constructor( ghostty: Ghostty, @@ -192,7 +194,8 @@ export class InputHandler { onBell: () => void, onKey?: (keyEvent: IKeyEvent) => void, customKeyEventHandler?: (event: KeyboardEvent) => boolean, - getMode?: (mode: number) => boolean + getMode?: (mode: number) => boolean, + onCopy?: () => boolean ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; @@ -201,6 +204,7 @@ export class InputHandler { this.onKeyCallback = onKey; this.customKeyEventHandler = customKeyEventHandler; this.getModeCallback = getMode; + this.onCopyCallback = onCopy; // Attach event listeners this.attach(); @@ -327,11 +331,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; } 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 3c2fc2f..15e82de 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -445,6 +445,10 @@ 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(); } ); @@ -755,6 +759,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 */ From 5e8e189964fbd8039c1698830860375fe3379162 Mon Sep 17 00:00:00 2001 From: kofany Date: Sat, 20 Dec 2025 08:52:54 +0100 Subject: [PATCH 2/2] feat: add mouse tracking support for terminal applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement mouse event handling to support terminal applications that use mouse input (e.g., mc, htop, vim with mouse mode). Changes: - Add MouseTrackingConfig interface for mouse configuration - Implement SGR (1006) and X10 mouse encoding formats - Handle mousedown, mouseup, mousemove, and wheel events - Convert pixel coordinates to terminal cell coordinates - Support modifier keys (Shift, Ctrl, Meta) in mouse events - Respect terminal mouse tracking modes (1000, 1002, 1003, 1006) The implementation checks if the terminal application has enabled mouse tracking via DECSET sequences before sending mouse events, ensuring compatibility with both mouse-aware and traditional terminal applications. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/input-handler.ts | 251 ++++++++++++++++++++++++++++++++++++++++++- lib/terminal.ts | 22 +++- 2 files changed, 270 insertions(+), 3 deletions(-) diff --git a/lib/input-handler.ts b/lib/input-handler.ts index d362549..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; @@ -167,14 +181,20 @@ export class InputHandler { 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 @@ -186,6 +206,7 @@ export class InputHandler { * @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, @@ -195,7 +216,8 @@ export class InputHandler { onKey?: (keyEvent: IKeyEvent) => void, customKeyEventHandler?: (event: KeyboardEvent) => boolean, getMode?: (mode: number) => boolean, - onCopy?: () => boolean + onCopy?: () => boolean, + mouseConfig?: MouseTrackingConfig ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; @@ -205,6 +227,7 @@ export class InputHandler { this.customKeyEventHandler = customKeyEventHandler; this.getModeCallback = getMode; this.onCopyCallback = onCopy; + this.mouseConfig = mouseConfig; // Attach event listeners this.attach(); @@ -250,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 }); } /** @@ -570,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 */ @@ -606,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/terminal.ts b/lib/terminal.ts index 9567222..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!, @@ -449,7 +466,8 @@ export class Terminal implements ITerminalCore { () => { // 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)