Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions src/vs/base/browser/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1854,12 +1854,14 @@ export class ModifierKeyEmitter extends event.Emitter<IModifierKeyStatus> {
return;
}

const modifiersChanged = this.hasModifierChanges(e);

this._keyStatus.altKey = e.altKey;
this._keyStatus.ctrlKey = e.ctrlKey;
this._keyStatus.metaKey = e.metaKey;
this._keyStatus.shiftKey = e.shiftKey;

if (this._keyStatus.lastKeyPressed) {
if (this._keyStatus.lastKeyPressed || modifiersChanged) {
this._keyStatus.event = e;
this.fire(this._keyStatus);
}
Expand All @@ -1886,36 +1888,74 @@ export class ModifierKeyEmitter extends event.Emitter<IModifierKeyStatus> {
this._keyStatus.lastKeyPressed = undefined;
}

const modifiersChanged = this.hasModifierChanges(e);

this._keyStatus.altKey = e.altKey;
this._keyStatus.ctrlKey = e.ctrlKey;
this._keyStatus.metaKey = e.metaKey;
this._keyStatus.shiftKey = e.shiftKey;

if (this._keyStatus.lastKeyReleased) {
if (this._keyStatus.lastKeyReleased || modifiersChanged) {
this._keyStatus.event = e;
this.fire(this._keyStatus);
}
}, true));

disposables.add(addDisposableListener(window.document.body, 'mousedown', () => {
disposables.add(addDisposableListener(window.document.body, 'mousedown', e => {
this._keyStatus.lastKeyPressed = undefined;

this.syncKeyStatus(e);
}, true));

disposables.add(addDisposableListener(window.document.body, 'mouseup', () => {
disposables.add(addDisposableListener(window.document.body, 'mouseup', e => {
this._keyStatus.lastKeyPressed = undefined;

this.syncKeyStatus(e);
}, true));

disposables.add(addDisposableListener(window.document.body, 'mousemove', e => {
if (e.buttons) {
this._keyStatus.lastKeyPressed = undefined;
}

this.syncKeyStatus(e);
}, true));

disposables.add(addDisposableListener(window, 'blur', () => {
this.resetKeyStatus();
}));
}

/**
* Modifier state goes stale when a modifier is released while our windows are not
* focused (e.g. during Alt+Tab or macOS Mission Control) because the matching
* `keyup` never arrives. Mouse events carry the actual modifier state, so they
* allow to recover from that (#331979).
*/
private syncKeyStatus(e: MouseEvent): void {
if (!this.hasModifierChanges(e)) {
return;
}

// The keyboard transition we missed makes the last pressed and
// released keys unreliable, so continue with a clean status.
this._keyStatus = {
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey
};

this.fire(this._keyStatus);
}

private hasModifierChanges(e: KeyboardEvent | MouseEvent): boolean {
return this._keyStatus.altKey !== e.altKey ||
this._keyStatus.ctrlKey !== e.ctrlKey ||
this._keyStatus.metaKey !== e.metaKey ||
this._keyStatus.shiftKey !== e.shiftKey;
}

get keyStatus(): IModifierKeyStatus {
return this._keyStatus;
}
Expand Down
46 changes: 45 additions & 1 deletion src/vs/base/test/browser/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { $, h, trackAttributes, copyAttributes, disposableWindowInterval, getWindows, getWindowsCount, getWindowId, getWindowById, hasWindow, getWindow, getDocument, isHTMLElement, SafeTriangle, AnimationFrameScheduler, DisposableResizeObserver, getRecentDisposableResizeObserverContextForLoopError, findParentWithClass, hasParentWithClass } from '../../browser/dom.js';
import { $, h, trackAttributes, copyAttributes, disposableWindowInterval, getWindows, getWindowsCount, getWindowId, getWindowById, hasWindow, getWindow, getDocument, isHTMLElement, SafeTriangle, AnimationFrameScheduler, DisposableResizeObserver, getRecentDisposableResizeObserverContextForLoopError, findParentWithClass, hasParentWithClass, ModifierKeyEmitter } from '../../browser/dom.js';
import { asCssValueWithDefault } from '../../../base/browser/cssValue.js';
import { ensureCodeWindow, isAuxiliaryWindow, mainWindow } from '../../browser/window.js';
import { DeferredPromise, timeout } from '../../common/async.js';
import { errorHandler, setUnexpectedErrorHandler } from '../../common/errors.js';
import { DisposableStore } from '../../common/lifecycle.js';
import { runWithFakedTimers } from '../common/timeTravelScheduler.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js';

Expand Down Expand Up @@ -737,5 +738,48 @@ suite('dom', () => {
});
});

suite('ModifierKeyEmitter', () => {
const disposables = new DisposableStore();

teardown(() => {
disposables.clear();
ModifierKeyEmitter.disposeInstance();
});

function trackAltKey(emitter: ModifierKeyEmitter): boolean[] {
const altKeyUpdates: boolean[] = [];
disposables.add(emitter.event(status => altKeyUpdates.push(status.altKey)));

return altKeyUpdates;
}

test('notifies when a keyboard event reveals that Alt is no longer pressed', () => {
const emitter = ModifierKeyEmitter.getInstance();
const altKeyUpdates = trackAltKey(emitter);

mainWindow.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true }));

// Alt got released while another application had focus, so the matching
// `keyup` never arrived and the next keyboard event reveals the truth
mainWindow.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));

assert.deepStrictEqual({ altKeyUpdates, altKey: emitter.keyStatus.altKey }, { altKeyUpdates: [true, false], altKey: false });
});

test('mouse events keep the modifier state in sync', () => {
const emitter = ModifierKeyEmitter.getInstance();
const altKeyUpdates = trackAltKey(emitter);

mainWindow.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true }));

// Mouse events carry the actual modifier state and are the only
// signal we get when the `keyup` for Alt was never delivered
mainWindow.document.body.dispatchEvent(new MouseEvent('mousemove'));
mainWindow.document.body.dispatchEvent(new MouseEvent('mousedown'));

assert.deepStrictEqual({ altKeyUpdates, altKey: emitter.keyStatus.altKey }, { altKeyUpdates: [true, false], altKey: false });
});
});

ensureNoDisposablesAreLeakedInTestSuite();
});