@@ -158,6 +158,20 @@ const KEY_MAP: Record<string, Key> = {
158158 * Attaches keyboard event listeners to a container and converts
159159 * keyboard events to terminal input data
160160 */
161+ /**
162+ * Mouse tracking configuration
163+ */
164+ export interface MouseTrackingConfig {
165+ /** Check if any mouse tracking mode is enabled */
166+ hasMouseTracking : ( ) => boolean ;
167+ /** Check if SGR extended mouse mode is enabled (mode 1006) */
168+ hasSgrMouseMode : ( ) => boolean ;
169+ /** Get cell dimensions for pixel to cell conversion */
170+ getCellDimensions : ( ) => { width : number ; height : number } ;
171+ /** Get canvas/container offset for accurate position calculation */
172+ getCanvasOffset : ( ) => { left : number ; top : number } ;
173+ }
174+
161175export class InputHandler {
162176 private encoder : KeyEncoder ;
163177 private container : HTMLElement ;
@@ -167,14 +181,20 @@ export class InputHandler {
167181 private customKeyEventHandler ?: ( event : KeyboardEvent ) => boolean ;
168182 private getModeCallback ?: ( mode : number ) => boolean ;
169183 private onCopyCallback ?: ( ) => boolean ;
184+ private mouseConfig ?: MouseTrackingConfig ;
170185 private keydownListener : ( ( e : KeyboardEvent ) => void ) | null = null ;
171186 private keypressListener : ( ( e : KeyboardEvent ) => void ) | null = null ;
172187 private pasteListener : ( ( e : ClipboardEvent ) => void ) | null = null ;
173188 private compositionStartListener : ( ( e : CompositionEvent ) => void ) | null = null ;
174189 private compositionUpdateListener : ( ( e : CompositionEvent ) => void ) | null = null ;
175190 private compositionEndListener : ( ( e : CompositionEvent ) => void ) | null = null ;
191+ private mousedownListener : ( ( e : MouseEvent ) => void ) | null = null ;
192+ private mouseupListener : ( ( e : MouseEvent ) => void ) | null = null ;
193+ private mousemoveListener : ( ( e : MouseEvent ) => void ) | null = null ;
194+ private wheelListener : ( ( e : WheelEvent ) => void ) | null = null ;
176195 private isComposing = false ;
177196 private isDisposed = false ;
197+ private mouseButtonsPressed = 0 ; // Track which buttons are pressed for motion reporting
178198
179199 /**
180200 * Create a new InputHandler
@@ -186,6 +206,7 @@ export class InputHandler {
186206 * @param customKeyEventHandler - Optional custom key event handler
187207 * @param getMode - Optional callback to query terminal mode state (for application cursor mode)
188208 * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection)
209+ * @param mouseConfig - Optional mouse tracking configuration
189210 */
190211 constructor (
191212 ghostty : Ghostty ,
@@ -195,7 +216,8 @@ export class InputHandler {
195216 onKey ?: ( keyEvent : IKeyEvent ) => void ,
196217 customKeyEventHandler ?: ( event : KeyboardEvent ) => boolean ,
197218 getMode ?: ( mode : number ) => boolean ,
198- onCopy ?: ( ) => boolean
219+ onCopy ?: ( ) => boolean ,
220+ mouseConfig ?: MouseTrackingConfig
199221 ) {
200222 this . encoder = ghostty . createKeyEncoder ( ) ;
201223 this . container = container ;
@@ -205,6 +227,7 @@ export class InputHandler {
205227 this . customKeyEventHandler = customKeyEventHandler ;
206228 this . getModeCallback = getMode ;
207229 this . onCopyCallback = onCopy ;
230+ this . mouseConfig = mouseConfig ;
208231
209232 // Attach event listeners
210233 this . attach ( ) ;
@@ -250,6 +273,19 @@ export class InputHandler {
250273
251274 this . compositionEndListener = this . handleCompositionEnd . bind ( this ) ;
252275 this . container . addEventListener ( 'compositionend' , this . compositionEndListener ) ;
276+
277+ // Mouse event listeners (for terminal mouse tracking)
278+ this . mousedownListener = this . handleMouseDown . bind ( this ) ;
279+ this . container . addEventListener ( 'mousedown' , this . mousedownListener ) ;
280+
281+ this . mouseupListener = this . handleMouseUp . bind ( this ) ;
282+ this . container . addEventListener ( 'mouseup' , this . mouseupListener ) ;
283+
284+ this . mousemoveListener = this . handleMouseMove . bind ( this ) ;
285+ this . container . addEventListener ( 'mousemove' , this . mousemoveListener ) ;
286+
287+ this . wheelListener = this . handleWheel . bind ( this ) ;
288+ this . container . addEventListener ( 'wheel' , this . wheelListener , { passive : false } ) ;
253289 }
254290
255291 /**
@@ -570,6 +606,199 @@ export class InputHandler {
570606 }
571607 }
572608
609+ // ==========================================================================
610+ // Mouse Event Handling (for terminal mouse tracking)
611+ // ==========================================================================
612+
613+ /**
614+ * Convert pixel coordinates to terminal cell coordinates
615+ */
616+ private pixelToCell ( event : MouseEvent ) : { col : number ; row : number } | null {
617+ if ( ! this . mouseConfig ) return null ;
618+
619+ const dims = this . mouseConfig . getCellDimensions ( ) ;
620+ const offset = this . mouseConfig . getCanvasOffset ( ) ;
621+
622+ if ( dims . width <= 0 || dims . height <= 0 ) return null ;
623+
624+ const x = event . clientX - offset . left ;
625+ const y = event . clientY - offset . top ;
626+
627+ // Convert to 1-based cell coordinates (terminal uses 1-based)
628+ const col = Math . floor ( x / dims . width ) + 1 ;
629+ const row = Math . floor ( y / dims . height ) + 1 ;
630+
631+ // Clamp to valid range (at least 1)
632+ return {
633+ col : Math . max ( 1 , col ) ,
634+ row : Math . max ( 1 , row ) ,
635+ } ;
636+ }
637+
638+ /**
639+ * Get modifier flags for mouse event
640+ */
641+ private getMouseModifiers ( event : MouseEvent ) : number {
642+ let mods = 0 ;
643+ if ( event . shiftKey ) mods |= 4 ;
644+ if ( event . metaKey ) mods |= 8 ; // Meta (Cmd on Mac)
645+ if ( event . ctrlKey ) mods |= 16 ;
646+ return mods ;
647+ }
648+
649+ /**
650+ * Encode mouse event as SGR sequence
651+ * SGR format: \x1b[<Btn;Col;RowM (press/motion) or \x1b[<Btn;Col;Rowm (release)
652+ */
653+ private encodeMouseSGR (
654+ button : number ,
655+ col : number ,
656+ row : number ,
657+ isRelease : boolean ,
658+ modifiers : number
659+ ) : string {
660+ const btn = button + modifiers ;
661+ const suffix = isRelease ? 'm' : 'M' ;
662+ return `\x1b[<${ btn } ;${ col } ;${ row } ${ suffix } ` ;
663+ }
664+
665+ /**
666+ * Encode mouse event as X10/normal sequence (legacy format)
667+ * Format: \x1b[M<Btn+32><Col+32><Row+32>
668+ */
669+ private encodeMouseX10 (
670+ button : number ,
671+ col : number ,
672+ row : number ,
673+ modifiers : number
674+ ) : string {
675+ // X10 format adds 32 to all values and encodes as characters
676+ // Button encoding: 0=left, 1=middle, 2=right, 3=release
677+ const btn = button + modifiers + 32 ;
678+ const colChar = String . fromCharCode ( Math . min ( col + 32 , 255 ) ) ;
679+ const rowChar = String . fromCharCode ( Math . min ( row + 32 , 255 ) ) ;
680+ return `\x1b[M${ String . fromCharCode ( btn ) } ${ colChar } ${ rowChar } ` ;
681+ }
682+
683+ /**
684+ * Send mouse event to terminal
685+ */
686+ private sendMouseEvent (
687+ button : number ,
688+ col : number ,
689+ row : number ,
690+ isRelease : boolean ,
691+ event : MouseEvent
692+ ) : void {
693+ const modifiers = this . getMouseModifiers ( event ) ;
694+
695+ // Check if SGR extended mode is enabled (mode 1006)
696+ const useSGR = this . mouseConfig ?. hasSgrMouseMode ?.( ) ?? true ;
697+
698+ let sequence : string ;
699+ if ( useSGR ) {
700+ sequence = this . encodeMouseSGR ( button , col , row , isRelease , modifiers ) ;
701+ } else {
702+ // X10/normal mode doesn't support release events directly
703+ // Button 3 means release in X10 mode
704+ const x10Button = isRelease ? 3 : button ;
705+ sequence = this . encodeMouseX10 ( x10Button , col , row , modifiers ) ;
706+ }
707+
708+ this . onDataCallback ( sequence ) ;
709+ }
710+
711+ /**
712+ * Handle mousedown event
713+ */
714+ private handleMouseDown ( event : MouseEvent ) : void {
715+ if ( this . isDisposed ) return ;
716+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
717+
718+ const cell = this . pixelToCell ( event ) ;
719+ if ( ! cell ) return ;
720+
721+ // Map browser button to terminal button
722+ // event.button: 0=left, 1=middle, 2=right
723+ // Terminal: 0=left, 1=middle, 2=right
724+ const button = event . button ;
725+
726+ // Track pressed buttons for motion events
727+ this . mouseButtonsPressed |= 1 << button ;
728+
729+ this . sendMouseEvent ( button , cell . col , cell . row , false , event ) ;
730+
731+ // Don't prevent default - let SelectionManager handle selection
732+ // Only prevent if we actually handled the event
733+ // event.preventDefault();
734+ }
735+
736+ /**
737+ * Handle mouseup event
738+ */
739+ private handleMouseUp ( event : MouseEvent ) : void {
740+ if ( this . isDisposed ) return ;
741+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
742+
743+ const cell = this . pixelToCell ( event ) ;
744+ if ( ! cell ) return ;
745+
746+ const button = event . button ;
747+
748+ // Clear pressed button
749+ this . mouseButtonsPressed &= ~ ( 1 << button ) ;
750+
751+ this . sendMouseEvent ( button , cell . col , cell . row , true , event ) ;
752+ }
753+
754+ /**
755+ * Handle mousemove event
756+ */
757+ private handleMouseMove ( event : MouseEvent ) : void {
758+ if ( this . isDisposed ) return ;
759+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
760+
761+ // Check if button motion mode or any-event tracking is enabled
762+ // Mode 1002 = button motion, Mode 1003 = any motion
763+ const hasButtonMotion = this . getModeCallback ?.( 1002 ) ?? false ;
764+ const hasAnyMotion = this . getModeCallback ?.( 1003 ) ?? false ;
765+
766+ if ( ! hasButtonMotion && ! hasAnyMotion ) return ;
767+
768+ // In button motion mode, only report if a button is pressed
769+ if ( hasButtonMotion && ! hasAnyMotion && this . mouseButtonsPressed === 0 ) return ;
770+
771+ const cell = this . pixelToCell ( event ) ;
772+ if ( ! cell ) return ;
773+
774+ // Determine which button to report (or 32 for motion with no button)
775+ let button = 32 ; // Motion flag
776+ if ( this . mouseButtonsPressed & 1 ) button += 0 ; // Left
777+ else if ( this . mouseButtonsPressed & 2 ) button += 1 ; // Middle
778+ else if ( this . mouseButtonsPressed & 4 ) button += 2 ; // Right
779+
780+ this . sendMouseEvent ( button , cell . col , cell . row , false , event ) ;
781+ }
782+
783+ /**
784+ * Handle wheel event (scroll)
785+ */
786+ private handleWheel ( event : WheelEvent ) : void {
787+ if ( this . isDisposed ) return ;
788+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
789+
790+ const cell = this . pixelToCell ( event ) ;
791+ if ( ! cell ) return ;
792+
793+ // Wheel events: button 64 = scroll up, button 65 = scroll down
794+ const button = event . deltaY < 0 ? 64 : 65 ;
795+
796+ this . sendMouseEvent ( button , cell . col , cell . row , false , event ) ;
797+
798+ // Prevent default scrolling when mouse tracking is active
799+ event . preventDefault ( ) ;
800+ }
801+
573802 /**
574803 * Dispose the InputHandler and remove event listeners
575804 */
@@ -606,6 +835,26 @@ export class InputHandler {
606835 this . compositionEndListener = null ;
607836 }
608837
838+ if ( this . mousedownListener ) {
839+ this . container . removeEventListener ( 'mousedown' , this . mousedownListener ) ;
840+ this . mousedownListener = null ;
841+ }
842+
843+ if ( this . mouseupListener ) {
844+ this . container . removeEventListener ( 'mouseup' , this . mouseupListener ) ;
845+ this . mouseupListener = null ;
846+ }
847+
848+ if ( this . mousemoveListener ) {
849+ this . container . removeEventListener ( 'mousemove' , this . mousemoveListener ) ;
850+ this . mousemoveListener = null ;
851+ }
852+
853+ if ( this . wheelListener ) {
854+ this . container . removeEventListener ( 'wheel' , this . wheelListener ) ;
855+ this . wheelListener = null ;
856+ }
857+
609858 this . isDisposed = true ;
610859 }
611860
0 commit comments