@@ -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 ;
@@ -166,14 +180,20 @@ export class InputHandler {
166180 private onKeyCallback ?: ( keyEvent : IKeyEvent ) => void ;
167181 private customKeyEventHandler ?: ( event : KeyboardEvent ) => boolean ;
168182 private getModeCallback ?: ( mode : number ) => boolean ;
183+ private mouseConfig ?: MouseTrackingConfig ;
169184 private keydownListener : ( ( e : KeyboardEvent ) => void ) | null = null ;
170185 private keypressListener : ( ( e : KeyboardEvent ) => void ) | null = null ;
171186 private pasteListener : ( ( e : ClipboardEvent ) => void ) | null = null ;
172187 private compositionStartListener : ( ( e : CompositionEvent ) => void ) | null = null ;
173188 private compositionUpdateListener : ( ( e : CompositionEvent ) => void ) | null = null ;
174189 private compositionEndListener : ( ( e : CompositionEvent ) => void ) | null = null ;
190+ private mousedownListener : ( ( e : MouseEvent ) => void ) | null = null ;
191+ private mouseupListener : ( ( e : MouseEvent ) => void ) | null = null ;
192+ private mousemoveListener : ( ( e : MouseEvent ) => void ) | null = null ;
193+ private wheelListener : ( ( e : WheelEvent ) => void ) | null = null ;
175194 private isComposing = false ;
176195 private isDisposed = false ;
196+ private mouseButtonsPressed = 0 ; // Track which buttons are pressed for motion reporting
177197
178198 /**
179199 * Create a new InputHandler
@@ -184,6 +204,7 @@ export class InputHandler {
184204 * @param onKey - Optional callback for raw key events
185205 * @param customKeyEventHandler - Optional custom key event handler
186206 * @param getMode - Optional callback to query terminal mode state (for application cursor mode)
207+ * @param mouseConfig - Optional mouse tracking configuration
187208 */
188209 constructor (
189210 ghostty : Ghostty ,
@@ -192,7 +213,8 @@ export class InputHandler {
192213 onBell : ( ) => void ,
193214 onKey ?: ( keyEvent : IKeyEvent ) => void ,
194215 customKeyEventHandler ?: ( event : KeyboardEvent ) => boolean ,
195- getMode ?: ( mode : number ) => boolean
216+ getMode ?: ( mode : number ) => boolean ,
217+ mouseConfig ?: MouseTrackingConfig
196218 ) {
197219 this . encoder = ghostty . createKeyEncoder ( ) ;
198220 this . container = container ;
@@ -201,6 +223,7 @@ export class InputHandler {
201223 this . onKeyCallback = onKey ;
202224 this . customKeyEventHandler = customKeyEventHandler ;
203225 this . getModeCallback = getMode ;
226+ this . mouseConfig = mouseConfig ;
204227
205228 // Attach event listeners
206229 this . attach ( ) ;
@@ -246,6 +269,19 @@ export class InputHandler {
246269
247270 this . compositionEndListener = this . handleCompositionEnd . bind ( this ) ;
248271 this . container . addEventListener ( 'compositionend' , this . compositionEndListener ) ;
272+
273+ // Mouse event listeners (for terminal mouse tracking)
274+ this . mousedownListener = this . handleMouseDown . bind ( this ) ;
275+ this . container . addEventListener ( 'mousedown' , this . mousedownListener ) ;
276+
277+ this . mouseupListener = this . handleMouseUp . bind ( this ) ;
278+ this . container . addEventListener ( 'mouseup' , this . mouseupListener ) ;
279+
280+ this . mousemoveListener = this . handleMouseMove . bind ( this ) ;
281+ this . container . addEventListener ( 'mousemove' , this . mousemoveListener ) ;
282+
283+ this . wheelListener = this . handleWheel . bind ( this ) ;
284+ this . container . addEventListener ( 'wheel' , this . wheelListener , { passive : false } ) ;
249285 }
250286
251287 /**
@@ -562,6 +598,199 @@ export class InputHandler {
562598 }
563599 }
564600
601+ // ==========================================================================
602+ // Mouse Event Handling (for terminal mouse tracking)
603+ // ==========================================================================
604+
605+ /**
606+ * Convert pixel coordinates to terminal cell coordinates
607+ */
608+ private pixelToCell ( event : MouseEvent ) : { col : number ; row : number } | null {
609+ if ( ! this . mouseConfig ) return null ;
610+
611+ const dims = this . mouseConfig . getCellDimensions ( ) ;
612+ const offset = this . mouseConfig . getCanvasOffset ( ) ;
613+
614+ if ( dims . width <= 0 || dims . height <= 0 ) return null ;
615+
616+ const x = event . clientX - offset . left ;
617+ const y = event . clientY - offset . top ;
618+
619+ // Convert to 1-based cell coordinates (terminal uses 1-based)
620+ const col = Math . floor ( x / dims . width ) + 1 ;
621+ const row = Math . floor ( y / dims . height ) + 1 ;
622+
623+ // Clamp to valid range (at least 1)
624+ return {
625+ col : Math . max ( 1 , col ) ,
626+ row : Math . max ( 1 , row ) ,
627+ } ;
628+ }
629+
630+ /**
631+ * Get modifier flags for mouse event
632+ */
633+ private getMouseModifiers ( event : MouseEvent ) : number {
634+ let mods = 0 ;
635+ if ( event . shiftKey ) mods |= 4 ;
636+ if ( event . metaKey ) mods |= 8 ; // Meta (Cmd on Mac)
637+ if ( event . ctrlKey ) mods |= 16 ;
638+ return mods ;
639+ }
640+
641+ /**
642+ * Encode mouse event as SGR sequence
643+ * SGR format: \x1b[<Btn;Col;RowM (press/motion) or \x1b[<Btn;Col;Rowm (release)
644+ */
645+ private encodeMouseSGR (
646+ button : number ,
647+ col : number ,
648+ row : number ,
649+ isRelease : boolean ,
650+ modifiers : number
651+ ) : string {
652+ const btn = button + modifiers ;
653+ const suffix = isRelease ? 'm' : 'M' ;
654+ return `\x1b[<${ btn } ;${ col } ;${ row } ${ suffix } ` ;
655+ }
656+
657+ /**
658+ * Encode mouse event as X10/normal sequence (legacy format)
659+ * Format: \x1b[M<Btn+32><Col+32><Row+32>
660+ */
661+ private encodeMouseX10 (
662+ button : number ,
663+ col : number ,
664+ row : number ,
665+ modifiers : number
666+ ) : string {
667+ // X10 format adds 32 to all values and encodes as characters
668+ // Button encoding: 0=left, 1=middle, 2=right, 3=release
669+ const btn = button + modifiers + 32 ;
670+ const colChar = String . fromCharCode ( Math . min ( col + 32 , 255 ) ) ;
671+ const rowChar = String . fromCharCode ( Math . min ( row + 32 , 255 ) ) ;
672+ return `\x1b[M${ String . fromCharCode ( btn ) } ${ colChar } ${ rowChar } ` ;
673+ }
674+
675+ /**
676+ * Send mouse event to terminal
677+ */
678+ private sendMouseEvent (
679+ button : number ,
680+ col : number ,
681+ row : number ,
682+ isRelease : boolean ,
683+ event : MouseEvent
684+ ) : void {
685+ const modifiers = this . getMouseModifiers ( event ) ;
686+
687+ // Check if SGR extended mode is enabled (mode 1006)
688+ const useSGR = this . mouseConfig ?. hasSgrMouseMode ?.( ) ?? true ;
689+
690+ let sequence : string ;
691+ if ( useSGR ) {
692+ sequence = this . encodeMouseSGR ( button , col , row , isRelease , modifiers ) ;
693+ } else {
694+ // X10/normal mode doesn't support release events directly
695+ // Button 3 means release in X10 mode
696+ const x10Button = isRelease ? 3 : button ;
697+ sequence = this . encodeMouseX10 ( x10Button , col , row , modifiers ) ;
698+ }
699+
700+ this . onDataCallback ( sequence ) ;
701+ }
702+
703+ /**
704+ * Handle mousedown event
705+ */
706+ private handleMouseDown ( event : MouseEvent ) : void {
707+ if ( this . isDisposed ) return ;
708+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
709+
710+ const cell = this . pixelToCell ( event ) ;
711+ if ( ! cell ) return ;
712+
713+ // Map browser button to terminal button
714+ // event.button: 0=left, 1=middle, 2=right
715+ // Terminal: 0=left, 1=middle, 2=right
716+ const button = event . button ;
717+
718+ // Track pressed buttons for motion events
719+ this . mouseButtonsPressed |= 1 << button ;
720+
721+ this . sendMouseEvent ( button , cell . col , cell . row , false , event ) ;
722+
723+ // Don't prevent default - let SelectionManager handle selection
724+ // Only prevent if we actually handled the event
725+ // event.preventDefault();
726+ }
727+
728+ /**
729+ * Handle mouseup event
730+ */
731+ private handleMouseUp ( event : MouseEvent ) : void {
732+ if ( this . isDisposed ) return ;
733+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
734+
735+ const cell = this . pixelToCell ( event ) ;
736+ if ( ! cell ) return ;
737+
738+ const button = event . button ;
739+
740+ // Clear pressed button
741+ this . mouseButtonsPressed &= ~ ( 1 << button ) ;
742+
743+ this . sendMouseEvent ( button , cell . col , cell . row , true , event ) ;
744+ }
745+
746+ /**
747+ * Handle mousemove event
748+ */
749+ private handleMouseMove ( event : MouseEvent ) : void {
750+ if ( this . isDisposed ) return ;
751+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
752+
753+ // Check if button motion mode or any-event tracking is enabled
754+ // Mode 1002 = button motion, Mode 1003 = any motion
755+ const hasButtonMotion = this . getModeCallback ?.( 1002 ) ?? false ;
756+ const hasAnyMotion = this . getModeCallback ?.( 1003 ) ?? false ;
757+
758+ if ( ! hasButtonMotion && ! hasAnyMotion ) return ;
759+
760+ // In button motion mode, only report if a button is pressed
761+ if ( hasButtonMotion && ! hasAnyMotion && this . mouseButtonsPressed === 0 ) return ;
762+
763+ const cell = this . pixelToCell ( event ) ;
764+ if ( ! cell ) return ;
765+
766+ // Determine which button to report (or 32 for motion with no button)
767+ let button = 32 ; // Motion flag
768+ if ( this . mouseButtonsPressed & 1 ) button += 0 ; // Left
769+ else if ( this . mouseButtonsPressed & 2 ) button += 1 ; // Middle
770+ else if ( this . mouseButtonsPressed & 4 ) button += 2 ; // Right
771+
772+ this . sendMouseEvent ( button , cell . col , cell . row , false , event ) ;
773+ }
774+
775+ /**
776+ * Handle wheel event (scroll)
777+ */
778+ private handleWheel ( event : WheelEvent ) : void {
779+ if ( this . isDisposed ) return ;
780+ if ( ! this . mouseConfig ?. hasMouseTracking ( ) ) return ;
781+
782+ const cell = this . pixelToCell ( event ) ;
783+ if ( ! cell ) return ;
784+
785+ // Wheel events: button 64 = scroll up, button 65 = scroll down
786+ const button = event . deltaY < 0 ? 64 : 65 ;
787+
788+ this . sendMouseEvent ( button , cell . col , cell . row , false , event ) ;
789+
790+ // Prevent default scrolling when mouse tracking is active
791+ event . preventDefault ( ) ;
792+ }
793+
565794 /**
566795 * Dispose the InputHandler and remove event listeners
567796 */
@@ -598,6 +827,26 @@ export class InputHandler {
598827 this . compositionEndListener = null ;
599828 }
600829
830+ if ( this . mousedownListener ) {
831+ this . container . removeEventListener ( 'mousedown' , this . mousedownListener ) ;
832+ this . mousedownListener = null ;
833+ }
834+
835+ if ( this . mouseupListener ) {
836+ this . container . removeEventListener ( 'mouseup' , this . mouseupListener ) ;
837+ this . mouseupListener = null ;
838+ }
839+
840+ if ( this . mousemoveListener ) {
841+ this . container . removeEventListener ( 'mousemove' , this . mousemoveListener ) ;
842+ this . mousemoveListener = null ;
843+ }
844+
845+ if ( this . wheelListener ) {
846+ this . container . removeEventListener ( 'wheel' , this . wheelListener ) ;
847+ this . wheelListener = null ;
848+ }
849+
601850 this . isDisposed = true ;
602851 }
603852
0 commit comments