Skip to content

Commit

Permalink
Several improvements in browser keyboard handling
Browse files Browse the repository at this point in the history
 - #4879: added command to choose a keyboard layout
 - BrowserKeyboardLayoutProvider state is stored in LocalStorage
 - Added several static keyboard layouts

Signed-off-by: Miro Spönemann <miro.spoenemann@typefox.io>
  • Loading branch information
spoenemann committed May 8, 2019
1 parent 92c66ba commit 5ff8d79
Show file tree
Hide file tree
Showing 53 changed files with 477 additions and 49 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## v0.7.0

- [core] added support for several international keyboard layouts
- [core] implemented auto-detection of keyboard layout based on pressed keys
- [core] added command to manually choose a keyboard layout

Breaking changes:

- [preferences] refactored to integrate launch configurations as preferences
Expand Down
46 changes: 42 additions & 4 deletions packages/core/scripts/generate-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,30 @@ const fs = require('fs');
const electron = require('electron');

/*
* Generate keyboard layouts for using Theia as web application.
*
* Usage:
* yarn generate-layout [--info] [--pretty] [--output file]
* yarn generate-layout [--info] [--all] [--pretty] [--output file]
*
* --info Print the keyboard layout information; if omitted, the full
* keyboard layout with info and mapping is printed.
* --all Include all keys in the output, not only the relevant ones.
* --pretty Pretty-print the JSON output.
* --output file Write the output to the given file instead of stdout.
*
* Hint: keyboard layouts are stored in packages/core/src/common/keyboard/layouts
* and have the following file 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`
*/
const args = parseArgs(process.argv);
const printInfo = args.info;
const prettyPrint = args.pretty;
const outFile = args.output;
const printInfo = args['info'];
const includeAll = args['all'];
const prettyPrint = args['pretty'];
const outFile = args['output'];

let output;
if (printInfo) {
Expand All @@ -41,6 +53,32 @@ if (printInfo) {
info: nativeKeymap.getCurrentKeyboardLayout(),
mapping: nativeKeymap.getKeyMap()
};
if (!includeAll) {
// We store only key codes for the "writing system keys" as defined here:
// https://w3c.github.io/uievents-code/#writing-system-keys
const ACCEPTED_CODES = [
'KeyA', 'KeyB', 'KeyC', 'KeyD', 'KeyE', 'KeyF', 'KeyG', 'KeyH', 'KeyI', 'KeyJ', 'KeyK', 'KeyL', 'KeyM',
'KeyN', 'KeyO', 'KeyP', 'KeyQ', 'KeyR', 'KeyS', 'KeyT', 'KeyU', 'KeyV', 'KeyW', 'KeyX', 'KeyY', 'KeyZ',
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
'Minus', 'Equal', 'BracketLeft', 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Backquote',
'Comma', 'Period', 'Slash', 'IntlBackslash', 'IntlRo', 'IntlYen'
];
const ACCEPTED_VARIANTS = ['value', 'withShift', 'withAltGr', 'withShiftAltGr', 'vkey'];
for (let code of Object.keys(output.mapping)) {
if (ACCEPTED_CODES.indexOf(code) < 0) {
delete output.mapping[code];
} else {
for (let variant of Object.keys(output.mapping[code])) {
if (ACCEPTED_VARIANTS.indexOf(variant) < 0 || output.mapping[code][variant] === '') {
delete output.mapping[code][variant];
}
}
if (Object.keys(output.mapping[code]).length === 0) {
delete output.mapping[code];
}
}
}
}
}

const stringOutput = JSON.stringify(output, undefined, prettyPrint ? 2 : undefined);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/********************************************************************************
* 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 WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable } from 'inversify';
import { isOSX } from '../../common/os';
import { CommandContribution, CommandRegistry, Command } from '../../common/command';
import { QuickPickService, QuickPickItem } from '../../common/quick-pick-service';
import { BrowserKeyboardLayoutProvider, KeyboardLayoutData } from './browser-keyboard-layout-provider';

export namespace KeyboardCommands {

const KEYBOARD_CATEGORY = 'Keyboard';

export const CHOOSE_KEYBOARD_LAYOUT: Command = {
id: 'core.keyboard.choose',
category: KEYBOARD_CATEGORY,
label: 'Choose Keyboard Layout',
};

}

@injectable()
export class BrowserKeyboardFrontendContribution implements CommandContribution {

@inject(BrowserKeyboardLayoutProvider)
protected readonly layoutProvider: BrowserKeyboardLayoutProvider;

@inject(QuickPickService)
protected readonly quickPickService: QuickPickService;

registerCommands(commandRegistry: CommandRegistry): void {
commandRegistry.registerCommand(KeyboardCommands.CHOOSE_KEYBOARD_LAYOUT, {
execute: () => this.chooseLayout()
});
}

protected async chooseLayout() {
const current = this.layoutProvider.currentLayoutData;
const autodetect: QuickPickItem<'autodetect'> = {
label: 'Auto-detect',
description: this.layoutProvider.currentLayoutSource !== 'user-choice' ? `(current: ${current.name})` : undefined,
detail: 'Try to detect the keyboard layout from browser information and pressed keys.',
value: 'autodetect'
};
const pcLayouts = this.layoutProvider.allLayoutData
.filter(layout => layout.hardware === 'pc')
.sort((a, b) => compare(a.name, b.name))
.map(layout => this.toQuickPickValue(layout, current === layout));
const macLayouts = this.layoutProvider.allLayoutData
.filter(layout => layout.hardware === 'mac')
.sort((a, b) => compare(a.name, b.name))
.map(layout => this.toQuickPickValue(layout, current === layout));
let layouts: QuickPickItem<KeyboardLayoutData | 'autodetect'>[];
if (isOSX) {
layouts = [
autodetect,
{ type: 'separator', label: 'Mac Keyboards' }, ...macLayouts,
{ type: 'separator', label: 'PC Keyboards' }, ...pcLayouts
];
} else {
layouts = [
autodetect,
{ type: 'separator', label: 'PC Keyboards' }, ...pcLayouts,
{ type: 'separator', label: 'Mac Keyboards' }, ...macLayouts
];
}
const chosen = await this.quickPickService.show(layouts, { placeholder: 'Choose a keyboard layout' });
if (chosen) {
return this.layoutProvider.setLayoutData(chosen);
}
}

protected toQuickPickValue(layout: KeyboardLayoutData, isCurrent: boolean): QuickPickItem<KeyboardLayoutData> {
return {
label: layout.name,
description: `${layout.hardware === 'mac' ? 'Mac' : 'PC'} (${layout.language})${isCurrent ? ' - current layout' : ''}`,
value: layout
};
}

}

function compare(a: string, b: string): number {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@

import { Container, injectable } from 'inversify';
import { IMacKeyboardLayoutInfo } from 'native-keymap';
import * as os from '../../common/os';
import { ILogger, Loggable } from '../../common/logger';
import * as chai from 'chai';
import * as sinon from 'sinon';
import { BrowserKeyboardLayoutProvider, DEFAULT_LAYOUT_DATA } from './browser-keyboard-layout-provider';
import * as os from '../../common/os';
import { ILogger, Loggable } from '../../common/logger';
import { LocalStorageService } from '../storage-service';
import { MessageService } from '../../common/message-service';
import { WindowService } from '../window/window-service';
import { BrowserKeyboardLayoutProvider } from './browser-keyboard-layout-provider';
import { Key, KeyCode } from './keys';

describe('browser keyboard layout provider', function () {
Expand All @@ -46,10 +49,13 @@ describe('browser keyboard layout provider', function () {
// tslint:disable-next-line:no-any
stubNavigator = sinon.stub(global, 'navigator' as any).value({});
const container = new Container();
container.bind(BrowserKeyboardLayoutProvider).toSelf().inSingletonScope();
container.bind(BrowserKeyboardLayoutProvider).toSelf();
container.bind(ILogger).to(MockLogger);
container.bind(LocalStorageService).toSelf().inSingletonScope();
container.bind(MessageService).toConstantValue({} as MessageService);
container.bind(WindowService).toConstantValue({} as WindowService);
const service = container.get(BrowserKeyboardLayoutProvider);
return service;
return { service, container };
};

afterEach(() => {
Expand All @@ -59,31 +65,31 @@ describe('browser keyboard layout provider', function () {
});

it('detects German Mac layout', async () => {
const service = setup('mac');
const { service } = setup('mac');
let currentLayout = await service.getNativeLayout();
service.onDidChangeNativeLayout(l => {
currentLayout = l;
});

chai.expect(currentLayout).to.equal(DEFAULT_LAYOUT_DATA.raw);
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.US');
service.validateKeyCode(new KeyCode({ key: Key.SEMICOLON, character: 'ö' }));
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.German');
});

it('detects French Mac layout', async () => {
const service = setup('mac');
const { service } = setup('mac');
let currentLayout = await service.getNativeLayout();
service.onDidChangeNativeLayout(l => {
currentLayout = l;
});

chai.expect(currentLayout).to.equal(DEFAULT_LAYOUT_DATA.raw);
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.US');
service.validateKeyCode(new KeyCode({ key: Key.SEMICOLON, character: 'm' }));
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.French');
});

it('detects keyboard layout change', async () => {
const service = setup('mac');
const { service } = setup('mac');
let currentLayout = await service.getNativeLayout();
service.onDidChangeNativeLayout(l => {
currentLayout = l;
Expand All @@ -97,6 +103,43 @@ describe('browser keyboard layout provider', function () {
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.French');
});

it('applies layout chosen by the user', async () => {
const { service } = setup('mac');
let currentLayout = await service.getNativeLayout();
service.onDidChangeNativeLayout(l => {
currentLayout = l;
});

service.validateKeyCode(new KeyCode({ key: Key.SEMICOLON, character: 'm' }));
const spanishLayout = service.allLayoutData.find(data => data.name === 'Spanish' && data.hardware === 'mac')!;
await service.setLayoutData(spanishLayout);
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.Spanish');
await service.setLayoutData('autodetect');
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.French');
});

it('restores pressed keys from last session', async () => {
const { service, container } = setup('mac');

service.validateKeyCode(new KeyCode({ key: Key.SEMICOLON, character: 'm' }));
const service2 = container.get(BrowserKeyboardLayoutProvider);
chai.expect(service2).to.not.equal(service);
const currentLayout = await service2.getNativeLayout();
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.French');
});

it('restores user selection from last session', async () => {
const { service, container } = setup('mac');

const spanishLayout = service.allLayoutData.find(data => data.name === 'Spanish' && data.hardware === 'mac')!;
await service.setLayoutData(spanishLayout);
const service2 = container.get(BrowserKeyboardLayoutProvider);
chai.expect(service2).to.not.equal(service);
service2.validateKeyCode(new KeyCode({ key: Key.SEMICOLON, character: 'm' }));
const currentLayout = await service2.getNativeLayout();
chai.expect((currentLayout.info as IMacKeyboardLayoutInfo).id).to.equal('com.apple.keylayout.Spanish');
});

});

@injectable()
Expand Down
Loading

0 comments on commit 5ff8d79

Please sign in to comment.