Skip to content

Commit 5e8e189

Browse files
kofanyclaude
andcommitted
feat: add mouse tracking support for terminal applications
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 <noreply@anthropic.com>
1 parent d0debf6 commit 5e8e189

2 files changed

Lines changed: 270 additions & 3 deletions

File tree

lib/input-handler.ts

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
161175
export 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

lib/terminal.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { BufferNamespace } from './buffer';
1919
import { EventEmitter } from './event-emitter';
2020
import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty';
2121
import { getGhostty } from './index';
22-
import { InputHandler } from './input-handler';
22+
import { InputHandler, type MouseTrackingConfig } from './input-handler';
2323
import type {
2424
IBufferNamespace,
2525
IBufferRange,
@@ -421,6 +421,23 @@ export class Terminal implements ITerminalCore {
421421
// Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling)
422422
this.renderer.resize(this.cols, this.rows);
423423

424+
// Create mouse tracking configuration
425+
const canvas = this.canvas;
426+
const renderer = this.renderer;
427+
const wasmTerm = this.wasmTerm;
428+
const mouseConfig: MouseTrackingConfig = {
429+
hasMouseTracking: () => wasmTerm?.hasMouseTracking() ?? false,
430+
hasSgrMouseMode: () => wasmTerm?.getMode(1006, false) ?? true, // SGR extended mode
431+
getCellDimensions: () => ({
432+
width: renderer.charWidth,
433+
height: renderer.charHeight,
434+
}),
435+
getCanvasOffset: () => {
436+
const rect = canvas.getBoundingClientRect();
437+
return { left: rect.left, top: rect.top };
438+
},
439+
};
440+
424441
// Create input handler
425442
this.inputHandler = new InputHandler(
426443
this.ghostty!,
@@ -449,7 +466,8 @@ export class Terminal implements ITerminalCore {
449466
() => {
450467
// Handle Cmd+C copy - returns true if there was a selection to copy
451468
return this.copySelection();
452-
}
469+
},
470+
mouseConfig
453471
);
454472

455473
// Create selection manager (pass textarea for context menu positioning)

0 commit comments

Comments
 (0)