Path: blob/main/src/vs/workbench/services/keybinding/browser/keyboardLayoutService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as nls from '../../../../nls.js';6import { Emitter, Event } from '../../../../base/common/event.js';7import { AppResourcePath, FileAccess } from '../../../../base/common/network.js';8import { Disposable } from '../../../../base/common/lifecycle.js';9import { KeymapInfo, IRawMixedKeyboardMapping, IKeymapInfo } from '../common/keymapInfo.js';10import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';11import { DispatchConfig, readKeyboardConfig } from '../../../../platform/keyboardLayout/common/keyboardConfig.js';12import { IKeyboardMapper, CachedKeyboardMapper } from '../../../../platform/keyboardLayout/common/keyboardMapper.js';13import { OS, OperatingSystem, isMacintosh, isWindows } from '../../../../base/common/platform.js';14import { WindowsKeyboardMapper } from '../common/windowsKeyboardMapper.js';15import { FallbackKeyboardMapper } from '../common/fallbackKeyboardMapper.js';16import { IKeyboardEvent } from '../../../../platform/keybinding/common/keybinding.js';17import { MacLinuxKeyboardMapper } from '../common/macLinuxKeyboardMapper.js';18import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';19import { URI } from '../../../../base/common/uri.js';20import { IFileService } from '../../../../platform/files/common/files.js';21import { RunOnceScheduler } from '../../../../base/common/async.js';22import { parse, getNodeType } from '../../../../base/common/json.js';23import * as objects from '../../../../base/common/objects.js';24import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';25import { Registry } from '../../../../platform/registry/common/platform.js';26import { Extensions as ConfigExtensions, IConfigurationRegistry, IConfigurationNode } from '../../../../platform/configuration/common/configurationRegistry.js';27import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';28import { INavigatorWithKeyboard } from './navigatorKeyboard.js';29import { INotificationService } from '../../../../platform/notification/common/notification.js';30import { ICommandService } from '../../../../platform/commands/common/commands.js';31import { IStorageService } from '../../../../platform/storage/common/storage.js';32import { getKeyboardLayoutId, IKeyboardLayoutInfo, IKeyboardLayoutService, IKeyboardMapping, IMacLinuxKeyboardMapping, IWindowsKeyboardMapping } from '../../../../platform/keyboardLayout/common/keyboardLayout.js';3334export class BrowserKeyboardMapperFactoryBase extends Disposable {35// keyboard mapper36protected _initialized: boolean;37protected _keyboardMapper: IKeyboardMapper | null;38private readonly _onDidChangeKeyboardMapper = new Emitter<void>();39public readonly onDidChangeKeyboardMapper: Event<void> = this._onDidChangeKeyboardMapper.event;4041// keymap infos42protected _keymapInfos: KeymapInfo[];43protected _mru: KeymapInfo[];44private _activeKeymapInfo: KeymapInfo | null;45private keyboardLayoutMapAllowed: boolean = (navigator as any).keyboard !== undefined;4647get activeKeymap(): KeymapInfo | null {48return this._activeKeymapInfo;49}5051get keymapInfos(): KeymapInfo[] {52return this._keymapInfos;53}5455get activeKeyboardLayout(): IKeyboardLayoutInfo | null {56if (!this._initialized) {57return null;58}5960return this._activeKeymapInfo?.layout ?? null;61}6263get activeKeyMapping(): IKeyboardMapping | null {64if (!this._initialized) {65return null;66}6768return this._activeKeymapInfo?.mapping ?? null;69}7071get keyboardLayouts(): IKeyboardLayoutInfo[] {72return this._keymapInfos.map(keymapInfo => keymapInfo.layout);73}7475protected constructor(76private readonly _configurationService: IConfigurationService,77// private _notificationService: INotificationService,78// private _storageService: IStorageService,79// private _commandService: ICommandService80) {81super();82this._keyboardMapper = null;83this._initialized = false;84this._keymapInfos = [];85this._mru = [];86this._activeKeymapInfo = null;8788if ((<INavigatorWithKeyboard>navigator).keyboard && (<INavigatorWithKeyboard>navigator).keyboard.addEventListener) {89(<INavigatorWithKeyboard>navigator).keyboard.addEventListener!('layoutchange', () => {90// Update user keyboard map settings91this._getBrowserKeyMapping().then((mapping: IKeyboardMapping | null) => {92if (this.isKeyMappingActive(mapping)) {93return;94}9596this.setLayoutFromBrowserAPI();97});98});99}100101this._register(this._configurationService.onDidChangeConfiguration((e) => {102if (e.affectsConfiguration('keyboard')) {103this._keyboardMapper = null;104this._onDidChangeKeyboardMapper.fire();105}106}));107}108109registerKeyboardLayout(layout: KeymapInfo) {110this._keymapInfos.push(layout);111this._mru = this._keymapInfos;112}113114removeKeyboardLayout(layout: KeymapInfo): void {115let index = this._mru.indexOf(layout);116this._mru.splice(index, 1);117index = this._keymapInfos.indexOf(layout);118this._keymapInfos.splice(index, 1);119}120121getMatchedKeymapInfo(keyMapping: IKeyboardMapping | null): { result: KeymapInfo; score: number } | null {122if (!keyMapping) {123return null;124}125126const usStandard = this.getUSStandardLayout();127128if (usStandard) {129let maxScore = usStandard.getScore(keyMapping);130if (maxScore === 0) {131return {132result: usStandard,133score: 0134};135}136137let result = usStandard;138for (let i = 0; i < this._mru.length; i++) {139const score = this._mru[i].getScore(keyMapping);140if (score > maxScore) {141if (score === 0) {142return {143result: this._mru[i],144score: 0145};146}147148maxScore = score;149result = this._mru[i];150}151}152153return {154result,155score: maxScore156};157}158159for (let i = 0; i < this._mru.length; i++) {160if (this._mru[i].fuzzyEqual(keyMapping)) {161return {162result: this._mru[i],163score: 0164};165}166}167168return null;169}170171getUSStandardLayout() {172const usStandardLayouts = this._mru.filter(layout => layout.layout.isUSStandard);173174if (usStandardLayouts.length) {175return usStandardLayouts[0];176}177178return null;179}180181isKeyMappingActive(keymap: IKeyboardMapping | null) {182return this._activeKeymapInfo && keymap && this._activeKeymapInfo.fuzzyEqual(keymap);183}184185setUSKeyboardLayout() {186this._activeKeymapInfo = this.getUSStandardLayout();187}188189setActiveKeyMapping(keymap: IKeyboardMapping | null) {190let keymapUpdated = false;191const matchedKeyboardLayout = this.getMatchedKeymapInfo(keymap);192if (matchedKeyboardLayout) {193// let score = matchedKeyboardLayout.score;194195// Due to https://bugs.chromium.org/p/chromium/issues/detail?id=977609, any key after a dead key will generate a wrong mapping,196// we shoud avoid yielding the false error.197// if (keymap && score < 0) {198// const donotAskUpdateKey = 'missing.keyboardlayout.donotask';199// if (this._storageService.getBoolean(donotAskUpdateKey, StorageScope.APPLICATION)) {200// return;201// }202203// the keyboard layout doesn't actually match the key event or the keymap from chromium204// this._notificationService.prompt(205// Severity.Info,206// nls.localize('missing.keyboardlayout', 'Fail to find matching keyboard layout'),207// [{208// label: nls.localize('keyboardLayoutMissing.configure', "Configure"),209// run: () => this._commandService.executeCommand('workbench.action.openKeyboardLayoutPicker')210// }, {211// label: nls.localize('neverAgain', "Don't Show Again"),212// isSecondary: true,213// run: () => this._storageService.store(donotAskUpdateKey, true, StorageScope.APPLICATION)214// }]215// );216217// console.warn('Active keymap/keyevent does not match current keyboard layout', JSON.stringify(keymap), this._activeKeymapInfo ? JSON.stringify(this._activeKeymapInfo.layout) : '');218219// return;220// }221222if (!this._activeKeymapInfo) {223this._activeKeymapInfo = matchedKeyboardLayout.result;224keymapUpdated = true;225} else if (keymap) {226if (matchedKeyboardLayout.result.getScore(keymap) > this._activeKeymapInfo.getScore(keymap)) {227this._activeKeymapInfo = matchedKeyboardLayout.result;228keymapUpdated = true;229}230}231}232233if (!this._activeKeymapInfo) {234this._activeKeymapInfo = this.getUSStandardLayout();235keymapUpdated = true;236}237238if (!this._activeKeymapInfo || !keymapUpdated) {239return;240}241242const index = this._mru.indexOf(this._activeKeymapInfo);243244this._mru.splice(index, 1);245this._mru.unshift(this._activeKeymapInfo);246247this._setKeyboardData(this._activeKeymapInfo);248}249250setActiveKeymapInfo(keymapInfo: KeymapInfo) {251this._activeKeymapInfo = keymapInfo;252253const index = this._mru.indexOf(this._activeKeymapInfo);254255if (index === 0) {256return;257}258259this._mru.splice(index, 1);260this._mru.unshift(this._activeKeymapInfo);261262this._setKeyboardData(this._activeKeymapInfo);263}264265public setLayoutFromBrowserAPI(): void {266this._updateKeyboardLayoutAsync(this._initialized);267}268269private _updateKeyboardLayoutAsync(initialized: boolean, keyboardEvent?: IKeyboardEvent) {270if (!initialized) {271return;272}273274this._getBrowserKeyMapping(keyboardEvent).then(keyMap => {275// might be false positive276if (this.isKeyMappingActive(keyMap)) {277return;278}279this.setActiveKeyMapping(keyMap);280});281}282283public getKeyboardMapper(): IKeyboardMapper {284const config = readKeyboardConfig(this._configurationService);285if (config.dispatch === DispatchConfig.KeyCode || !this._initialized || !this._activeKeymapInfo) {286// Forcefully set to use keyCode287return new FallbackKeyboardMapper(config.mapAltGrToCtrlAlt, OS);288}289if (!this._keyboardMapper) {290this._keyboardMapper = new CachedKeyboardMapper(BrowserKeyboardMapperFactory._createKeyboardMapper(this._activeKeymapInfo, config.mapAltGrToCtrlAlt));291}292return this._keyboardMapper;293}294295public validateCurrentKeyboardMapping(keyboardEvent: IKeyboardEvent): void {296if (!this._initialized) {297return;298}299300const isCurrentKeyboard = this._validateCurrentKeyboardMapping(keyboardEvent);301302if (isCurrentKeyboard) {303return;304}305306this._updateKeyboardLayoutAsync(true, keyboardEvent);307}308309public setKeyboardLayout(layoutName: string) {310const matchedLayouts: KeymapInfo[] = this.keymapInfos.filter(keymapInfo => getKeyboardLayoutId(keymapInfo.layout) === layoutName);311312if (matchedLayouts.length > 0) {313this.setActiveKeymapInfo(matchedLayouts[0]);314}315}316317private _setKeyboardData(keymapInfo: KeymapInfo): void {318this._initialized = true;319320this._keyboardMapper = null;321this._onDidChangeKeyboardMapper.fire();322}323324private static _createKeyboardMapper(keymapInfo: KeymapInfo, mapAltGrToCtrlAlt: boolean): IKeyboardMapper {325const rawMapping = keymapInfo.mapping;326const isUSStandard = !!keymapInfo.layout.isUSStandard;327if (OS === OperatingSystem.Windows) {328return new WindowsKeyboardMapper(isUSStandard, <IWindowsKeyboardMapping>rawMapping, mapAltGrToCtrlAlt);329}330if (Object.keys(rawMapping).length === 0) {331// Looks like reading the mappings failed (most likely Mac + Japanese/Chinese keyboard layouts)332return new FallbackKeyboardMapper(mapAltGrToCtrlAlt, OS);333}334335return new MacLinuxKeyboardMapper(isUSStandard, <IMacLinuxKeyboardMapping>rawMapping, mapAltGrToCtrlAlt, OS);336}337338//#region Browser API339private _validateCurrentKeyboardMapping(keyboardEvent: IKeyboardEvent): boolean {340if (!this._initialized) {341return true;342}343344const standardKeyboardEvent = keyboardEvent as StandardKeyboardEvent;345const currentKeymap = this._activeKeymapInfo;346if (!currentKeymap) {347return true;348}349350if (standardKeyboardEvent.browserEvent.key === 'Dead' || standardKeyboardEvent.browserEvent.isComposing) {351return true;352}353354const mapping = currentKeymap.mapping[standardKeyboardEvent.code];355356if (!mapping) {357return false;358}359360if (mapping.value === '') {361// The value is empty when the key is not a printable character, we skip validation.362if (keyboardEvent.ctrlKey || keyboardEvent.metaKey) {363setTimeout(() => {364this._getBrowserKeyMapping().then((keymap: IRawMixedKeyboardMapping | null) => {365if (this.isKeyMappingActive(keymap)) {366return;367}368369this.setLayoutFromBrowserAPI();370});371}, 350);372}373return true;374}375376const expectedValue = standardKeyboardEvent.altKey && standardKeyboardEvent.shiftKey ? mapping.withShiftAltGr :377standardKeyboardEvent.altKey ? mapping.withAltGr :378standardKeyboardEvent.shiftKey ? mapping.withShift : mapping.value;379380const isDead = (standardKeyboardEvent.altKey && standardKeyboardEvent.shiftKey && mapping.withShiftAltGrIsDeadKey) ||381(standardKeyboardEvent.altKey && mapping.withAltGrIsDeadKey) ||382(standardKeyboardEvent.shiftKey && mapping.withShiftIsDeadKey) ||383mapping.valueIsDeadKey;384385if (isDead && standardKeyboardEvent.browserEvent.key !== 'Dead') {386return false;387}388389// TODO, this assumption is wrong as `browserEvent.key` doesn't necessarily equal expectedValue from real keymap390if (!isDead && standardKeyboardEvent.browserEvent.key !== expectedValue) {391return false;392}393394return true;395}396397private async _getBrowserKeyMapping(keyboardEvent?: IKeyboardEvent): Promise<IRawMixedKeyboardMapping | null> {398if (this.keyboardLayoutMapAllowed) {399try {400return await (navigator as any).keyboard.getLayoutMap().then((e: any) => {401const ret: IKeyboardMapping = {};402for (const key of e) {403ret[key[0]] = {404'value': key[1],405'withShift': '',406'withAltGr': '',407'withShiftAltGr': ''408};409}410411return ret;412413// const matchedKeyboardLayout = this.getMatchedKeymapInfo(ret);414415// if (matchedKeyboardLayout) {416// return matchedKeyboardLayout.result.mapping;417// }418419// return null;420});421} catch {422// getLayoutMap can throw if invoked from a nested browsing context423this.keyboardLayoutMapAllowed = false;424}425}426if (keyboardEvent && !keyboardEvent.shiftKey && !keyboardEvent.altKey && !keyboardEvent.metaKey && !keyboardEvent.metaKey) {427const ret: IKeyboardMapping = {};428const standardKeyboardEvent = keyboardEvent as StandardKeyboardEvent;429ret[standardKeyboardEvent.browserEvent.code] = {430'value': standardKeyboardEvent.browserEvent.key,431'withShift': '',432'withAltGr': '',433'withShiftAltGr': ''434};435436const matchedKeyboardLayout = this.getMatchedKeymapInfo(ret);437438if (matchedKeyboardLayout) {439return ret;440}441442return null;443}444445return null;446}447448//#endregion449}450451export class BrowserKeyboardMapperFactory extends BrowserKeyboardMapperFactoryBase {452constructor(configurationService: IConfigurationService, notificationService: INotificationService, storageService: IStorageService, commandService: ICommandService) {453// super(notificationService, storageService, commandService);454super(configurationService);455456const platform = isWindows ? 'win' : isMacintosh ? 'darwin' : 'linux';457458import(FileAccess.asBrowserUri(`vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.${platform}.js` satisfies AppResourcePath).path).then((m) => {459const keymapInfos: IKeymapInfo[] = m.KeyboardLayoutContribution.INSTANCE.layoutInfos;460this._keymapInfos.push(...keymapInfos.map(info => (new KeymapInfo(info.layout, info.secondaryLayouts, info.mapping, info.isUserKeyboardLayout))));461this._mru = this._keymapInfos;462this._initialized = true;463this.setLayoutFromBrowserAPI();464});465}466}467468class UserKeyboardLayout extends Disposable {469470private readonly reloadConfigurationScheduler: RunOnceScheduler;471protected readonly _onDidChange: Emitter<void> = this._register(new Emitter<void>());472readonly onDidChange: Event<void> = this._onDidChange.event;473474private _keyboardLayout: KeymapInfo | null;475get keyboardLayout(): KeymapInfo | null { return this._keyboardLayout; }476477constructor(478private readonly keyboardLayoutResource: URI,479private readonly fileService: IFileService480) {481super();482483this._keyboardLayout = null;484485this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.reload().then(changed => {486if (changed) {487this._onDidChange.fire();488}489}), 50));490491this._register(Event.filter(this.fileService.onDidFilesChange, e => e.contains(this.keyboardLayoutResource))(() => this.reloadConfigurationScheduler.schedule()));492}493494async initialize(): Promise<void> {495await this.reload();496}497498private async reload(): Promise<boolean> {499const existing = this._keyboardLayout;500try {501const content = await this.fileService.readFile(this.keyboardLayoutResource);502const value = parse(content.value.toString());503if (getNodeType(value) === 'object') {504const layoutInfo = value.layout;505const mappings = value.rawMapping;506this._keyboardLayout = KeymapInfo.createKeyboardLayoutFromDebugInfo(layoutInfo, mappings, true);507} else {508this._keyboardLayout = null;509}510} catch (e) {511this._keyboardLayout = null;512}513514return existing ? !objects.equals(existing, this._keyboardLayout) : true;515}516517}518519export class BrowserKeyboardLayoutService extends Disposable implements IKeyboardLayoutService {520public _serviceBrand: undefined;521522private readonly _onDidChangeKeyboardLayout = new Emitter<void>();523public readonly onDidChangeKeyboardLayout: Event<void> = this._onDidChangeKeyboardLayout.event;524525private _userKeyboardLayout: UserKeyboardLayout;526527private readonly _factory: BrowserKeyboardMapperFactory;528private _keyboardLayoutMode: string;529530constructor(531@IEnvironmentService environmentService: IEnvironmentService,532@IFileService fileService: IFileService,533@INotificationService notificationService: INotificationService,534@IStorageService storageService: IStorageService,535@ICommandService commandService: ICommandService,536@IConfigurationService private configurationService: IConfigurationService,537) {538super();539const keyboardConfig = configurationService.getValue<{ layout: string }>('keyboard');540const layout = keyboardConfig.layout;541this._keyboardLayoutMode = layout ?? 'autodetect';542this._factory = new BrowserKeyboardMapperFactory(configurationService, notificationService, storageService, commandService);543544this._register(this._factory.onDidChangeKeyboardMapper(() => {545this._onDidChangeKeyboardLayout.fire();546}));547548if (layout && layout !== 'autodetect') {549// set keyboard layout550this._factory.setKeyboardLayout(layout);551}552553this._register(configurationService.onDidChangeConfiguration(e => {554if (e.affectsConfiguration('keyboard.layout')) {555const keyboardConfig = configurationService.getValue<{ layout: string }>('keyboard');556const layout = keyboardConfig.layout;557this._keyboardLayoutMode = layout;558559if (layout === 'autodetect') {560this._factory.setLayoutFromBrowserAPI();561} else {562this._factory.setKeyboardLayout(layout);563}564}565}));566567this._userKeyboardLayout = new UserKeyboardLayout(environmentService.keyboardLayoutResource, fileService);568this._userKeyboardLayout.initialize().then(() => {569if (this._userKeyboardLayout.keyboardLayout) {570this._factory.registerKeyboardLayout(this._userKeyboardLayout.keyboardLayout);571572this.setUserKeyboardLayoutIfMatched();573}574});575576this._register(this._userKeyboardLayout.onDidChange(() => {577const userKeyboardLayouts = this._factory.keymapInfos.filter(layout => layout.isUserKeyboardLayout);578579if (userKeyboardLayouts.length) {580if (this._userKeyboardLayout.keyboardLayout) {581userKeyboardLayouts[0].update(this._userKeyboardLayout.keyboardLayout);582} else {583this._factory.removeKeyboardLayout(userKeyboardLayouts[0]);584}585} else {586if (this._userKeyboardLayout.keyboardLayout) {587this._factory.registerKeyboardLayout(this._userKeyboardLayout.keyboardLayout);588}589}590591this.setUserKeyboardLayoutIfMatched();592}));593}594595setUserKeyboardLayoutIfMatched() {596const keyboardConfig = this.configurationService.getValue<{ layout: string }>('keyboard');597const layout = keyboardConfig.layout;598599if (layout && this._userKeyboardLayout.keyboardLayout) {600if (getKeyboardLayoutId(this._userKeyboardLayout.keyboardLayout.layout) === layout && this._factory.activeKeymap) {601602if (!this._userKeyboardLayout.keyboardLayout.equal(this._factory.activeKeymap)) {603this._factory.setActiveKeymapInfo(this._userKeyboardLayout.keyboardLayout);604}605}606}607}608609getKeyboardMapper(): IKeyboardMapper {610return this._factory.getKeyboardMapper();611}612613public getCurrentKeyboardLayout(): IKeyboardLayoutInfo | null {614return this._factory.activeKeyboardLayout;615}616617public getAllKeyboardLayouts(): IKeyboardLayoutInfo[] {618return this._factory.keyboardLayouts;619}620621public getRawKeyboardMapping(): IKeyboardMapping | null {622return this._factory.activeKeyMapping;623}624625public validateCurrentKeyboardMapping(keyboardEvent: IKeyboardEvent): void {626if (this._keyboardLayoutMode !== 'autodetect') {627return;628}629630this._factory.validateCurrentKeyboardMapping(keyboardEvent);631}632}633634registerSingleton(IKeyboardLayoutService, BrowserKeyboardLayoutService, InstantiationType.Delayed);635636// Configuration637const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigExtensions.Configuration);638const keyboardConfiguration: IConfigurationNode = {639'id': 'keyboard',640'order': 15,641'type': 'object',642'title': nls.localize('keyboardConfigurationTitle', "Keyboard"),643'properties': {644'keyboard.layout': {645'type': 'string',646'default': 'autodetect',647'description': nls.localize('keyboard.layout.config', "Control the keyboard layout used in web.")648}649}650};651652configurationRegistry.registerConfiguration(keyboardConfiguration);653654655