Skip to content

Commit d903208

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 <[email protected]>
1 parent c7e37fb commit d903208

File tree

2 files changed

+270
-3
lines changed

2 files changed

+270
-3
lines changed

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;
@@ -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

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!,
@@ -445,7 +462,8 @@ export class Terminal implements ITerminalCore {
445462
(mode: number) => {
446463
// Query terminal mode state (e.g., mode 1 for application cursor mode)
447464
return this.wasmTerm?.getMode(mode, false) ?? false;
448-
}
465+
},
466+
mouseConfig
449467
);
450468

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

0 commit comments

Comments
 (0)