diff --git a/lib/input-handler.ts b/lib/input-handler.ts index ec196f9..83d6f3f 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; @@ -168,6 +182,7 @@ 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; @@ -175,8 +190,13 @@ export class InputHandler { 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 private lastKeyDownData: string | null = null; private lastKeyDownTime = 0; private lastPasteData: string | null = null; @@ -198,6 +218,8 @@ 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 inputElement - Optional input element for beforeinput events + * @param mouseConfig - Optional mouse tracking configuration */ constructor( ghostty: Ghostty, @@ -208,7 +230,8 @@ export class InputHandler { customKeyEventHandler?: (event: KeyboardEvent) => boolean, getMode?: (mode: number) => boolean, onCopy?: () => boolean, - inputElement?: HTMLElement + inputElement?: HTMLElement, + mouseConfig?: MouseTrackingConfig ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; @@ -219,6 +242,7 @@ export class InputHandler { this.customKeyEventHandler = customKeyEventHandler; this.getModeCallback = getMode; this.onCopyCallback = onCopy; + this.mouseConfig = mouseConfig; // Attach event listeners this.attach(); @@ -272,6 +296,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 }); } /** @@ -679,6 +716,196 @@ 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(); + } + /** * Emit paste data with bracketed paste support */ @@ -847,6 +1074,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 c97c6ec..deef77e 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, @@ -425,6 +425,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!, @@ -454,7 +471,8 @@ export class Terminal implements ITerminalCore { // Handle Cmd+C copy - returns true if there was a selection to copy return this.copySelection(); }, - this.textarea + this.textarea, + mouseConfig ); // Create selection manager (pass textarea for context menu positioning)