Merge pull request #5953 from xtermjs/cursor/alt-click-for-mouse-events-5b17

Add altClickForMouseEvents option
This commit is contained in:
Daniel Imms
2026-05-28 06:35:56 -07:00
committed by GitHub
9 changed files with 314 additions and 15 deletions
+4 -3
View File
@@ -37,7 +37,7 @@ import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'
import { CoreBrowserService } from 'browser/services/CoreBrowserService';
import { LinkProviderService } from 'browser/services/LinkProviderService';
import { MouseCoordsService } from 'browser/services/MouseCoordsService';
import { MouseService } from 'browser/services/MouseService';
import { MouseEventCssClasses, MouseService } from 'browser/services/MouseService';
import { RenderService } from 'browser/services/RenderService';
import { SelectionService } from 'browser/services/SelectionService';
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
@@ -618,11 +618,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e)));
// apply mouse event classes set by escape codes before terminal was attached
if (this.mouseStateService.areMouseEventsActive) {
if (this.mouseStateService.areMouseEventsActive && !this.options.mouseEventsRequireAlt) {
this._selectionService.disable();
this.element.classList.add('enable-mouse-events');
this.element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
} else {
this._selectionService.enable();
this.element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
}
if (this.options.screenReaderMode) {
+141 -1
View File
@@ -3,10 +3,11 @@
* @license MIT
*/
import { assert } from 'chai';
import { MouseService } from 'browser/services/MouseService';
import { AltMouseCursorController, MouseEventCssClasses, MouseService } from 'browser/services/MouseService';
import { MouseStateService } from 'common/services/MouseStateService';
import { CoreMouseAction, CoreMouseButton } from 'common/Types';
import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
import { OptionsService } from 'common/services/OptionsService';
import { MockCoreBrowserService, MockRenderService, MockSelectionService } from 'browser/TestUtils.test';
function toBytes(s: string | undefined): number[] {
@@ -42,6 +43,36 @@ const logService: ILogService = {
error: () => {}
} as any;
class TestSelectionService extends MockSelectionService {
public enableCount = 0;
public disableCount = 0;
public override enable(): void {
this.enableCount++;
}
public override disable(): void {
this.disableCount++;
}
public override shouldForceSelection(): boolean {
return false;
}
}
function createTestMouseTargetElement(): HTMLElement {
const classes = new Set<string>();
return {
classList: {
add: (className: string) => classes.add(className),
remove: (className: string) => classes.delete(className),
contains: (className: string) => classes.has(className)
},
addEventListener: () => {},
removeEventListener: () => {}
} as any;
}
describe('MouseService _triggerMouseEvent', () => {
let mouseService: MouseService;
let mouseStateService: MouseStateService;
@@ -211,3 +242,112 @@ describe('MouseService _triggerMouseEvent', () => {
reports = [];
});
});
describe('MouseService mouseEventsRequireAlt', () => {
it('should update selection state and cursor class when toggled while mouse events are active', () => {
const mouseStateService = new MouseStateService();
const optionsService = new OptionsService({});
const selectionService = new TestSelectionService();
const mouseService = new MouseService(
new MockRenderService(),
{
getMouseReportCoords: (_ev: MouseEvent, _el: HTMLElement) => ({ col: 0, row: 0, x: 0, y: 0 })
} as any,
mouseStateService,
{
triggerDataEvent: () => {},
triggerBinaryEvent: () => {},
decPrivateModes: { applicationCursorKeys: false }
} as any,
bufferService,
optionsService,
selectionService,
logService,
new MockCoreBrowserService()
);
const element = createTestMouseTargetElement();
const screenElement = createTestMouseTargetElement();
const document = {
addEventListener: () => {},
removeEventListener: () => {}
} as any;
mouseService.bindMouse({
element,
screenElement,
document
}, disposable => disposable, () => {});
mouseStateService.activeProtocol = 'ANY';
assert.isTrue(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
assert.equal(selectionService.disableCount, 1);
optionsService.options.mouseEventsRequireAlt = true;
assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
assert.equal(selectionService.enableCount, 2);
optionsService.options.mouseEventsRequireAlt = false;
assert.isTrue(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
assert.equal(selectionService.disableCount, 2);
});
it('should strip alt modifier from forwarded mouse reports', () => {
const mouseStateService = new MouseStateService();
mouseStateService.activeProtocol = 'ANY';
mouseStateService.activeEncoding = 'SGR';
const optionsService = new OptionsService({ mouseEventsRequireAlt: true });
const reports: string[] = [];
const mouseService = new MouseService(
new MockRenderService(),
{
getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 })
} as any,
mouseStateService,
{
triggerDataEvent: (data: string) => reports.push(data),
triggerBinaryEvent: () => {},
decPrivateModes: { applicationCursorKeys: false }
} as any,
bufferService,
optionsService,
new TestSelectionService(),
logService,
new MockCoreBrowserService()
);
const ctx = {
target: {
screenElement: createTestMouseTargetElement()
},
requestedEvents: {}
} as any;
const sent = (mouseService as any)._sendEvent(ctx, {
type: 'mousedown',
button: 0,
altKey: true,
ctrlKey: false,
shiftKey: false
} as MouseEvent);
assert.isTrue(sent);
assert.deepEqual(reports, ['\x1b[<0;1;1M']);
});
it('should toggle enable-mouse-events class when alt modifier changes', () => {
const element = createTestMouseTargetElement();
const altMouseCursor = new AltMouseCursorController(element, {
addEventListener: () => {},
removeEventListener: () => {}
} as any, () => true);
const sync = (altHeld: boolean) => altMouseCursor.syncFromModifier({
getModifierState: (key: string) => key === 'Alt' && altHeld
} as KeyboardEvent);
assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
sync(true);
assert.isTrue(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
sync(false);
assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
});
});
+109 -6
View File
@@ -7,12 +7,16 @@ import { addDisposableListener } from 'browser/Dom';
import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from 'common/Types';
import { C0 } from 'common/data/EscapeSequences';
import { toDisposable } from 'common/Lifecycle';
import { DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
import { Gesture, EventType as GestureEventType, IGestureEvent } from 'browser/scrollable/touch';
type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>;
export const enum MouseEventCssClasses {
ENABLE_MOUSE_EVENTS = 'enable-mouse-events'
}
interface IMouseBindContext {
readonly target: IMouseServiceTarget;
readonly focus: () => void;
@@ -25,6 +29,7 @@ export class MouseService implements IMouseService {
private _lastEvent: ICoreMouseEvent | null = null;
private _wheelPartialScroll: number = 0;
private _touchScrollAccumulator: number = 0;
private _altMouseCursor: AltMouseCursorController | undefined;
constructor(
@IRenderService private readonly _renderService: IRenderService,
@@ -63,9 +68,20 @@ export class MouseService implements IMouseService {
mousedrag: (ev: Event) => this._handleMouseDrag(ctx, ev as MouseEvent),
mousemove: (ev: Event) => this._handleMouseMove(ctx, ev as MouseEvent)
};
this._altMouseCursor = new AltMouseCursorController(
element,
document,
() => this._mouseStateService.areMouseEventsActive
&& !!this._optionsService.rawOptions.mouseEventsRequireAlt
);
register(this._altMouseCursor);
register(this._mouseStateService.onProtocolChange(events => {
this._handleProtocolChange(ctx, eventListeners, events);
}));
register(this._optionsService.onSpecificOptionChange('mouseEventsRequireAlt', () => {
this._syncMouseModeState(element);
this._altMouseCursor?.sync();
}));
// force initial onProtocolChange so we dont miss early mouse requests
this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol;
@@ -153,6 +169,19 @@ export class MouseService implements IMouseService {
return false;
}
if (but !== CoreMouseButton.WHEEL
&& this._optionsService.rawOptions.mouseEventsRequireAlt
&& this._mouseStateService.areMouseEventsActive
&& !ev.altKey) {
return false;
}
// Alt is only used locally to gate mouse passthrough; do not forward it to the
// application (e.g. tmux ignores alt-modified mouse reports).
const stripAltFromReport = but !== CoreMouseButton.WHEEL
&& this._optionsService.rawOptions.mouseEventsRequireAlt
&& this._mouseStateService.areMouseEventsActive;
return this._triggerMouseEvent({
col: pos.col,
row: pos.row,
@@ -161,7 +190,7 @@ export class MouseService implements IMouseService {
button: but,
action,
ctrl: ev.ctrlKey,
alt: ev.altKey,
alt: stripAltFromReport ? false : ev.altKey,
shift: ev.shiftKey
});
}
@@ -353,6 +382,21 @@ export class MouseService implements IMouseService {
this._touchScrollAccumulator = 0;
}
private _syncMouseModeState(element: HTMLElement): void {
if (this._mouseStateService.areMouseEventsActive) {
if (this._optionsService.rawOptions.mouseEventsRequireAlt) {
this._altMouseCursor?.resetClass();
this._selectionService.enable();
} else {
element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
this._selectionService.disable();
}
} else {
element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
this._selectionService.enable();
}
}
private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
const { element, document } = ctx.target;
const { requestedEvents } = ctx;
@@ -361,13 +405,11 @@ export class MouseService implements IMouseService {
if (this._optionsService.rawOptions.logLevel === 'debug') {
this._logService.debug('Binding to mouse events:', this._explainEvents(events));
}
element.classList.add('enable-mouse-events');
this._selectionService.disable();
} else {
this._logService.debug('Unbinding from mouse events.');
element.classList.remove('enable-mouse-events');
this._selectionService.enable();
}
this._syncMouseModeState(element);
this._altMouseCursor?.sync();
// add/remove handlers from requestedEvents
if (!(events & CoreMouseEventType.MOVE)) {
@@ -537,3 +579,64 @@ export class MouseService implements IMouseService {
}
}
/**
* Toggles MouseEventCssClasses.ENABLE_MOUSE_EVENTS on the terminal element while alt is held when
* `mouseEventsRequireAlt` is active. DOM listeners are only registered while active.
*/
export class AltMouseCursorController implements IDisposable {
private readonly _listeners = new MutableDisposable<IDisposable>();
constructor(
private readonly _element: HTMLElement,
private readonly _document: Document,
private readonly _isActive: () => boolean
) {
}
public dispose(): void {
this._listeners.dispose();
}
public sync(): void {
this._listeners.clear();
if (!this._isActive()) {
return;
}
const store = new DisposableStore();
const syncFromModifier = (ev: KeyboardEvent | MouseEvent): void => this.syncFromModifier(ev);
store.add(addDisposableListener(this._document, 'keydown', syncFromModifier));
store.add(addDisposableListener(this._document, 'keyup', syncFromModifier));
store.add(addDisposableListener(this._element, 'mousemove', syncFromModifier));
const targetWindow = this._element.ownerDocument?.defaultView;
if (targetWindow) {
store.add(addDisposableListener(targetWindow, 'blur', () => {
if (this._isActive()) {
this.resetClass();
}
}));
}
this._listeners.value = store;
}
public resetClass(): void {
this._updateClass(false);
}
public syncFromModifier(ev: KeyboardEvent | MouseEvent): void {
if (!this._isActive()) {
return;
}
this._updateClass(ev.getModifierState('Alt'));
}
private _updateClass(altHeld: boolean): void {
if (altHeld) {
this._element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
} else {
this._element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
}
}
}
+23 -4
View File
@@ -7,7 +7,7 @@ import { assert } from 'chai';
import { SelectionService, SelectionMode } from './SelectionService';
import { SelectionModel } from 'browser/selection/SelectionModel';
import { IBufferLine } from 'common/Types';
import { MockBufferService, MockOptionsService, MockCoreService, createCellData } from 'common/TestUtils.test';
import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, createCellData } from 'common/TestUtils.test';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferService, IOptionsService } from 'common/services/Services';
@@ -22,9 +22,10 @@ class TestSelectionService extends SelectionService {
constructor(
bufferService: IBufferService,
optionsService: IOptionsService,
renderService: IRenderService
renderService: IRenderService,
public readonly mouseStateService: MockMouseStateService
) {
super(null!, null!, null!, bufferService, new MockCoreService(), new MockMouseService(), optionsService, renderService, new MockCoreBrowserService());
super(null!, null!, null!, bufferService, new MockCoreService(), new MockMouseService(), optionsService, mouseStateService, renderService, new MockCoreBrowserService());
}
public get model(): SelectionModel { return this._model; }
@@ -45,16 +46,18 @@ describe('SelectionService', () => {
let buffer: IBuffer;
let bufferService: IBufferService;
let optionsService: IOptionsService;
let mouseStateService: MockMouseStateService;
let selectionService: TestSelectionService;
beforeEach(() => {
optionsService = new MockOptionsService();
mouseStateService = new MockMouseStateService();
bufferService = new MockBufferService(20, 20, optionsService);
buffer = bufferService.buffer;
const renderService = new MockRenderService();
renderService.dimensions.css.canvas.height = 10 * 20;
renderService.dimensions.css.canvas.width = 10 * 20;
selectionService = new TestSelectionService(bufferService, optionsService, renderService);
selectionService = new TestSelectionService(bufferService, optionsService, renderService, mouseStateService);
});
function stringToRow(text: string): IBufferLine {
@@ -499,5 +502,21 @@ describe('SelectionService', () => {
assert.isFalse(selectionService.areCoordsInSelection([2, 1], [2, 0], [2, 1]));
});
});
describe('shouldForceSelection', () => {
it('should force selection without alt when mouseEventsRequireAlt is enabled', () => {
optionsService.options.mouseEventsRequireAlt = true;
mouseStateService.areMouseEventsActive = true;
assert.isTrue(selectionService.shouldForceSelection({ altKey: false } as MouseEvent));
assert.isFalse(selectionService.shouldForceSelection({ altKey: true } as MouseEvent));
});
it('should take precedence over macOptionClickForcesSelection', () => {
optionsService.options.mouseEventsRequireAlt = true;
optionsService.options.macOptionClickForcesSelection = true;
mouseStateService.areMouseEventsActive = true;
assert.isFalse(selectionService.shouldForceSelection({ altKey: true } as MouseEvent));
});
});
});
+13 -1
View File
@@ -15,7 +15,7 @@ import { IBufferLine, ICellData, IDisposable } from 'common/Types';
import { getRangeLength } from 'common/buffer/BufferRange';
import { CellData } from 'common/buffer/CellData';
import { IBuffer } from 'common/buffer/Types';
import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services';
import { IBufferService, ICoreService, IMouseStateService, IOptionsService } from 'common/services/Services';
import { Emitter } from 'common/Event';
const enum Constants {
@@ -127,6 +127,7 @@ export class SelectionService extends Disposable implements ISelectionService {
@ICoreService private readonly _coreService: ICoreService,
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IMouseStateService private readonly _mouseStateService: IMouseStateService,
@IRenderService private readonly _renderService: IRenderService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
) {
@@ -434,6 +435,10 @@ export class SelectionService extends Disposable implements ISelectionService {
* @param event The mouse event.
*/
public shouldForceSelection(event: MouseEvent): boolean {
if (this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive) {
return !event.altKey;
}
if (Browser.isMac) {
return event.altKey && this._optionsService.rawOptions.macOptionClickForcesSelection;
}
@@ -458,6 +463,10 @@ export class SelectionService extends Disposable implements ISelectionService {
return;
}
if (this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive && event.altKey) {
return;
}
// Allow selection when using a specific modifier key, even when disabled
if (!this._enabled) {
if (!this.shouldForceSelection(event)) {
@@ -596,6 +605,9 @@ export class SelectionService extends Disposable implements ISelectionService {
* @param event the mouse or keyboard event
*/
public shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean {
if (this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive) {
return false;
}
return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);
}
+1
View File
@@ -41,6 +41,7 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
minimumContrastRatio: 1,
mouseEventsRequireAlt: false,
disableStdin: false,
allowProposedApi: false,
allowTransparency: false,
+1
View File
@@ -234,6 +234,7 @@ export interface ITerminalOptions {
macOptionIsMeta?: boolean;
macOptionClickForcesSelection?: boolean;
minimumContrastRatio?: number;
mouseEventsRequireAlt?: boolean;
reflowCursorLine?: boolean;
rescaleOverlappingGlyphs?: boolean;
rightClickSelectsWord?: boolean;
+11
View File
@@ -136,6 +136,17 @@ declare module '@xterm/headless' {
*/
minimumContrastRatio?: number;
/**
* When enabled and the terminal is in mouse events mode, mouse click, drag,
* and move events are only sent to the underlying application when the alt
* key is held. The alt key is not included in the mouse reports sent to the
* application. Wheel events are not affected. This allows normal text
* selection by default while still supporting application mouse interaction
* and scrolling when holding alt. When enabled, this takes precedence over
* `macOptionClickForcesSelection`.
*/
mouseEventsRequireAlt?: boolean;
/**
* Whether to reflow the line containing the cursor when the terminal is
* resized. Defaults to false, because shells usually handle this
+11
View File
@@ -206,6 +206,17 @@ declare module '@xterm/xterm' {
*/
minimumContrastRatio?: number;
/**
* When enabled and the terminal is in mouse events mode, mouse click, drag,
* and move events are only sent to the underlying application when the alt
* key is held. The alt key is not included in the mouse reports sent to the
* application. Wheel events are not affected. This allows normal text
* selection by default while still supporting application mouse interaction
* and scrolling when holding alt. When enabled, this takes precedence over
* `macOptionClickForcesSelection`.
*/
mouseEventsRequireAlt?: boolean;
/**
* Control various quirks features that are either non-standard or standard
* in but generally rejected in modern terminals.