diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 62c4ae31c..a9461581e 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -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) { diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index f395083e3..d7aa7308f 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -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(); + 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)); + }); +}); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index d701796c2..f9d68f42e 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -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(); + + 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); + } + } +} diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index be62ccc6b..04d691d40 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -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)); + }); + }); }); diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 6968feb7e..6516e5154 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -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); } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index ec647aaa5..8527d2ea0 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -41,6 +41,7 @@ export const DEFAULT_OPTIONS: Readonly> = { macOptionIsMeta: false, macOptionClickForcesSelection: false, minimumContrastRatio: 1, + mouseEventsRequireAlt: false, disableStdin: false, allowProposedApi: false, allowTransparency: false, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a858193c3..fa1c4412f 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -234,6 +234,7 @@ export interface ITerminalOptions { macOptionIsMeta?: boolean; macOptionClickForcesSelection?: boolean; minimumContrastRatio?: number; + mouseEventsRequireAlt?: boolean; reflowCursorLine?: boolean; rescaleOverlappingGlyphs?: boolean; rightClickSelectsWord?: boolean; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index d51dc096d..21e884ecb 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -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 diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d1ce877b9..c66a52e85 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -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.