Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make keyboard layouts in charge of modifier keys and formatting of keys #257

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
130 changes: 37 additions & 93 deletions packages/commands/src/index.ts
Original file line number Diff line number Diff line change
@@ -1062,26 +1062,6 @@ export namespace CommandRegistry {
*/
shift: boolean;

/**
* Whether `'ArrowLeft'` appears in the keystroke.
*/
arrowLeft: boolean;

/**
* Whether `'ArrowUp'` appears in the keystroke.
*/
arrowUp: boolean;

/**
* Whether `'ArrowRight'` appears in the keystroke.
*/
arrowRight: boolean;

/**
* Whether `'ArrowDown'` appears in the keystroke.
*/
arrowDown: boolean;

/**
* The primary key for the keystroke.
*/
@@ -1116,10 +1096,6 @@ export namespace CommandRegistry {
let cmd = false;
let ctrl = false;
let shift = false;
let arrowLeft = false;
let arrowUp = false;
let arrowRight = false;
let arrowDown = false;
for (let token of keystroke.split(/\s+/)) {
if (token === 'Accel') {
if (Platform.IS_MAC) {
@@ -1135,29 +1111,11 @@ export namespace CommandRegistry {
ctrl = true;
} else if (token === 'Shift') {
shift = true;
} else if (token === 'ArrowLeft') {
arrowLeft = true;
} else if (token === 'ArrowUp') {
arrowUp = true;
} else if (token === 'ArrowRight') {
arrowRight = true;
} else if (token === 'ArrowDown') {
arrowDown = true;
} else if (token.length > 0) {
key = token;
}
}
return {
cmd,
ctrl,
alt,
shift,
key,
arrowLeft,
arrowUp,
arrowRight,
arrowDown
};
return { cmd, ctrl, alt, shift, key };
}

/**
@@ -1214,45 +1172,25 @@ export namespace CommandRegistry {
* Format a keystroke for display on the local system.
*/
export function formatKeystroke(keystroke: string): string {
let mods = '';
let parts = parseKeystroke(keystroke);
if (Platform.IS_MAC) {
if (parts.ctrl) {
mods += '\u2303 ';
}
if (parts.alt) {
mods += '\u2325 ';
}
if (parts.shift) {
mods += '\u21E7 ';
}
if (parts.cmd) {
mods += '\u2318 ';
}
if (parts.arrowLeft) {
mods += '\u2190 ';
}
if (parts.arrowUp) {
mods += '\u2191 ';
}
if (parts.arrowRight) {
mods += '\u2192 ';
}
if (parts.arrowDown) {
mods += '\u2193 ';
}
} else {
if (parts.ctrl) {
mods += 'Ctrl+';
}
if (parts.alt) {
mods += 'Alt+';
}
if (parts.shift) {
mods += 'Shift+';
}
let layout = getKeyboardLayout();
let label = [];
let separator = Platform.IS_MAC ? ' ' : '+';
if (parts.ctrl) {
label.push('Ctrl');
}
return mods + parts.key;
if (parts.alt) {
label.push('Alt');
}
if (parts.shift) {
label.push('Shift');
}
if (Platform.IS_MAC && parts.cmd) {
// Keyboard layouts label Command as Meta
label.push('Meta');
}
label.push(parts.key);
return label.map(k => layout.formatKey(k)).join(separator);
}

/**
@@ -1282,20 +1220,25 @@ export namespace CommandRegistry {
if (!key || layout.isModifierKey(key)) {
return '';
}
let mods = '';
if (event.ctrlKey) {
mods += 'Ctrl ';
}
if (event.altKey) {
mods += 'Alt ';
}
if (event.shiftKey) {
mods += 'Shift ';
}
if (event.metaKey && Platform.IS_MAC) {
mods += 'Cmd ';
// Loop through modifier keys in order to test them
let mods = [];
for (let mod of layout.modifierKeys()) {
// Special treatment for Meta (Cmd on macOS) for backwards compatibility
if (mod === 'Meta') {
if (Platform.IS_MAC && event.getModifierState(mod)) {
mods.push('Cmd');
}
} else if (mod === 'Ctrl') {
// For backwards compatibility, our keyboard layout still uses Ctrl.
if (event.getModifierState('Control')) {
mods.push('Ctrl');
}
} else if (event.getModifierState(mod)) {
mods.push(mod);
}
}
return mods + key;
mods.push(key);
return mods.join(' ');
}
}

@@ -1635,6 +1578,7 @@ namespace Private {
clone.shiftKey = event.shiftKey || false;
clone.metaKey = event.metaKey || false;
clone.view = event.view || window;
clone.getModifierState = (key: string) => event.getModifierState(key);
return clone as KeyboardEvent;
}
}
96 changes: 60 additions & 36 deletions packages/commands/tests/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -26,6 +26,29 @@ const NULL_COMMAND = {
}
};

function generateKeydown(options: any) {
let event = generate('keydown', options);
(event as any).getModifierState = (key: string) => {
let state = false;
switch (key) {
case 'Alt':
state = options.altKey || false;
break;
case 'Control':
state = options.ctrlKey || false;
break;
case 'Shift':
state = options.shiftKey || false;
break;
case 'Meta':
state = options.metaKey || false;
break;
}
return state;
};
return event;
}

describe('@lumino/commands', () => {
describe('CommandRegistry', () => {
let registry: CommandRegistry = null!;
@@ -581,7 +604,7 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let event = generate('keydown', { keyCode: 59, ctrlKey: true });
let event = generateKeydown({ keyCode: 59, ctrlKey: true });
elem.dispatchEvent(event);
expect(called).to.equal(true);
});
@@ -599,7 +622,7 @@ describe('@lumino/commands', () => {
command: 'test'
});
binding.dispose();
let event = generate('keydown', { keyCode: 59, ctrlKey: true });
let event = generateKeydown({ keyCode: 59, ctrlKey: true });
elem.dispatchEvent(event);
expect(called).to.equal(false);
});
@@ -641,7 +664,7 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let event = generate('keydown', { keyCode: 59, ctrlKey: true });
let event = generateKeydown({ keyCode: 59, ctrlKey: true });
elem.dispatchEvent(event);
expect(called).to.equal(true);
});
@@ -659,7 +682,7 @@ describe('@lumino/commands', () => {
command: 'test'
});
parent.setAttribute('data-lm-suppress-shortcuts', 'true');
let event = generate('keydown', { keyCode: 59, ctrlKey: true });
let event = generateKeydown({ keyCode: 59, ctrlKey: true });
elem.dispatchEvent(event);
expect(called).to.equal(false);
});
@@ -676,7 +699,7 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let event = generate('keydown', { keyCode: 45, ctrlKey: true });
let event = generateKeydown({ keyCode: 45, ctrlKey: true });
elem.dispatchEvent(event);
expect(called).to.equal(false);
});
@@ -693,8 +716,8 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let eventAlt = generate('keydown', { keyCode: 83, altKey: true });
let eventShift = generate('keydown', { keyCode: 83, shiftKey: true });
let eventAlt = generateKeydown({ keyCode: 83, altKey: true });
let eventShift = generateKeydown({ keyCode: 83, shiftKey: true });
elem.dispatchEvent(eventAlt);
expect(count).to.equal(0);
elem.dispatchEvent(eventShift);
@@ -713,17 +736,17 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let eventK = generate('keydown', { keyCode: 75, ctrlKey: true });
let eventL = generate('keydown', { keyCode: 76, ctrlKey: true });
let eventK = generateKeydown({ keyCode: 75, ctrlKey: true });
let eventL = generateKeydown({ keyCode: 76, ctrlKey: true });
elem.dispatchEvent(eventK);
expect(count).to.equal(0);
elem.dispatchEvent(eventL);
expect(count).to.equal(1);
elem.dispatchEvent(generate('keydown', eventL)); // Don't reuse; clone.
elem.dispatchEvent(generateKeydown(eventL)); // Don't reuse; clone.
expect(count).to.equal(1);
elem.dispatchEvent(generate('keydown', eventK)); // Don't reuse; clone.
elem.dispatchEvent(generateKeydown(eventK)); // Don't reuse; clone.
expect(count).to.equal(1);
elem.dispatchEvent(generate('keydown', eventL)); // Don't reuse; clone.
elem.dispatchEvent(generateKeydown(eventL)); // Don't reuse; clone.
expect(count).to.equal(2);
});

@@ -739,7 +762,7 @@ describe('@lumino/commands', () => {
selector: '.inaccessible-scope',
command: 'test'
});
let event = generate('keydown', { keyCode: 80, shiftKey: true });
let event = generateKeydown({ keyCode: 80, shiftKey: true });
expect(count).to.equal(0);
elem.dispatchEvent(event);
expect(count).to.equal(0);
@@ -757,7 +780,7 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let event = generate('keydown', { keyCode: 17 });
let event = generateKeydown({ keyCode: 17 });
expect(count).to.equal(0);
elem.dispatchEvent(event);
expect(count).to.equal(0);
@@ -786,8 +809,8 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test2'
});
let event1 = generate('keydown', { keyCode: 83, ctrlKey: true });
let event2 = generate('keydown', { keyCode: 68, ctrlKey: true });
let event1 = generateKeydown({ keyCode: 83, ctrlKey: true });
let event2 = generateKeydown({ keyCode: 68, ctrlKey: true });
expect(count1).to.equal(0);
expect(count2).to.equal(0);
elem.dispatchEvent(event1);
@@ -821,13 +844,13 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test2'
});
let event1 = generate('keydown', {
let event1 = generateKeydown({
keyCode: 84,
ctrlKey: true,
altKey: true,
shiftKey: true
});
let event2 = generate('keydown', {
let event2 = generateKeydown({
keyCode: 81,
ctrlKey: true,
altKey: true,
@@ -858,9 +881,10 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let event1 = generate('keydown', { keyCode: 68 });
let event2 = generate('keydown', { keyCode: 69 });
let event1 = generateKeydown({ keyCode: 68 });
let event2 = generateKeydown({ keyCode: 69 });
elem.dispatchEvent(event1);
console.log(codes);
expect(codes.length).to.equal(0);
elem.dispatchEvent(event2);
expect(called).to.equal(false);
@@ -885,7 +909,7 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let event = generate('keydown', { keyCode: 68 });
let event = generateKeydown({ keyCode: 68 });
elem.dispatchEvent(event);
expect(codes.length).to.equal(0);
setTimeout(() => {
@@ -919,7 +943,7 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test2'
});
let event = generate('keydown', { keyCode: 68 });
let event = generateKeydown({ keyCode: 68 });
elem.dispatchEvent(event);
expect(called1).to.equal(false);
expect(called2).to.equal(false);
@@ -954,7 +978,7 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test2'
});
let event = generate('keydown', { keyCode: 59, ctrlKey: true });
let event = generateKeydown({ keyCode: 59, ctrlKey: true });
elem.dispatchEvent(event);
expect(called1).to.equal(false);
expect(called2).to.equal(true);
@@ -977,7 +1001,7 @@ describe('@lumino/commands', () => {
selector: '#baz',
command: 'test'
});
let event = generate('keydown', { keyCode: 68 });
let event = generateKeydown({ keyCode: 68 });
elem.dispatchEvent(event);
expect(codes).to.deep.equal([68]);
expect(called).to.equal(false);
@@ -1001,7 +1025,7 @@ describe('@lumino/commands', () => {
selector: '#baz',
command: 'test'
});
let event = generate('keydown', { keyCode: 68 });
let event = generateKeydown({ keyCode: 68 });
elem.dispatchEvent(event);
expect(codes).to.deep.equal([68]);
expect(called).to.equal(false);
@@ -1020,9 +1044,9 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let eventK = generate('keydown', { keyCode: 75, ctrlKey: true });
let eventCtrl = generate('keydown', { keyCode: 17, ctrlKey: true });
let eventL = generate('keydown', { keyCode: 76, ctrlKey: true });
let eventK = generateKeydown({ keyCode: 75, ctrlKey: true });
let eventCtrl = generateKeydown({ keyCode: 17, ctrlKey: true });
let eventL = generateKeydown({ keyCode: 76, ctrlKey: true });
elem.dispatchEvent(eventK);
expect(count).to.equal(0);
elem.dispatchEvent(eventCtrl); // user presses Ctrl again - this should not break the sequence
@@ -1043,10 +1067,10 @@ describe('@lumino/commands', () => {
selector: `#${elem.id}`,
command: 'test'
});
let eventShift = generate('keydown', { keyCode: 16, shiftlKey: true });
let eventK = generate('keydown', { keyCode: 75, shiftKey: true });
let eventCtrl = generate('keydown', { keyCode: 17, ctrlKey: true });
let eventL = generate('keydown', { keyCode: 76, ctrlKey: true });
let eventShift = generateKeydown({ keyCode: 16, shiftlKey: true });
let eventK = generateKeydown({ keyCode: 75, shiftKey: true });
let eventCtrl = generateKeydown({ keyCode: 17, ctrlKey: true });
let eventL = generateKeydown({ keyCode: 76, ctrlKey: true });
elem.dispatchEvent(eventShift);
expect(count).to.equal(0);
elem.dispatchEvent(eventK);
@@ -1107,15 +1131,15 @@ describe('@lumino/commands', () => {

describe('.keystrokeForKeydownEvent()', () => {
it('should create a normalized keystroke', () => {
let event = generate('keydown', { ctrlKey: true, keyCode: 83 });
let event = generateKeydown({ ctrlKey: true, keyCode: 83 });
let keystroke = CommandRegistry.keystrokeForKeydownEvent(
event as KeyboardEvent
);
expect(keystroke).to.equal('Ctrl S');
});

it('should handle multiple modifiers', () => {
let event = generate('keydown', {
let event = generateKeydown({
ctrlKey: true,
altKey: true,
shiftKey: true,
@@ -1128,15 +1152,15 @@ describe('@lumino/commands', () => {
});

it('should fail on an invalid shortcut', () => {
let event = generate('keydown', { keyCode: -1 });
let event = generateKeydown({ keyCode: -1 });
let keystroke = CommandRegistry.keystrokeForKeydownEvent(
event as KeyboardEvent
);
expect(keystroke).to.equal('');
});

it('should return nothing for keys that are marked as modifier in keyboard layout', () => {
let event = generate('keydown', { keyCode: 17, ctrlKey: true });
let event = generateKeydown({ keyCode: 17, ctrlKey: true });
let keystroke = CommandRegistry.keystrokeForKeydownEvent(
event as KeyboardEvent
);
3 changes: 3 additions & 0 deletions packages/keyboard/package.json
Original file line number Diff line number Diff line change
@@ -43,6 +43,9 @@
"test:ie": "cd tests && karma start --browsers=IE",
"watch": "tsc --build --watch"
},
"dependencies": {
"@lumino/domutils": "^1.8.0"
},
"devDependencies": {
"@microsoft/api-extractor": "^7.6.0",
"@types/chai": "^3.4.35",
126 changes: 114 additions & 12 deletions packages/keyboard/src/index.ts
Original file line number Diff line number Diff line change
@@ -8,6 +8,8 @@
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/

import { Platform } from '@lumino/domutils';

/**
* An object which represents an abstract keyboard layout.
*/
@@ -30,6 +32,17 @@ export interface IKeyboardLayout {
*/
keys(): string[];

/**
* Get an arrow of all modifier key values supported by the layout.
*
* @returns A new array of the supported modifier key values.
*
* #### Notes
* This can be useful for authoring tools and debugging, when it's
* necessary to know which modifier keys are available for shortcut use.
*/
modifierKeys(): string[];

/**
* Test whether the given key is a valid value for the layout.
*
@@ -40,18 +53,17 @@ export interface IKeyboardLayout {
isValidKey(key: string): boolean;

/**
* Test whether the given key is a modifier key.
* Test whether the given key is a valid modifier key for the layout.
*
* @param key - The user provided key.
* @param key - The user provided key to test for validity.
*
* @returns `true` if the key is a modifier key, `false` otherwise.
*
* #### Notes
* This is necessary so that we don't process modifier keys pressed
* in the middle of the key sequence.
* E.g. "Shift C Ctrl P" is actually 4 keydown events:
* "Shift", "Shift P", "Ctrl", "Ctrl P",
* and events for "Shift" and "Ctrl" should be ignored.
* This may be useful to determine whether we should ignore a keypress
* event in the middle of a key sequence. For example, "Shift C Ctrl P" is
* actually four keydown events: "Shift, "Shift P", "Ctrl", "Ctrl P", but
* the keypress events for the modifier keys "Shift" and "Ctrl" are ignored.
*/
isModifierKey(key: string): boolean;

@@ -64,6 +76,15 @@ export interface IKeyboardLayout {
* does not represent a valid primary key.
*/
keyForKeydownEvent(event: KeyboardEvent): string;

/**
* Get the formatted string for displaying a key in the user interface.
*
* @param key - The user provided key.
*
* @returns A unicode string representing the key in the interface.
*/
formatKey(key: string): string;
}

/**
@@ -115,12 +136,14 @@ export class KeycodeLayout implements IKeyboardLayout {
constructor(
name: string,
codes: KeycodeLayout.CodeMap,
modifierKeys: string[] = []
modifierKeys: string[] = [],
format: KeycodeLayout.KeyFormat = x => x
) {
this.name = name;
this._codes = codes;
this._keys = KeycodeLayout.extractKeys(codes);
this._modifierKeys = KeycodeLayout.convertToKeySet(modifierKeys);
this._modifierKeys = new Set<string>(modifierKeys);
this._format = format;
}

/**
@@ -137,6 +160,15 @@ export class KeycodeLayout implements IKeyboardLayout {
return Object.keys(this._keys);
}

/**
* Get an arrow of all modifier key values supported by the layout.
*
* @returns A new array of the supported modifier key values.
*/
modifierKeys(): string[] {
return Array.from(this._modifierKeys);
}

/**
* Test whether the given key is a valid value for the layout.
*
@@ -156,7 +188,7 @@ export class KeycodeLayout implements IKeyboardLayout {
* @returns `true` if the key is a modifier key, `false` otherwise.
*/
isModifierKey(key: string): boolean {
return key in this._modifierKeys;
return this._modifierKeys.has(key);
}

/**
@@ -171,9 +203,21 @@ export class KeycodeLayout implements IKeyboardLayout {
return this._codes[event.keyCode] || '';
}

/**
* Get the formatted string for displaying a key in the user interface.
*
* @param key - The user provided key.
*
* @returns A unicode string representing the key in the interface.
*/
formatKey(key: string): string {
return this._format(key);
}

private _keys: KeycodeLayout.KeySet;
private _codes: KeycodeLayout.CodeMap;
private _modifierKeys: KeycodeLayout.KeySet;
private _modifierKeys: Set<string>;
private _format: KeycodeLayout.KeyFormat;
}

/**
@@ -190,6 +234,11 @@ export namespace KeycodeLayout {
*/
export type KeySet = { readonly [key: string]: boolean };

/**
* A type alias for a key format function.
*/
export type KeyFormat = (key: string) => string;

/**
* Extract the set of keys from a code map.
*
@@ -221,6 +270,57 @@ export namespace KeycodeLayout {
}
}

export const MAC_DISPLAY: { [key: string]: string } = {
Backspace: '⌫',
Tab: '⇥',
Enter: '↩',
Shift: '⇧',
Ctrl: '⌃',
Alt: '⌥',
Escape: '⎋',
PageUp: '⇞',
PageDown: '⇟',
End: '↘',
Home: '↖',
ArrowLeft: '←',
ArrowUp: '↑',
ArrowRight: '→',
ArrowDown: '↓',
Delete: '⌦',
Meta: '⌘'
};

export const WIN_DISPLAY: { [key: string]: string } = {
// 'Backspace': '⌫',
// 'Tab': '⇥',
// 'Enter': 'Return',
// 'Shift': '⇧',
// 'Ctrl': 'Ctrl',
// 'Alt': '⌥',
// 'Pause': '',
Escape: 'Esc',
// 'Space': '␣',
PageUp: 'Page Up',
PageDown: 'Page Down',
// 'End': '↘',
// 'Home': '↖',
ArrowLeft: 'Left',
ArrowUp: 'Right',
ArrowRight: 'Up',
ArrowDown: 'Down',
// 'Insert': '',
Delete: 'Del'
// 'Meta': ''
};

export function formatKey(key: string): string {
if (Platform.IS_MAC) {
return MAC_DISPLAY.hasOwnProperty(key) ? MAC_DISPLAY[key] : key;
} else {
return WIN_DISPLAY.hasOwnProperty(key) ? WIN_DISPLAY[key] : key;
}
}

/**
* A keycode-based keyboard layout for US English keyboards.
*
@@ -345,7 +445,9 @@ export const EN_US: IKeyboardLayout = new KeycodeLayout(
222: "'",
224: 'Meta' // firefox
},
['Shift', 'Ctrl', 'Alt', 'Meta'] // modifier keys
// The modifier is labeled "Control", but the key value is "Ctrl"?
['Ctrl', 'Alt', 'Shift', 'Meta'], // modifier keys in display order
formatKey
);

/**
2 changes: 1 addition & 1 deletion packages/keyboard/tsconfig.json
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@
"moduleResolution": "node",
"target": "ES5",
"outDir": "lib",
"lib": ["ES5", "DOM", "ES2015.Iterable"],
"lib": ["ES5", "DOM", "ES2015.Collection", "ES2015.Iterable"],
"importHelpers": true,
"types": [],
"rootDir": "src"