UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

470 lines (423 loc) • 16.3 kB
// ***************************************************************************** // Copyright (C) 2019 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, postConstruct, inject } from 'inversify'; import { isOSX } from '../../common/os'; import { Emitter, Event } from '../../common/event'; import { ILogger } from '../../common/logger'; import { Deferred } from '../../common/promise-util'; import { NativeKeyboardLayout, KeyboardLayoutProvider, KeyboardLayoutChangeNotifier, KeyValidator, KeyValidationInput } from '../../common/keyboard/keyboard-layout-provider'; import { LocalStorageService } from '../storage-service'; export type KeyboardLayoutSource = 'navigator.keyboard' | 'user-choice' | 'pressed-keys'; @injectable() export class BrowserKeyboardLayoutProvider implements KeyboardLayoutProvider, KeyboardLayoutChangeNotifier, KeyValidator { @inject(ILogger) protected readonly logger: ILogger; @inject(LocalStorageService) protected readonly storageService: LocalStorageService; protected readonly initialized = new Deferred(); protected readonly nativeLayoutChanged = new Emitter<NativeKeyboardLayout>(); get onDidChangeNativeLayout(): Event<NativeKeyboardLayout> { return this.nativeLayoutChanged.event; } protected readonly tester = new KeyboardTester(loadAllLayouts()); protected source: KeyboardLayoutSource = 'pressed-keys'; protected currentLayout: KeyboardLayoutData = DEFAULT_LAYOUT_DATA; get allLayoutData(): KeyboardLayoutData[] { return this.tester.candidates.slice(); } get currentLayoutData(): KeyboardLayoutData { return this.currentLayout; } get currentLayoutSource(): KeyboardLayoutSource { return this.source; } @postConstruct() protected init(): void { this.doInit(); } protected async doInit(): Promise<void> { await this.loadState(); const keyboard = (navigator as NavigatorExtension).keyboard; if (keyboard && keyboard.addEventListener) { keyboard.addEventListener('layoutchange', async () => { const newLayout = await this.getNativeLayout(); this.nativeLayoutChanged.fire(newLayout); }); } this.initialized.resolve(); } async getNativeLayout(): Promise<NativeKeyboardLayout> { await this.initialized.promise; if (this.source === 'user-choice') { return this.currentLayout.raw; } const [layout, source] = await this.autodetect(); this.setCurrent(layout, source); return layout.raw; } /** * Set user-chosen keyboard layout data. */ async setLayoutData(layout: KeyboardLayoutData | 'autodetect'): Promise<KeyboardLayoutData> { if (layout === 'autodetect') { if (this.source === 'user-choice') { const [newLayout, source] = await this.autodetect(); this.setCurrent(newLayout, source); this.nativeLayoutChanged.fire(newLayout.raw); return newLayout; } return this.currentLayout; } else { if (this.source !== 'user-choice' || layout !== this.currentLayout) { this.setCurrent(layout, 'user-choice'); this.nativeLayoutChanged.fire(layout.raw); } return layout; } } /** * Test all known keyboard layouts with the given combination of pressed key and * produced character. Matching layouts have their score increased (see class * KeyboardTester). If this leads to a change of the top-scoring layout, a layout * change event is fired. */ validateKey(keyCode: KeyValidationInput): void { if (this.source !== 'pressed-keys') { return; } const accepted = this.tester.updateScores(keyCode); if (!accepted) { return; } const layout = this.selectLayout(); if (layout !== this.currentLayout && layout !== DEFAULT_LAYOUT_DATA) { this.setCurrent(layout, 'pressed-keys'); this.nativeLayoutChanged.fire(layout.raw); } } protected setCurrent(layout: KeyboardLayoutData, source: KeyboardLayoutSource): void { this.currentLayout = layout; this.source = source; this.saveState(); if (this.tester.inputCount && (source === 'pressed-keys' || source === 'navigator.keyboard')) { const from = source === 'pressed-keys' ? 'pressed keys' : 'browser API'; const hardware = layout.hardware === 'mac' ? 'Mac' : 'PC'; this.logger.info(`Detected keyboard layout from ${from}: ${layout.name} (${hardware})`); } } protected async autodetect(): Promise<[KeyboardLayoutData, KeyboardLayoutSource]> { const keyboard = (navigator as NavigatorExtension).keyboard; if (keyboard && keyboard.getLayoutMap) { try { const layoutMap = await keyboard.getLayoutMap(); this.testLayoutMap(layoutMap); return [this.selectLayout(), 'navigator.keyboard']; } catch (error) { this.logger.warn('Failed to obtain keyboard layout map.', error); } } return [this.selectLayout(), 'pressed-keys']; } /** * @param layoutMap a keyboard layout map according to https://wicg.github.io/keyboard-map/ */ protected testLayoutMap(layoutMap: KeyboardLayoutMap): void { this.tester.reset(); for (const [code, key] of layoutMap.entries()) { this.tester.updateScores({ code, character: key }); } } /** * Select a layout based on the current tester state and the operating system * and language detected from the browser. */ protected selectLayout(): KeyboardLayoutData { const candidates = this.tester.candidates; const scores = this.tester.scores; const topScore = this.tester.topScore; const language = navigator.language; let matchingOScount = 0; let topScoringCount = 0; for (let i = 0; i < candidates.length; i++) { if (scores[i] === topScore) { const candidate = candidates[i]; if (osMatches(candidate.hardware)) { if (language && language.startsWith(candidate.language)) { return candidate; } matchingOScount++; } topScoringCount++; } } if (matchingOScount >= 1) { return candidates.find((c, i) => scores[i] === topScore && osMatches(c.hardware))!; } if (topScoringCount >= 1) { return candidates.find((_, i) => scores[i] === topScore)!; } return DEFAULT_LAYOUT_DATA; } protected saveState(): Promise<void> { const data: LayoutProviderState = { tester: this.tester.getState(), source: this.source, currentLayout: this.currentLayout !== DEFAULT_LAYOUT_DATA ? getLayoutId(this.currentLayout) : undefined }; return this.storageService.setData('keyboard', data); } protected async loadState(): Promise<void> { const data = await this.storageService.getData<LayoutProviderState>('keyboard'); if (data) { this.tester.setState(data.tester || {}); this.source = data.source || 'pressed-keys'; if (data.currentLayout) { const layout = this.tester.candidates.find(c => getLayoutId(c) === data.currentLayout); if (layout) { this.currentLayout = layout; } } else { this.currentLayout = DEFAULT_LAYOUT_DATA; } } } } export interface KeyboardLayoutData { name: string; hardware: 'pc' | 'mac'; language: string; raw: NativeKeyboardLayout; } function osMatches(hardware: 'pc' | 'mac'): boolean { return isOSX ? hardware === 'mac' : hardware === 'pc'; } /** * This is the fallback keyboard layout selected when nothing else matches. * It has an empty mapping, so user inputs are handled like with a standard US keyboard. */ export const DEFAULT_LAYOUT_DATA: KeyboardLayoutData = { name: 'US', hardware: isOSX ? 'mac' : 'pc', language: 'en', raw: { // eslint-disable-next-line @typescript-eslint/no-explicit-any info: {} as any, mapping: {} } }; export interface LayoutProviderState { tester?: KeyboardTesterState; source?: KeyboardLayoutSource; currentLayout?: string; } export interface KeyboardTesterState { scores?: { [id: string]: number }; topScore?: number; testedInputs?: { [key: string]: string } } /** * Holds score values for all known keyboard layouts. Scores are updated * by comparing key codes with the corresponding character produced by * the user's keyboard. */ export class KeyboardTester { readonly scores: number[]; topScore: number = 0; private readonly testedInputs = new Map<string, string>(); get inputCount(): number { return this.testedInputs.size; } constructor(readonly candidates: KeyboardLayoutData[]) { this.scores = this.candidates.map(() => 0); } reset(): void { for (let i = 0; i < this.scores.length; i++) { this.scores[i] = 0; } this.topScore = 0; this.testedInputs.clear(); } updateScores(input: KeyValidationInput): boolean { let property: 'value' | 'withShift' | 'withAltGr' | 'withShiftAltGr'; if (input.shiftKey && input.altKey) { property = 'withShiftAltGr'; } else if (input.shiftKey) { property = 'withShift'; } else if (input.altKey) { property = 'withAltGr'; } else { property = 'value'; } const inputKey = `${input.code}.${property}`; if (this.testedInputs.has(inputKey)) { if (this.testedInputs.get(inputKey) === input.character) { return false; } else { // The same input keystroke leads to a different character: // probably a keyboard layout change, so forget all previous scores this.reset(); } } const scores = this.scores; for (let i = 0; i < this.candidates.length; i++) { scores[i] += this.testCandidate(this.candidates[i], input, property); if (scores[i] > this.topScore) { this.topScore = scores[i]; } } this.testedInputs.set(inputKey, input.character); return true; } protected testCandidate(candidate: KeyboardLayoutData, input: KeyValidationInput, property: 'value' | 'withShift' | 'withAltGr' | 'withShiftAltGr'): number { const keyMapping = candidate.raw.mapping[input.code]; if (keyMapping && keyMapping[property]) { return keyMapping[property] === input.character ? 1 : 0; } else { return 0; } } getState(): KeyboardTesterState { const scores: { [id: string]: number } = {}; for (let i = 0; i < this.scores.length; i++) { scores[getLayoutId(this.candidates[i])] = this.scores[i]; } const testedInputs: { [key: string]: string } = {}; for (const [key, character] of this.testedInputs.entries()) { testedInputs[key] = character; } return { scores, topScore: this.topScore, testedInputs }; } setState(state: KeyboardTesterState): void { this.reset(); if (state.scores) { const layoutIds = this.candidates.map(getLayoutId); for (const id in state.scores) { if (state.scores.hasOwnProperty(id)) { const index = layoutIds.indexOf(id); if (index > 0) { this.scores[index] = state.scores[id]; } } } } if (state.topScore) { this.topScore = state.topScore; } if (state.testedInputs) { for (const key in state.testedInputs) { if (state.testedInputs.hasOwnProperty(key)) { this.testedInputs.set(key, state.testedInputs[key]); } } } } } /** * API specified by https://wicg.github.io/keyboard-map/ */ interface NavigatorExtension extends Navigator { keyboard: Keyboard; } interface Keyboard { getLayoutMap(): Promise<KeyboardLayoutMap>; addEventListener(type: 'layoutchange', listener: EventListenerOrEventListenerObject): void; } type KeyboardLayoutMap = Map<string, string>; function getLayoutId(layout: KeyboardLayoutData): string { return `${layout.language}-${layout.name.replace(' ', '_')}-${layout.hardware}`; } /** * Keyboard layout files are expected to have the following name scheme: * `language-name-hardware.json` * * - `language`: A language subtag according to IETF BCP 47 * - `name`: Display name of the keyboard layout (without dashes) * - `hardware`: `pc` or `mac` */ function loadLayout(fileName: string): KeyboardLayoutData { const [language, name, hardware] = fileName.split('-'); return { name: name.replace('_', ' '), hardware: hardware as 'pc' | 'mac', language, // Webpack knows what to do here and it should bundle all files under `../../../src/common/keyboard/layouts/` // eslint-disable-next-line import/no-dynamic-require raw: require('../../../src/common/keyboard/layouts/' + fileName + '.json') }; } function loadAllLayouts(): KeyboardLayoutData[] { // The order of keyboard layouts is relevant for autodetection. Layouts with // lower index have a higher chance of being selected. // The current ordering approach is to sort by estimated number of developers // in the respective country (taken from the Stack Overflow Developer Survey), // but keeping all layouts of the same language together. return [ 'en-US-pc', 'en-US-mac', 'en-Dvorak-pc', 'en-Dvorak-mac', 'en-Dvorak_Lefthanded-pc', 'en-Dvorak_Lefthanded-mac', 'en-Dvorak_Righthanded-pc', 'en-Dvorak_Righthanded-mac', 'en-Colemak-mac', 'en-British-pc', 'en-British-mac', 'de-German-pc', 'de-German-mac', 'de-Swiss_German-pc', 'de-Swiss_German-mac', 'fr-French-pc', 'fr-French-mac', 'fr-Canadian_French-pc', 'fr-Canadian_French-mac', 'fr-Swiss_French-pc', 'fr-Swiss_French-mac', 'fr-Bepo-pc', 'pt-Portuguese-pc', 'pt-Portuguese-mac', 'pt-Brazilian-mac', 'pl-Polish-pc', 'pl-Polish-mac', 'nl-Dutch-pc', 'nl-Dutch-mac', 'es-Spanish-pc', 'es-Spanish-mac', 'it-Italian-pc', 'it-Italian-mac', 'sv-Swedish-pc', 'sv-Swedish-mac', 'tr-Turkish_Q-pc', 'tr-Turkish_Q-mac', 'cs-Czech-pc', 'cs-Czech-mac', 'ro-Romanian-pc', 'ro-Romanian-mac', 'da-Danish-pc', 'da-Danish-mac', 'nb-Norwegian-pc', 'nb-Norwegian-mac', 'hu-Hungarian-pc', 'hu-Hungarian-mac' ].map(loadLayout); }