Skip to content

Commit

Permalink
Hotkey management prototype, renamed KeyboardDragListener hotkeys set…
Browse files Browse the repository at this point in the history
…ter => setHotkeys(), see #1621
  • Loading branch information
jonathanolson committed Mar 28, 2024
1 parent d20fc37 commit 3f28bf8
Show file tree
Hide file tree
Showing 9 changed files with 536 additions and 38 deletions.
34 changes: 30 additions & 4 deletions js/accessibility/EnglishStringToCodeMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,36 @@ const EnglishStringToCodeMap = {
arrowDown: [ KeyboardUtils.KEY_DOWN_ARROW ],

// modifier keys
ctrl: [ KeyboardUtils.KEY_CONTROL_LEFT, KeyboardUtils.KEY_CONTROL_RIGHT ],
alt: [ KeyboardUtils.KEY_ALT_LEFT, KeyboardUtils.KEY_ALT_RIGHT ],
shift: [ KeyboardUtils.KEY_SHIFT_LEFT, KeyboardUtils.KEY_SHIFT_RIGHT ]
ctrl: KeyboardUtils.CONTROL_KEYS,
alt: KeyboardUtils.ALT_KEYS,
shift: KeyboardUtils.SHIFT_KEYS,
meta: KeyboardUtils.META_KEYS
};

scenery.register( 'EnglishStringToCodeMap', EnglishStringToCodeMap );
export default EnglishStringToCodeMap;
export default EnglishStringToCodeMap;

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

/**
* Returns the first EnglishStringToCodeMap that corresponds to the provided event.code. Null if no match is found.
* Useful when matching an english string used by KeyboardListener to the event code from a
* SceneryEvent.domEvent.code.
*
* For example:
*
* KeyboardUtils.eventCodeToEnglishString( 'KeyA' ) === 'a'
* KeyboardUtils.eventCodeToEnglishString( 'Numpad0' ) === '0'
* KeyboardUtils.eventCodeToEnglishString( 'Digit0' ) === '0'
*
* NOTE: This cannot be in KeyboardUtils because it would create a circular dependency.
*/
export const eventCodeToEnglishString = ( eventCode: string ): EnglishKey | null => {
for ( const key in EnglishStringToCodeMap ) {
if ( EnglishStringToCodeMap.hasOwnProperty( key ) &&
( EnglishStringToCodeMap[ key as EnglishKey ] ).includes( eventCode ) ) {
return key as EnglishKey;
}
}
return null;
};
9 changes: 8 additions & 1 deletion 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 { EventIO, KeyboardUtils, scenery } from '../imports.js';
import { EnglishKey, EnglishStringToCodeMap, 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 @@ -264,6 +264,13 @@ class KeyStateTracker {
return this.keyState[ key ].keyDown;
}

/**
* Returns true if the key with the KeyboardEvent.code is currently down.
*/
public isEnglishKeyDown( key: EnglishKey ): boolean {
return this.isAnyKeyInListDown( EnglishStringToCodeMap[ key ] );
}

/**
* Returns true if any of the keys in the list are currently down. Keys are the KeyboardEvent.code strings.
*/
Expand Down
6 changes: 5 additions & 1 deletion js/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export { default as xlinkns } from './util/xlinkns.js';
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 } from './accessibility/EnglishStringToCodeMap.js';
export { default as EnglishStringToCodeMap, eventCodeToEnglishString, metaEnglishKeys } from './accessibility/EnglishStringToCodeMap.js';
export type { EnglishKey } from './accessibility/EnglishStringToCodeMap.js';
export { default as EnglishStringKeyUtils } from './accessibility/EnglishStringKeyUtils.js';
export { default as EventIO } from './input/EventIO.js';
Expand Down Expand Up @@ -217,6 +217,10 @@ 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 Hotkey } from './input/Hotkey.js';
export type { HotkeyOptions, HotkeyFireOnHoldTiming } from './input/Hotkey.js';
export { default as globalHotkeyRegistry } from './input/globalHotkeyRegistry.js';
export { default as hotkeyManager } from './input/hotkeyManager.js';

export { default as InputFuzzer } from './input/InputFuzzer.js';
export { default as DownUpListener } from './input/DownUpListener.js';
Expand Down
152 changes: 152 additions & 0 deletions js/input/Hotkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright 2024, University of Colorado Boulder

/**
* IMPORTANT: EXPERIMENTAL!
* TODO DO NOT USE IN SIMULATIONS, SEE https://github.com/phetsims/scenery/issues/1621 FIRST
*
* Represents a single hotkey (keyboard shortcut) that can be either:
*
* 1. Added to globalHotkeyRegistry (to be available regardless of keyboard focus)
* 2. Added to a node's inputListeners (to be available only when that node is part of the focused trail)
*
* @author Jesse Greenberg (PhET Interactive Simulations)
* @author Jonathan Olson <[email protected]>
*/

import { EnglishKey, 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';

export type HotkeyFireOnHoldTiming = 'browser' | 'custom';

type SelfOptions = {
// 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;

// A set of modifier keys that:
//
// 1. Need to be pressed before the main key before this hotkey is considered pressed.
// 2. If not a normal (ctrl/alt/meta/shift) modifier key, will also be required to be "off" for other hotkeys to be
// activated when this hotkey is present.
//
// Note that the release of a modifier key may "activate" the hotkey for "fire-on-hold", but not for "fire-on-down".
modifierKeys?: EnglishKey[];

// TODO: consider the ability to "not" match modifier keys? https://github.com/phetsims/scenery/issues/1621

// Called as fire() when the hotkey is tired
fire?: ( event: KeyboardEvent | null ) => void;

// If true, the hotkey will fire when the hotkey is initially pressed.
// If false, the hotkey will fire when the hotkey is finally released.
fireOnDown?: boolean;

// Whether the fire-on-hold feature is enabled
fireOnHold?: boolean;

// Whether we should listen to the browser's fire-on-hold timing, or use our own.
fireOnHoldTiming?: HotkeyFireOnHoldTiming;

// Start to fire continuously after pressing for this long (milliseconds)
fireOnHoldCustomDelay?: number;

// Fire continuously at this interval (milliseconds)
fireOnHoldCustomInterval?: number;

// TODO: consider attach:false https://github.com/phetsims/scenery/issues/1621
};

export type HotkeyOptions = SelfOptions & EnabledComponentOptions;

export default class Hotkey extends EnabledComponent {

// Straight from options
public readonly key: EnglishKey;
public readonly modifierKeys: EnglishKey[];
public readonly fire: ( event: KeyboardEvent | null ) => void;
public readonly fireOnDown: boolean;
public readonly fireOnHold: boolean;
public readonly fireOnHoldTiming: HotkeyFireOnHoldTiming;

// A Property that tracks whether the hotkey is currently pressed.
// Will be true if it meets the following conditions:
//
// 1. Main `key` pressed
// 2. All modifier keys in the hotkey's `modifierKeys` are pressed
// 3. All modifier keys not in the hotkey's `modifierKeys` (but in the other enabled hotkeys) are not pressed
public readonly isPressedProperty: TProperty<boolean> = new BooleanProperty( false );

// (read-only for client code)
// Whether the last release (value during isPressedProperty => false) was due to an interruption (e.g. focus changed).
// If false, the hotkey was released due to the key being released.
public interrupted = false;

// Internal timer for when fireOnHold:true and fireOnHoldTiming:custom.
private fireOnHoldTimer?: CallbackTimer;

public constructor(
providedOptions: HotkeyOptions
) {

assert && assert( providedOptions.fireOnHoldTiming === 'custom' || ( providedOptions.fireOnHoldCustomDelay === undefined && providedOptions.fireOnHoldCustomInterval === undefined ),
'Cannot specify fireOnHoldCustomDelay / fireOnHoldCustomInterval if fireOnHoldTiming is not custom' );

const options = optionize<HotkeyOptions, SelfOptions, EnabledComponentOptions>()( {
modifierKeys: [],
fire: _.noop,
fireOnDown: true,
fireOnHold: false,
fireOnHoldTiming: 'browser',
fireOnHoldCustomDelay: 400,
fireOnHoldCustomInterval: 100
}, providedOptions );

super( options );

// Store public things
this.key = options.key;
this.modifierKeys = options.modifierKeys;
this.fire = options.fire;
this.fireOnDown = options.fireOnDown;
this.fireOnHold = options.fireOnHold;
this.fireOnHoldTiming = options.fireOnHoldTiming;

// Create a timer to handle the optional fire-on-hold feature.
if ( this.fireOnHold && this.fireOnHoldTiming === 'custom' ) {
this.fireOnHoldTimer = new CallbackTimer( {
// TODO: Consider passing the _original_ event here? https://github.com/phetsims/scenery/issues/1621
callback: this.fire.bind( this, null ), // Pass null for fire-on-hold events
delay: options.fireOnHoldCustomDelay,
interval: options.fireOnHoldCustomInterval
} );
this.disposeEmitter.addListener( () => this.fireOnHoldTimer!.dispose() );

this.isPressedProperty.link( isPressed => {
// We need to reset the timer, so we stop it (even if we are starting it in just a bit again)
this.fireOnHoldTimer!.stop( false );

if ( isPressed ) {
this.fireOnHoldTimer!.start();
}
} );
}
}

public getHotkeyString(): string {
return [
...this.modifierKeys,
this.key
].join( '+' );
}

public override dispose(): void {
this.isPressedProperty.dispose();

super.dispose();
}
}
scenery.register( 'Hotkey', Hotkey );
5 changes: 4 additions & 1 deletion js/input/TInputListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import Bounds2 from '../../../dot/js/Bounds2.js';
import StrictOmit from '../../../phet-core/js/types/StrictOmit.js';
import { SceneryEvent } from '../imports.js';
import { Hotkey, SceneryEvent } from '../imports.js';

export type SceneryListenerFunction<T extends Event = Event> = ( event: SceneryEvent<T> ) => void;

Expand All @@ -31,6 +31,9 @@ type TInputListener = {
// called from a listener attached to a Pointer so that the API is compatible with multi-touch.
createPanTargetBounds?: ( () => Bounds2 ) | null;

// Hotkeys that will be available whenever a node with this listener is in a focused trail.
hotkeys?: Hotkey[];

////////////////////////////////////////////////
//////////////////////////////////////////////
// Only actual events below here
Expand Down
38 changes: 38 additions & 0 deletions js/input/globalHotkeyRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2024, University of Colorado Boulder

/**
* IMPORTANT: EXPERIMENTAL!
* TODO DO NOT USE IN SIMULATIONS, SEE https://github.com/phetsims/scenery/issues/1621 FIRST
*
* Stores a record of all global hotkeys (Hotkey instances that should be available regardless of focus).
*
* @author Jesse Greenberg (PhET Interactive Simulations)
* @author Jonathan Olson <[email protected]>
*/

import { Hotkey, scenery } from '../imports.js';
import TinyProperty from '../../../axon/js/TinyProperty.js';
import TProperty from '../../../axon/js/TProperty.js';

class GlobalHotkeyRegistry {

// (read-only) The set of hotkeys that are currently available globally
public readonly hotkeysProperty: TProperty<Set<Hotkey>> = new TinyProperty( new Set<Hotkey>() );

public add( hotkey: Hotkey ): void {
assert && assert( !this.hotkeysProperty.value.has( hotkey ), 'Hotkey already added' );

this.hotkeysProperty.value = new Set( [ ...this.hotkeysProperty.value, hotkey ] );
}

public remove( hotkey: Hotkey ): void {
assert && assert( this.hotkeysProperty.value.has( hotkey ), 'Hotkey not found' );

this.hotkeysProperty.value = new Set( [ ...this.hotkeysProperty.value ].filter( value => value !== hotkey ) );
}
}
scenery.register( 'GlobalHotkeyRegistry', GlobalHotkeyRegistry );

const globalHotkeyRegistry = new GlobalHotkeyRegistry();

export default globalHotkeyRegistry;
Loading

0 comments on commit 3f28bf8

Please sign in to comment.