Skip to content

Commit

Permalink
Add HotkeyData, and better types for KeyDescriptor, see #1266
Browse files Browse the repository at this point in the history
  • Loading branch information
jessegreenberg committed Jul 31, 2024
1 parent 65b3ce0 commit 6c672d1
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 35 deletions.
7 changes: 4 additions & 3 deletions js/accessibility/EnglishStringToCodeMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { KeyboardUtils, scenery } from '../imports.js';

export type EnglishKey = keyof typeof EnglishStringToCodeMap;
export type EnglishKeyString = `${EnglishKey}`;

const EnglishStringToCodeMap = {

Expand Down Expand Up @@ -83,7 +84,7 @@ const EnglishStringToCodeMap = {
scenery.register( 'EnglishStringToCodeMap', EnglishStringToCodeMap );
export default EnglishStringToCodeMap;

export const metaEnglishKeys: EnglishKey[] = [ 'ctrl', 'alt', 'shift', 'meta' ];
export const metaEnglishKeys: EnglishKeyString[] = [ 'ctrl', 'alt', 'shift', 'meta' ];

/**
* Returns the first EnglishStringToCodeMap that corresponds to the provided event.code. Null if no match is found.
Expand All @@ -98,11 +99,11 @@ export const metaEnglishKeys: EnglishKey[] = [ 'ctrl', 'alt', 'shift', 'meta' ];
*
* NOTE: This cannot be in KeyboardUtils because it would create a circular dependency.
*/
export const eventCodeToEnglishString = ( eventCode: string ): EnglishKey | null => {
export const eventCodeToEnglishString = ( eventCode: string ): EnglishKeyString | null => {
for ( const key in EnglishStringToCodeMap ) {
if ( EnglishStringToCodeMap.hasOwnProperty( key ) &&
( EnglishStringToCodeMap[ key as EnglishKey ] ).includes( eventCode ) ) {
return key as EnglishKey;
return key as EnglishKeyString;
}
}
return null;
Expand Down
6 changes: 3 additions & 3 deletions js/accessibility/KeyStateTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import PhetioAction from '../../../tandem/js/PhetioAction.js';
import Emitter from '../../../axon/js/Emitter.js';
import stepTimer from '../../../axon/js/stepTimer.js';
import EventType from '../../../tandem/js/EventType.js';
import { EnglishKey, EnglishStringToCodeMap, eventCodeToEnglishString, EventIO, KeyboardUtils, scenery } from '../imports.js';
import { EnglishKey, EnglishKeyString, EnglishStringToCodeMap, eventCodeToEnglishString, EventIO, KeyboardUtils, scenery } from '../imports.js';
import { PhetioObjectOptions } from '../../../tandem/js/PhetioObject.js';
import PickOptional from '../../../phet-core/js/types/PickOptional.js';
import TEmitter from '../../../axon/js/TEmitter.js';
Expand Down Expand Up @@ -306,8 +306,8 @@ class KeyStateTracker {
*
* NOTE: Always returns a new Set, so a defensive copy is not needed.
*/
public getEnglishKeysDown(): Set<EnglishKey> {
const englishKeySet = new Set<EnglishKey>();
public getEnglishKeysDown(): Set<EnglishKeyString> {
const englishKeySet = new Set<EnglishKeyString>();

for ( const key of this.getKeysDown() ) {
const englishKey = eventCodeToEnglishString( key );
Expand Down
5 changes: 3 additions & 2 deletions js/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export { default as Utils } from './util/Utils.js';
export { default as Focus } from './accessibility/Focus.js';
export { default as KeyboardUtils } from './accessibility/KeyboardUtils.js';
export { default as EnglishStringToCodeMap, eventCodeToEnglishString, metaEnglishKeys } from './accessibility/EnglishStringToCodeMap.js';
export type { EnglishKey } from './accessibility/EnglishStringToCodeMap.js';
export type { EnglishKey, EnglishKeyString } from './accessibility/EnglishStringToCodeMap.js';
export { default as EnglishStringKeyUtils } from './accessibility/EnglishStringKeyUtils.js';
export { default as EventIO } from './input/EventIO.js';
export { default as SceneryStyle } from './util/SceneryStyle.js';
Expand Down Expand Up @@ -220,8 +220,9 @@ export type { InputOptions } from './input/Input.js';
export { default as BatchedDOMEvent, BatchedDOMEventType } from './input/BatchedDOMEvent.js';
export type { BatchedDOMEventCallback } from './input/BatchedDOMEvent.js';
export { default as BrowserEvents } from './input/BrowserEvents.js';
export { default as HotkeyData } from './input/HotkeyData.js';
export { default as KeyDescriptor } from './input/KeyDescriptor.js';
export type { KeyDescriptorOptions, OneKeyStroke, OneKeyStrokeEntry } from './input/KeyDescriptor.js';
export type { KeyDescriptorOptions, OneKeyStroke, OneKeyStrokeEntry, AllowedKeysString } from './input/KeyDescriptor.js';
export { default as Hotkey } from './input/Hotkey.js';
export type { HotkeyOptions, HotkeyFireOnHoldTiming } from './input/Hotkey.js';
export { default as globalHotkeyRegistry } from './input/globalHotkeyRegistry.js';
Expand Down
5 changes: 2 additions & 3 deletions js/input/Hotkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,12 @@
* @author Jonathan Olson <[email protected]>
*/

import { EnglishKey, EnglishStringToCodeMap, hotkeyManager, OneKeyStroke, scenery } from '../imports.js';
import { AllowedKeysString, EnglishStringToCodeMap, hotkeyManager, KeyDescriptor, OneKeyStroke, scenery } from '../imports.js';
import optionize from '../../../phet-core/js/optionize.js';
import EnabledComponent, { EnabledComponentOptions } from '../../../axon/js/EnabledComponent.js';
import TProperty from '../../../axon/js/TProperty.js';
import BooleanProperty from '../../../axon/js/BooleanProperty.js';
import CallbackTimer from '../../../axon/js/CallbackTimer.js';
import KeyDescriptor from './KeyDescriptor.js';
import DerivedProperty from '../../../axon/js/DerivedProperty.js';
import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js';

Expand Down Expand Up @@ -109,7 +108,7 @@ export default class Hotkey extends EnabledComponent {
public readonly keyDescriptorProperty: TReadOnlyProperty<KeyDescriptor>;

// All keys that are part of this hotkey (key + modifierKeys) as defined by the current KeyDescriptor.
public keysProperty: TReadOnlyProperty<EnglishKey[]>;
public keysProperty: TReadOnlyProperty<AllowedKeysString[]>;

// A Property that tracks whether the hotkey is currently pressed.
// Will be true if it meets the following conditions:
Expand Down
121 changes: 121 additions & 0 deletions js/input/HotkeyData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2024, University of Colorado Boulder

/**
* Data pertaining to a hotkey, including keystrokes and associated metadata for documentation and the keyboard help
* dialog.
*
* @author Jesse Greenberg (PhET Interactive Simulations)
*/

import { KeyDescriptor, OneKeyStroke, scenery } from '../../../scenery/js/imports.js';
import optionize from '../../../phet-core/js/optionize.js';
import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js';
import DerivedProperty from '../../../axon/js/DerivedProperty.js';
import InstanceRegistry from '../../../phet-core/js/documentation/InstanceRegistry.js';

// The type for a serialized HotkeyData object for documentation (binder).
type SerializedHotkeyData = {
keyStrings: string[];
repoName: string;
binderName: string;
global: boolean;
};

export type HotkeyDataOptions = {

// The list of keystrokes that will trigger the hotkey. Wrapping in a Property allows for i18n in the future.
keyStringProperties: TReadOnlyProperty<OneKeyStroke>[];

// The visual label for this Hotkey in the Keyboard Help dialog. This will also be used as the label in
// generated documentation, unless binderName is provided.
keyboardHelpDialogLabelStringProperty?: TReadOnlyProperty<string> | null;

// The PDOM label and description for this Hotkey in the Keyboard Help dialog.
keyboardHelpDialogPDOMLabelStringProperty?: TReadOnlyProperty<string> | string | null;

// Data for binder (generated documentation).
repoName: string; // Name of the repository where the hotkey is defined.
global?: boolean; // Is this Hotkey global?
binderName?: string; // If there is no keyboardHelpDialogLabelStringProperty, this name will be used in documentation.
};

export default class HotkeyData {
public readonly keyStringProperties: TReadOnlyProperty<OneKeyStroke>[];
public readonly keyboardHelpDialogLabelStringProperty: TReadOnlyProperty<string> | null;
public readonly keyboardHelpDialogPDOMLabelStringProperty: TReadOnlyProperty<string> | string | null;

// KeyDescriptors derived from keyStringProperties.
public readonly keyDescriptorsProperty: TReadOnlyProperty<KeyDescriptor[]>;

private readonly repoName: string;
private readonly global: boolean;
private readonly binderName: string;

public constructor( providedOptions: HotkeyDataOptions ) {
assert && assert( providedOptions.binderName || providedOptions.keyboardHelpDialogLabelStringProperty,
'You must provide some label for the hotkey' );

const options = optionize<HotkeyDataOptions>()( {
keyboardHelpDialogPDOMLabelStringProperty: null,
keyboardHelpDialogLabelStringProperty: null,
global: false,
binderName: ''
}, providedOptions );

this.keyStringProperties = options.keyStringProperties;
this.keyboardHelpDialogLabelStringProperty = options.keyboardHelpDialogLabelStringProperty;
this.keyboardHelpDialogPDOMLabelStringProperty = options.keyboardHelpDialogPDOMLabelStringProperty;

this.repoName = options.repoName;
this.global = options.global;
this.binderName = options.binderName;

this.keyDescriptorsProperty = DerivedProperty.deriveAny( this.keyStringProperties, () => {
return this.keyStringProperties.map( keyStringProperty => {
return KeyDescriptor.keyStrokeToKeyDescriptor( keyStringProperty.value );
} );
} );

// Add this Hotkey to the binder registry for documentation. See documentation in the binder repository
// for more information about how this is done.
assert && phet?.chipper?.queryParameters?.binder && InstanceRegistry.registerHotkey( this );
}

/**
* Returns true if any of the keyStringProperties of this HotkeyData have the given keyStroke.
*/
public hasKeyStroke( keyStroke: OneKeyStroke ): boolean {
return this.keyStringProperties.some( keyStringProperty => keyStringProperty.value === keyStroke );
}

/**
* Serialization for usage with binder (generated documentation).
*/
public serialize(): SerializedHotkeyData {
return {
keyStrings: this.keyStringProperties.map( keyStringProperty => keyStringProperty.value ),
binderName: ( this.binderName || this.keyboardHelpDialogLabelStringProperty?.value )!,
repoName: this.repoName,
global: this.global
};
}

/**
* Dispose of owned Properties to prevent memory leaks.
*/
public dispose(): void {
this.keyDescriptorsProperty.dispose();
}

/**
* Combine the keyStringProperties of an array of HotkeyData into a single array. Useful if you want to combine
* multiple HotkeyData for a single KeyboardListener.
*/
public static combineKeyStringProperties( hotkeyDataArray: HotkeyData[] ): TReadOnlyProperty<OneKeyStroke>[] {
return hotkeyDataArray.reduce<TReadOnlyProperty<OneKeyStroke>[]>( ( accumulator, hotkeyData ) => {
return accumulator.concat( hotkeyData.keyStringProperties );
}, [] );
}
}

scenery.register( 'HotkeyData', HotkeyData );
40 changes: 22 additions & 18 deletions js/input/KeyDescriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* @author Jesse Greenberg (PhET Interactive Simulations)
*/

import { EnglishKey, EnglishStringToCodeMap, metaEnglishKeys, scenery } from '../imports.js';
import { EnglishKey, EnglishKeyString, EnglishStringToCodeMap, metaEnglishKeys, scenery } from '../imports.js';
import optionize from '../../../phet-core/js/optionize.js';

// NOTE: The typing for ModifierKey and OneKeyStroke is limited TypeScript, there is a limitation to the number of
Expand All @@ -30,6 +30,10 @@ type IgnoreOtherModifierKeys = `${IgnoreDelimiter}${ModifierKey}`;
// Allowed keys are the keys of the EnglishStringToCodeMap.
type AllowedKeys = keyof typeof EnglishStringToCodeMap;

// Allowed keys as a string - the format they will be provided by the user.
export type AllowedKeysString = `${AllowedKeys}`;

// A key stroke entry is a single key or a key with "ignore" modifiers, see examples and keyStrokeToKeyDescriptor.
export type OneKeyStrokeEntry = `${AllowedKeys}` | `${IgnoreModifierKey}+${EnglishKey}` | `${IgnoreOtherModifierKeys}+${EnglishKey}`;

export type OneKeyStroke =
Expand All @@ -49,7 +53,7 @@ export type KeyDescriptorOptions = {

// The key that should be pressed to trigger the hotkey (in fireOnDown:true mode) or released to trigger the hotkey
// (in fireOnDown:false mode).
key: EnglishKey;
key: AllowedKeysString;

// A set of modifier keys that:
//
Expand All @@ -67,17 +71,17 @@ export type KeyDescriptorOptions = {
// so that is kept consistent for PhET-specific modifier keys.
//
// Note that the release of a modifier key may "activate" the hotkey for "fire-on-hold", but not for "fire-on-down".
modifierKeys?: EnglishKey[];
modifierKeys?: AllowedKeysString[];

// A set of modifier keys that can be down and the hotkey will still fire. Essentially ignoring the modifier
// key behavior for this key.
ignoredModifierKeys?: EnglishKey[];
ignoredModifierKeys?: AllowedKeysString[];
};

export default class KeyDescriptor {
public readonly key: EnglishKey;
public readonly modifierKeys: EnglishKey[];
public readonly ignoredModifierKeys: EnglishKey[];
public readonly key: AllowedKeysString;
public readonly modifierKeys: AllowedKeysString[];
public readonly ignoredModifierKeys: AllowedKeysString[];

public constructor( providedOptions?: KeyDescriptorOptions ) {
const options = optionize<KeyDescriptorOptions>()( {
Expand Down Expand Up @@ -136,7 +140,7 @@ export default class KeyDescriptor {
*/
public static keyStrokeToKeyDescriptor( keyStroke: OneKeyStroke ): KeyDescriptor {

const tokens = keyStroke.split( '+' );
const tokens = keyStroke.split( '+' ) as OneKeyStrokeEntry[];

// assertions
let foundIgnoreDelimiter = false;
Expand All @@ -145,13 +149,13 @@ export default class KeyDescriptor {
// the ignore delimiter can only be used on default modifier keys
if ( token.length > 1 && token.includes( IGNORE_DELIMITER ) ) {
assert && assert( !foundIgnoreDelimiter, 'There can only be one ignore delimiter' );
assert && assert( metaEnglishKeys.includes( token.replace( IGNORE_DELIMITER, '' ) as EnglishKey ), 'The ignore delimiter can only be used on default modifier keys' );
assert && assert( metaEnglishKeys.includes( token.replace( IGNORE_DELIMITER, '' ) as EnglishKeyString ), 'The ignore delimiter can only be used on default modifier keys' );
foundIgnoreDelimiter = true;
}
} );

const modifierKeys: string[] = [];
const ignoredModifierKeys: string[] = [];
const modifierKeys: AllowedKeysString[] = [];
const ignoredModifierKeys: AllowedKeysString[] = [];

tokens.forEach( token => {

Expand All @@ -162,35 +166,35 @@ export default class KeyDescriptor {
if ( token.startsWith( IGNORE_DELIMITER ) ) {

// Add all default modifiers except the current stripped token to the ignored keys
const otherModifiers = metaEnglishKeys.filter( mod => mod !== strippedToken ) as string[];
const otherModifiers = metaEnglishKeys.filter( mod => mod !== strippedToken );
ignoredModifierKeys.push( ...otherModifiers );

// Include the stripped token as a regular modifier key
modifierKeys.push( strippedToken );
modifierKeys.push( strippedToken as AllowedKeysString );
}
else {

// Add the stripped token to the ignored modifier keys
ignoredModifierKeys.push( strippedToken );
ignoredModifierKeys.push( strippedToken as AllowedKeysString );
}
}
else {

// If there's no question mark, add the token to the modifier keys
modifierKeys.push( token );
modifierKeys.push( token as AllowedKeysString );
}
} );

// Assume the last token is the key
const key = modifierKeys.pop() as EnglishKey;
const key = modifierKeys.pop()!;

// Filter out ignored modifier keys from the modifier keys list
const filteredModifierKeys = modifierKeys.filter( mod => !ignoredModifierKeys.includes( mod ) );

return new KeyDescriptor( {
key: key,
modifierKeys: filteredModifierKeys as EnglishKey[],
ignoredModifierKeys: ignoredModifierKeys as EnglishKey[]
modifierKeys: filteredModifierKeys,
ignoredModifierKeys: ignoredModifierKeys
} );
}
}
Expand Down
4 changes: 2 additions & 2 deletions js/input/KeyDescriptorTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
* @author Jesse Greenberg (PhET Interactive Simulations)
*/

import { EnglishKey, KeyDescriptor } from '../imports.js';
import { EnglishKeyString, KeyDescriptor } from '../imports.js';

QUnit.module( 'KeyDescriptor' );

// Helper function to compare two arrays with QUnit assertions, ignoring order.
const arraysEqualIgnoringOrder = function( array1: EnglishKey[], array2: EnglishKey[], assert: Assert, message: string ) {
const arraysEqualIgnoringOrder = function( array1: EnglishKeyString[], array2: EnglishKeyString[], assert: Assert, message: string ) {
array1 = array1.slice().sort();
array2 = array2.slice().sort();

Expand Down
8 changes: 4 additions & 4 deletions js/input/hotkeyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* @author Jonathan Olson <[email protected]>
*/

import { EnglishKey, eventCodeToEnglishString, FocusManager, globalHotkeyRegistry, globalKeyStateTracker, Hotkey, KeyboardUtils, metaEnglishKeys, Node, scenery } from '../imports.js';
import { AllowedKeysString, EnglishKeyString, eventCodeToEnglishString, FocusManager, globalHotkeyRegistry, globalKeyStateTracker, Hotkey, KeyboardUtils, metaEnglishKeys, Node, scenery } from '../imports.js';
import DerivedProperty, { UnknownDerivedProperty } from '../../../axon/js/DerivedProperty.js';
import TProperty from '../../../axon/js/TProperty.js';
import TinyProperty from '../../../axon/js/TinyProperty.js';
Expand All @@ -46,12 +46,12 @@ class HotkeyManager {
private readonly enabledHotkeysProperty: TProperty<Hotkey[]> = new TinyProperty( [] );

// The set of EnglishKeys that are currently pressed.
private englishKeysDown: Set<EnglishKey> = new Set<EnglishKey>();
private englishKeysDown: Set<EnglishKeyString> = new Set<EnglishKeyString>();

// The current set of modifier keys (pressed or not) based on current enabled hotkeys
// NOTE: Pressed modifier keys will prevent any other Hotkeys from becoming active. For example if you have a hotkey
// with 'b+x', pressing 'b' will prevent any other hotkeys from becoming active.
private modifierKeys: EnglishKey[] = [];
private modifierKeys: AllowedKeysString[] = [];

// Hotkeys that are actively pressed
private readonly activeHotkeys: Set<Hotkey> = new Set<Hotkey>();
Expand Down Expand Up @@ -246,7 +246,7 @@ class HotkeyManager {
* 2. All modifier keys in the hotkey's modifierKeys pressed
* 3. All modifier keys not in the hotkey's modifierKeys (but in the other hotkeys above) not pressed
*/
private getHotkeysForMainKey( mainKey: EnglishKey ): Hotkey[] {
private getHotkeysForMainKey( mainKey: EnglishKeyString ): Hotkey[] {

// If the main key isn't down, there's no way it could be active
if ( !this.englishKeysDown.has( mainKey ) ) {
Expand Down

0 comments on commit 6c672d1

Please sign in to comment.