Skip to content

Commit 03ead6e

Browse files
kofanytobilgclaudeblink-so[bot]
authored
feat: add mouse tracking support for terminal applications (#106)
* Fixes for copying on Safari & Firefox * 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> --------- Co-authored-by: tobilg <tobilg@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
1 parent 98753e0 commit 03ead6e

File tree

2 files changed

+268
-3
lines changed

2 files changed

+268
-3
lines changed

lib/input-handler.ts

Lines changed: 248 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;
@@ -168,15 +182,21 @@ export class InputHandler {
168182
private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
169183
private getModeCallback?: (mode: number) => boolean;
170184
private onCopyCallback?: () => boolean;
185+
private mouseConfig?: MouseTrackingConfig;
171186
private keydownListener: ((e: KeyboardEvent) => void) | null = null;
172187
private keypressListener: ((e: KeyboardEvent) => void) | null = null;
173188
private pasteListener: ((e: ClipboardEvent) => void) | null = null;
174189
private beforeInputListener: ((e: InputEvent) => void) | null = null;
175190
private compositionStartListener: ((e: CompositionEvent) => void) | null = null;
176191
private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null;
177192
private compositionEndListener: ((e: CompositionEvent) => void) | null = null;
193+
private mousedownListener: ((e: MouseEvent) => void) | null = null;
194+
private mouseupListener: ((e: MouseEvent) => void) | null = null;
195+
private mousemoveListener: ((e: MouseEvent) => void) | null = null;
196+
private wheelListener: ((e: WheelEvent) => void) | null = null;
178197
private isComposing = false;
179198
private isDisposed = false;
199+
private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting
180200
private lastKeyDownData: string | null = null;
181201
private lastKeyDownTime = 0;
182202
private lastPasteData: string | null = null;
@@ -198,6 +218,8 @@ export class InputHandler {
198218
* @param customKeyEventHandler - Optional custom key event handler
199219
* @param getMode - Optional callback to query terminal mode state (for application cursor mode)
200220
* @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection)
221+
* @param inputElement - Optional input element for beforeinput events
222+
* @param mouseConfig - Optional mouse tracking configuration
201223
*/
202224
constructor(
203225
ghostty: Ghostty,
@@ -208,7 +230,8 @@ export class InputHandler {
208230
customKeyEventHandler?: (event: KeyboardEvent) => boolean,
209231
getMode?: (mode: number) => boolean,
210232
onCopy?: () => boolean,
211-
inputElement?: HTMLElement
233+
inputElement?: HTMLElement,
234+
mouseConfig?: MouseTrackingConfig
212235
) {
213236
this.encoder = ghostty.createKeyEncoder();
214237
this.container = container;
@@ -219,6 +242,7 @@ export class InputHandler {
219242
this.customKeyEventHandler = customKeyEventHandler;
220243
this.getModeCallback = getMode;
221244
this.onCopyCallback = onCopy;
245+
this.mouseConfig = mouseConfig;
222246

223247
// Attach event listeners
224248
this.attach();
@@ -272,6 +296,19 @@ export class InputHandler {
272296

273297
this.compositionEndListener = this.handleCompositionEnd.bind(this);
274298
this.container.addEventListener('compositionend', this.compositionEndListener);
299+
300+
// Mouse event listeners (for terminal mouse tracking)
301+
this.mousedownListener = this.handleMouseDown.bind(this);
302+
this.container.addEventListener('mousedown', this.mousedownListener);
303+
304+
this.mouseupListener = this.handleMouseUp.bind(this);
305+
this.container.addEventListener('mouseup', this.mouseupListener);
306+
307+
this.mousemoveListener = this.handleMouseMove.bind(this);
308+
this.container.addEventListener('mousemove', this.mousemoveListener);
309+
310+
this.wheelListener = this.handleWheel.bind(this);
311+
this.container.addEventListener('wheel', this.wheelListener, { passive: false });
275312
}
276313

277314
/**
@@ -679,6 +716,196 @@ export class InputHandler {
679716
}
680717
}
681718

719+
// ==========================================================================
720+
// Mouse Event Handling (for terminal mouse tracking)
721+
// ==========================================================================
722+
723+
/**
724+
* Convert pixel coordinates to terminal cell coordinates
725+
*/
726+
private pixelToCell(event: MouseEvent): { col: number; row: number } | null {
727+
if (!this.mouseConfig) return null;
728+
729+
const dims = this.mouseConfig.getCellDimensions();
730+
const offset = this.mouseConfig.getCanvasOffset();
731+
732+
if (dims.width <= 0 || dims.height <= 0) return null;
733+
734+
const x = event.clientX - offset.left;
735+
const y = event.clientY - offset.top;
736+
737+
// Convert to 1-based cell coordinates (terminal uses 1-based)
738+
const col = Math.floor(x / dims.width) + 1;
739+
const row = Math.floor(y / dims.height) + 1;
740+
741+
// Clamp to valid range (at least 1)
742+
return {
743+
col: Math.max(1, col),
744+
row: Math.max(1, row),
745+
};
746+
}
747+
748+
/**
749+
* Get modifier flags for mouse event
750+
*/
751+
private getMouseModifiers(event: MouseEvent): number {
752+
let mods = 0;
753+
if (event.shiftKey) mods |= 4;
754+
if (event.metaKey) mods |= 8; // Meta (Cmd on Mac)
755+
if (event.ctrlKey) mods |= 16;
756+
return mods;
757+
}
758+
759+
/**
760+
* Encode mouse event as SGR sequence
761+
* SGR format: \x1b[<Btn;Col;RowM (press/motion) or \x1b[<Btn;Col;Rowm (release)
762+
*/
763+
private encodeMouseSGR(
764+
button: number,
765+
col: number,
766+
row: number,
767+
isRelease: boolean,
768+
modifiers: number
769+
): string {
770+
const btn = button + modifiers;
771+
const suffix = isRelease ? 'm' : 'M';
772+
return `\x1b[<${btn};${col};${row}${suffix}`;
773+
}
774+
775+
/**
776+
* Encode mouse event as X10/normal sequence (legacy format)
777+
* Format: \x1b[M<Btn+32><Col+32><Row+32>
778+
*/
779+
private encodeMouseX10(button: number, col: number, row: number, modifiers: number): string {
780+
// X10 format adds 32 to all values and encodes as characters
781+
// Button encoding: 0=left, 1=middle, 2=right, 3=release
782+
const btn = button + modifiers + 32;
783+
const colChar = String.fromCharCode(Math.min(col + 32, 255));
784+
const rowChar = String.fromCharCode(Math.min(row + 32, 255));
785+
return `\x1b[M${String.fromCharCode(btn)}${colChar}${rowChar}`;
786+
}
787+
788+
/**
789+
* Send mouse event to terminal
790+
*/
791+
private sendMouseEvent(
792+
button: number,
793+
col: number,
794+
row: number,
795+
isRelease: boolean,
796+
event: MouseEvent
797+
): void {
798+
const modifiers = this.getMouseModifiers(event);
799+
800+
// Check if SGR extended mode is enabled (mode 1006)
801+
const useSGR = this.mouseConfig?.hasSgrMouseMode?.() ?? true;
802+
803+
let sequence: string;
804+
if (useSGR) {
805+
sequence = this.encodeMouseSGR(button, col, row, isRelease, modifiers);
806+
} else {
807+
// X10/normal mode doesn't support release events directly
808+
// Button 3 means release in X10 mode
809+
const x10Button = isRelease ? 3 : button;
810+
sequence = this.encodeMouseX10(x10Button, col, row, modifiers);
811+
}
812+
813+
this.onDataCallback(sequence);
814+
}
815+
816+
/**
817+
* Handle mousedown event
818+
*/
819+
private handleMouseDown(event: MouseEvent): void {
820+
if (this.isDisposed) return;
821+
if (!this.mouseConfig?.hasMouseTracking()) return;
822+
823+
const cell = this.pixelToCell(event);
824+
if (!cell) return;
825+
826+
// Map browser button to terminal button
827+
// event.button: 0=left, 1=middle, 2=right
828+
// Terminal: 0=left, 1=middle, 2=right
829+
const button = event.button;
830+
831+
// Track pressed buttons for motion events
832+
this.mouseButtonsPressed |= 1 << button;
833+
834+
this.sendMouseEvent(button, cell.col, cell.row, false, event);
835+
836+
// Don't prevent default - let SelectionManager handle selection
837+
// Only prevent if we actually handled the event
838+
// event.preventDefault();
839+
}
840+
841+
/**
842+
* Handle mouseup event
843+
*/
844+
private handleMouseUp(event: MouseEvent): void {
845+
if (this.isDisposed) return;
846+
if (!this.mouseConfig?.hasMouseTracking()) return;
847+
848+
const cell = this.pixelToCell(event);
849+
if (!cell) return;
850+
851+
const button = event.button;
852+
853+
// Clear pressed button
854+
this.mouseButtonsPressed &= ~(1 << button);
855+
856+
this.sendMouseEvent(button, cell.col, cell.row, true, event);
857+
}
858+
859+
/**
860+
* Handle mousemove event
861+
*/
862+
private handleMouseMove(event: MouseEvent): void {
863+
if (this.isDisposed) return;
864+
if (!this.mouseConfig?.hasMouseTracking()) return;
865+
866+
// Check if button motion mode or any-event tracking is enabled
867+
// Mode 1002 = button motion, Mode 1003 = any motion
868+
const hasButtonMotion = this.getModeCallback?.(1002) ?? false;
869+
const hasAnyMotion = this.getModeCallback?.(1003) ?? false;
870+
871+
if (!hasButtonMotion && !hasAnyMotion) return;
872+
873+
// In button motion mode, only report if a button is pressed
874+
if (hasButtonMotion && !hasAnyMotion && this.mouseButtonsPressed === 0) return;
875+
876+
const cell = this.pixelToCell(event);
877+
if (!cell) return;
878+
879+
// Determine which button to report (or 32 for motion with no button)
880+
let button = 32; // Motion flag
881+
if (this.mouseButtonsPressed & 1)
882+
button += 0; // Left
883+
else if (this.mouseButtonsPressed & 2)
884+
button += 1; // Middle
885+
else if (this.mouseButtonsPressed & 4) button += 2; // Right
886+
887+
this.sendMouseEvent(button, cell.col, cell.row, false, event);
888+
}
889+
890+
/**
891+
* Handle wheel event (scroll)
892+
*/
893+
private handleWheel(event: WheelEvent): void {
894+
if (this.isDisposed) return;
895+
if (!this.mouseConfig?.hasMouseTracking()) return;
896+
897+
const cell = this.pixelToCell(event);
898+
if (!cell) return;
899+
900+
// Wheel events: button 64 = scroll up, button 65 = scroll down
901+
const button = event.deltaY < 0 ? 64 : 65;
902+
903+
this.sendMouseEvent(button, cell.col, cell.row, false, event);
904+
905+
// Prevent default scrolling when mouse tracking is active
906+
event.preventDefault();
907+
}
908+
682909
/**
683910
* Emit paste data with bracketed paste support
684911
*/
@@ -847,6 +1074,26 @@ export class InputHandler {
8471074
this.compositionEndListener = null;
8481075
}
8491076

1077+
if (this.mousedownListener) {
1078+
this.container.removeEventListener('mousedown', this.mousedownListener);
1079+
this.mousedownListener = null;
1080+
}
1081+
1082+
if (this.mouseupListener) {
1083+
this.container.removeEventListener('mouseup', this.mouseupListener);
1084+
this.mouseupListener = null;
1085+
}
1086+
1087+
if (this.mousemoveListener) {
1088+
this.container.removeEventListener('mousemove', this.mousemoveListener);
1089+
this.mousemoveListener = null;
1090+
}
1091+
1092+
if (this.wheelListener) {
1093+
this.container.removeEventListener('wheel', this.wheelListener);
1094+
this.wheelListener = null;
1095+
}
1096+
8501097
this.isDisposed = true;
8511098
}
8521099

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,
@@ -425,6 +425,23 @@ export class Terminal implements ITerminalCore {
425425
// Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling)
426426
this.renderer.resize(this.cols, this.rows);
427427

428+
// Create mouse tracking configuration
429+
const canvas = this.canvas;
430+
const renderer = this.renderer;
431+
const wasmTerm = this.wasmTerm;
432+
const mouseConfig: MouseTrackingConfig = {
433+
hasMouseTracking: () => wasmTerm?.hasMouseTracking() ?? false,
434+
hasSgrMouseMode: () => wasmTerm?.getMode(1006, false) ?? true, // SGR extended mode
435+
getCellDimensions: () => ({
436+
width: renderer.charWidth,
437+
height: renderer.charHeight,
438+
}),
439+
getCanvasOffset: () => {
440+
const rect = canvas.getBoundingClientRect();
441+
return { left: rect.left, top: rect.top };
442+
},
443+
};
444+
428445
// Create input handler
429446
this.inputHandler = new InputHandler(
430447
this.ghostty!,
@@ -454,7 +471,8 @@ export class Terminal implements ITerminalCore {
454471
// Handle Cmd+C copy - returns true if there was a selection to copy
455472
return this.copySelection();
456473
},
457-
this.textarea
474+
this.textarea,
475+
mouseConfig
458476
);
459477

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

0 commit comments

Comments
 (0)