diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 7ef394e5..1f10cd99 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -73,13 +73,13 @@ Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../us ### Keyboard Shortcut Configuration -| Option | Default | Description | -| ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------- | -| key | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft` | -| ctrl | `false` | If `true` requires the `ctrl` key to be held. | -| shift | `false` | If `true` requires the `shift` key to be held. | -| alt | `false` | If `true` requires the `alt` key to be held. | -| meta | `false` | If `true` requires the `meta` key to be held. | +| Option | Default | Description | +| ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| key | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft` | +| ctrl | | If `true` requires the `ctrl` key to be held, if `false` requires it not to be. When unset the `ctrl` key is not considered, and the shortcut matches either way. | +| shift | | If `true` requires the `shift` key to be held, if `false` requires it not to be. When unset the `shift` key is not considered, and the shortcut matches either way. | +| alt | | If `true` requires the `alt` key to be held, if `false` requires it not to be. When unset the `alt` key is not considered, and the shortcut matches either way. | +| meta | | If `true` requires the `meta` key to be held, if `false` requires it not to be. When unset the `meta` key is not considered, and the shortcut matches either way. | ## `theme` 🎨 diff --git a/docs/usage/keyboard-shortcuts.md b/docs/usage/keyboard-shortcuts.md index ae7db070..7da0d5ee 100644 --- a/docs/usage/keyboard-shortcuts.md +++ b/docs/usage/keyboard-shortcuts.md @@ -5,8 +5,27 @@ There are two ways to have the card respond to key input: - As a convenience, the card supports a small number of built in shortcuts with pre-defined default bindings. See [Built-in shortcuts](#built-in-shortcuts) for these built in shortcuts. Use the [`keyboard_shortcuts`](../configuration/view.md?id=keyboard_shortcuts) configuration to change their bindings. - More generally, _any_ [action](../configuration/actions/README.md) can be configured to run in response to keyboard input as part of an [automation](../configuration/automations.md), even if that action does not have a pre-defined shortcut. See [keyboard automation example](../examples.md?id=responding-to-key-input) to show how to execute any arbitrary action(s) in response to keyboard activity. +## How key input is handled + +- **The card must have focus.** Key input only reaches the card if the user has + interacted with it (i.e. click or tab to the card first). +- **Registered keyboard shortcuts will be handled only by the card.** When a key + matches a built-in shortcut (below) or a [`key` + trigger](../configuration/conditions-triggers.md?id=key), the browser's own + behavior for that key is suppressed (to ensure that pressing `ArrowDown` pans + the camera without also scrolling the dashboard). Keys with no binding are + left entirely to the browser. + - **A `key` trigger with no `key` property is an exception.** Such a trigger + fires on _every_ key, so no suppression occurs to preserve the browser + behavior in the general case (e.g. `Tab` still moves focus). +- **Keys typed into an input field are otherwise ignored.** Key strokes aimed at + intentional inputs (e.g. a text box or dropdown) do not trigger card actions / + automations. + ## Built-in shortcuts +Built in keyboard shortcuts can be disabled through the [`keyboard_shortcuts` configuration](../configuration/view.md?id=keyboard_shortcuts). + | Name | Default key binding | Action | Description | | -------------- | ------------------- | --------------------------------------------------------------------- | ------------------- | | `ptz_down` | `ArrowDown` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ move down. | diff --git a/src/card-controller/actions/actions/ptz-digital.ts b/src/card-controller/actions/actions/ptz-digital.ts index dd5d4ed9..47685d13 100644 --- a/src/card-controller/actions/actions/ptz-digital.ts +++ b/src/card-controller/actions/actions/ptz-digital.ts @@ -14,14 +14,14 @@ import type { CardActionsAPI } from '../../types'; import { ZoomRequestViewModifier } from '../../view/modifiers/zoom-request'; import type { TargetedActionContext } from '../types'; import { - setInProgressForThisTarget, - stopInProgressForThisTarget, + clearInProgressForThisTarget, + replaceInProgressForThisTarget, } from '../utils/action-state'; import { AdvancedCameraCardAction } from './base'; const STEP_DELAY_SECONDS = 0.1; const STEP_ZOOM = 0.1; -const STEP_PAN = 5; +export const STEP_PAN = 5; declare module 'action' { interface ActionContext { @@ -31,6 +31,7 @@ declare module 'action' { export class PTZDigitalAction extends AdvancedCameraCardAction { private _timer = new Timer(); + private _stopped = false; private async _stepChange(api: CardActionsAPI, targetID: string): Promise { api @@ -46,6 +47,7 @@ export class PTZDigitalAction extends AdvancedCameraCardAction { + this._stopped = true; this._timer.stop(); } @@ -72,16 +74,20 @@ export class PTZDigitalAction extends AdvancedCameraCardAction - this._stepChange(api, targetID), - ); + + // The steps are repeated only once the first step returns, and only if + // this action has not been stopped. + if (!this._stopped) { + this._timer.startRepeated(STEP_DELAY_SECONDS, () => + this._stepChange(api, targetID), + ); + } } else if (action.ptz_phase === 'stop') { - await stopInProgressForThisTarget(targetID, this._context.ptzDigital); - delete this._context.ptzDigital?.[targetID]; + await clearInProgressForThisTarget(targetID, this._context, 'ptzDigital'); } } diff --git a/src/card-controller/actions/actions/ptz.ts b/src/card-controller/actions/actions/ptz.ts index b35f7a8b..08e3c685 100644 --- a/src/card-controller/actions/actions/ptz.ts +++ b/src/card-controller/actions/actions/ptz.ts @@ -4,21 +4,16 @@ import { PTZMovementType } from '../../../types'; import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz'; import { Timer } from '../../../utils/timer'; import type { CardActionsAPI } from '../../types'; +import type { TargetedActionContext } from '../types'; import { - setInProgressForThisTarget, - stopInProgressForThisTarget, + clearInProgressForThisTarget, + replaceInProgressForThisTarget, } from '../utils/action-state'; import { AdvancedCameraCardAction } from './base'; -interface PTZContext { - [cameraID: string]: { - inProgressAction?: PTZAction; - }; -} - declare module 'action' { interface ActionContext { - ptz?: PTZContext; + ptz?: TargetedActionContext; } } @@ -96,8 +91,8 @@ export class PTZAction extends AdvancedCameraCardAction { if (action.ptz_phase === 'start') { // Scenario: Asked to start a continuous move, camera only supports relative moves natively. - await stopInProgressForThisTarget(ptzCameraID, this._context.ptz); - setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this); + this._stopped = false; + await replaceInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this); const singleStep = async (): Promise => { /* v8 ignore else: the else path cannot be reached as ptz_action @@ -108,10 +103,10 @@ export class PTZAction extends AdvancedCameraCardAction { }); } + // The next step is scheduled only once this step returns, and only if + // this action has not been stopped. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1967 if (!this._stopped) { - // Only start the timer for the next step after this step returns, and - // only if this action has not been stopped. - // See: https://github.com/dermotduffy/advanced-camera-card/issues/1967 this._timer.start( ptzConfiguration.r2c_delay_between_calls_seconds, singleStep, @@ -119,11 +114,10 @@ export class PTZAction extends AdvancedCameraCardAction { } }; - this._stopped = false; await singleStep(); } else if (action.ptz_phase === 'stop') { // Scenario: Asked to stop continuous move, camera only supports relative moves natively. - await stopInProgressForThisTarget(ptzCameraID, this._context.ptz); + await clearInProgressForThisTarget(ptzCameraID, this._context, 'ptz'); } else { this._stopped = false; diff --git a/src/card-controller/actions/utils/action-state.ts b/src/card-controller/actions/utils/action-state.ts index b44af2f1..ac17ed4d 100644 --- a/src/card-controller/actions/utils/action-state.ts +++ b/src/card-controller/actions/utils/action-state.ts @@ -1,16 +1,9 @@ import type { ActionContext } from 'action'; import { merge } from 'lodash-es'; -import type { Action, TargetedActionContext } from '../types'; +import type { Action } from '../types'; -export const stopInProgressForThisTarget = async ( - targetID: string, - context?: TargetedActionContext, -): Promise => { - await context?.[targetID]?.inProgressAction?.stop(); -}; - -export const setInProgressForThisTarget = ( +const setInProgressForThisTarget = ( targetID: string, context: ActionContext, contextKey: keyof ActionContext, @@ -24,3 +17,28 @@ export const setInProgressForThisTarget = ( }, }); }; + +// `action` is registered before the stop is awaited, so an action that starts +// for this target meanwhile sees `action`. +export const replaceInProgressForThisTarget = async ( + targetID: string, + context: ActionContext, + contextKey: keyof ActionContext, + action: Action, +): Promise => { + const replaced = context[contextKey]?.[targetID]?.inProgressAction; + setInProgressForThisTarget(targetID, context, contextKey, action); + await replaced?.stop(); +}; + +// The removal is made before the stop is awaited, so an action that registers +// for this target meanwhile is left in place. +export const clearInProgressForThisTarget = async ( + targetID: string, + context: ActionContext, + contextKey: keyof ActionContext, +): Promise => { + const stopped = context[contextKey]?.[targetID]?.inProgressAction; + delete context[contextKey]?.[targetID]; + await stopped?.stop(); +}; diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts index 08dc484a..beb2b873 100644 --- a/src/card-controller/automations-manager.ts +++ b/src/card-controller/automations-manager.ts @@ -3,6 +3,7 @@ import { createConditionEvaluator } from '../condition-trigger/conditions/factor import { TriggersManager } from '../condition-trigger/triggers/manager.js'; import type { TriggerData } from '../condition-trigger/triggers/types.js'; import type { Automation, AutomationActions } from '../config/schema/automations.js'; +import type { Trigger } from '../config/schema/condition-trigger/triggers/types.js'; import { localize } from '../localize/localize.js'; import type { CardAutomationsAPI, TaggedAutomation } from './types.js'; @@ -21,6 +22,10 @@ export class AutomationsManager { this._api = api; } + public getTriggers(): Trigger[] { + return [...this._automations.keys()].flatMap((automation) => automation.triggers); + } + public deleteAutomations(tag?: unknown) { for (const [automation, triggers] of this._automations) { if (automation.tag === tag) { diff --git a/src/card-controller/config/load-keyboard-shortcuts.ts b/src/card-controller/config/load-keyboard-shortcuts.ts index ca31a149..33231bfd 100644 --- a/src/card-controller/config/load-keyboard-shortcuts.ts +++ b/src/card-controller/config/load-keyboard-shortcuts.ts @@ -88,6 +88,15 @@ const convertKeyboardShortcutsToAutomations = ( trigger: 'key' as const, key: shortcut.key, state: 'up', + + // The same modifiers as the start above, so that the pair claims the + // same presses. A key is recorded with the modifiers it was pressed + // with, so a release still matches when a modifier is taken up while + // the key is held. + shift: shortcut.shift, + ctrl: shortcut.ctrl, + alt: shortcut.alt, + meta: shortcut.meta, }, ], actions: [ diff --git a/src/card-controller/keyboard-state-manager.ts b/src/card-controller/keyboard-state-manager.ts index 7b3c78b0..b9863924 100644 --- a/src/card-controller/keyboard-state-manager.ts +++ b/src/card-controller/keyboard-state-manager.ts @@ -1,8 +1,12 @@ import { isEqual } from 'lodash-es'; +import { KeyConditionEvaluator } from '../condition-trigger/conditions/conditions/key'; +import type { Trigger } from '../config/schema/condition-trigger/triggers/types'; import { isFocusWithin } from '../utils/focus'; import type { CardKeyboardStateAPI, KeysState } from './types'; +const KEY_STATES = ['down', 'up'] as const; + export class KeyboardStateManager { private _api: CardKeyboardStateAPI; private _state: KeysState = {}; @@ -33,16 +37,20 @@ export class KeyboardStateManager { capture: true, }); - // Clear state on disconnect. Without listeners the card cannot know - // whether a key was released while detached, and stale "down" state - // would suppress the next real keydown (e.g. PTZ stop shortcuts). - if (Object.keys(this._state).length) { - this._state = {}; - this._processStateChange(); - } + this._releaseHeldKeys(); } private _handleKeydown = (ev: KeyboardEvent): void => { + if (this._isKeyEventOwnedElsewhere(ev)) { + return; + } + + // If the card acts on this key, the browser must NOT act on it also (e.g. + // 'down' should pan the camera without also scrolling the dashboard). + if (this._isKeyEventClaimedByAnyTrigger(ev)) { + ev.preventDefault(); + } + const keyObj = { state: 'down' as const, ctrl: ev.ctrlKey, @@ -57,6 +65,64 @@ export class KeyboardStateManager { } }; + private _isKeyEventOwnedElsewhere(ev: KeyboardEvent): boolean { + // A key press belongs to something other than the card when: + return ( + // ... something within the card has already answered it ... + ev.defaultPrevented || + // ... a character is mid-composition, e.g. choosing a Japanese character + // from an input method's candidate list with the arrows ... + ev.isComposing || + // ... or it landed on an element with keys of its own. + this._isKeyHandlingElement(ev) + ); + } + + private _isKeyEventClaimedByAnyTrigger(ev: KeyboardEvent): boolean { + return this._api + .getAutomationsManager() + .getTriggers() + .some((trigger) => this._isKeyEventClaimedByTrigger(ev, trigger)); + } + + private _isKeyHandlingElement(ev: KeyboardEvent): boolean { + const target = ev.composedPath()[0]; + + return ( + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ); + } + + private _isKeyEventClaimedByTrigger(ev: KeyboardEvent, trigger: Trigger): boolean { + // A trigger with no key of its own (i.e. undefined `trigger.key` field) + // watches *every* key without "claiming" any, as the card would otherwise + // swallow every press. + if (trigger.trigger !== 'key' || trigger.enabled === false) { + return false; + } + const evaluator = new KeyConditionEvaluator(trigger); + + // Must count both directions, since the browser acts on a key as it goes + // down and so a trigger that acts on the way up must claim it then too. + return KEY_STATES.some( + (state) => + evaluator.evaluate({ + keys: { + [ev.key]: { + state: state, + ctrl: ev.ctrlKey, + alt: ev.altKey, + meta: ev.metaKey, + shift: ev.shiftKey, + }, + }, + }).result, + ); + } + private _handleKeyup = (ev: KeyboardEvent): void => { if (ev.key in this._state && this._state[ev.key].state === 'down') { this._state[ev.key] = { ...this._state[ev.key], state: 'up' as const }; @@ -88,12 +154,27 @@ export class KeyboardStateManager { return; } - if (Object.keys(this._state).length) { - // State is emptied if the element loses focus. - this._state = {}; + this._releaseHeldKeys(); + }; + + // Report every held key as newly released. The card receives key events only + // while it has focus, so it may never see the key release itself without + // this, and a condition that matches a released key would thus never + // evaluate. + private _releaseHeldKeys(): void { + let released = false; + + for (const [key, keyObj] of Object.entries(this._state)) { + if (keyObj.state === 'down') { + this._state[key] = { ...keyObj, state: 'up' as const }; + released = true; + } + } + + if (released) { this._processStateChange(); } - }; + } // Clone before passing to ConditionStateManager so that subsequent // in-place mutations to this._state don't affect the stored reference, diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index e94688d5..826fdd92 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -251,6 +251,7 @@ export interface CardInteractionAPI { } export interface CardKeyboardStateAPI { + getAutomationsManager(): AutomationsManager; getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; diff --git a/src/condition-trigger/triggers/triggers/key.ts b/src/condition-trigger/triggers/triggers/key.ts index c7d121b8..abcbe64e 100644 --- a/src/condition-trigger/triggers/triggers/key.ts +++ b/src/condition-trigger/triggers/triggers/key.ts @@ -4,6 +4,9 @@ import type { TriggerOfType } from './types'; export class KeyTrigger extends ConditionStateTriggerBase> { protected _getValue(state: ConditionState): unknown { - return state.keys; + const key = this._trigger.key; + + // Without a key the trigger is the any-change form, and watches every key. + return key === undefined ? state.keys : state.keys?.[key]; } } diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index b6f36c48..11f9aa85 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -12,15 +12,19 @@ const keyboardShortcut = z.object({ }); export type KeyboardShortcut = z.infer; +// Only claim the keys if there are NO modifiers pressed, otherwise they are +// allowed to fall through to browser handling. +const UNMODIFIED = { ctrl: false, alt: false, meta: false }; + const keyboardShortcutsDefault = { enabled: true, - ptz_left: { key: 'ArrowLeft' }, - ptz_right: { key: 'ArrowRight' }, - ptz_up: { key: 'ArrowUp' }, - ptz_down: { key: 'ArrowDown' }, - ptz_zoom_in: { key: '+' }, - ptz_zoom_out: { key: '-' }, - ptz_home: { key: 'h' }, + ptz_left: { key: 'ArrowLeft', ...UNMODIFIED }, + ptz_right: { key: 'ArrowRight', ...UNMODIFIED }, + ptz_up: { key: 'ArrowUp', ...UNMODIFIED }, + ptz_down: { key: 'ArrowDown', ...UNMODIFIED }, + ptz_zoom_in: { key: '+', ...UNMODIFIED }, + ptz_zoom_out: { key: '-', ...UNMODIFIED }, + ptz_home: { key: 'h', ...UNMODIFIED }, }; const keyboardShortcutsSchema = z.object({ diff --git a/tests/card-controller/actions/actions/ptz-digital.test.ts b/tests/card-controller/actions/actions/ptz-digital.test.ts index a2171a56..02b12aed 100644 --- a/tests/card-controller/actions/actions/ptz-digital.test.ts +++ b/tests/card-controller/actions/actions/ptz-digital.test.ts @@ -474,5 +474,93 @@ describe('should handle ptz digital action', () => { expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(1); }); + + it('should stop without anything in progress', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + + const stopAction = new PTZDigitalAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_digital', + ptz_phase: 'stop', + }, + ); + await stopAction.execute(api); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewWithModifiers).not.toHaveBeenCalled(); + }); + + it('should stop movement started by two concurrent starts', async () => { + const api = createCardAPI(); + const context = {}; + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + + const createStartAction = (ptzAction: PTZAction): PTZDigitalAction => + new PTZDigitalAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_digital', + ptz_action: ptzAction, + ptz_phase: 'start', + }); + + await Promise.all([ + createStartAction('left').execute(api), + createStartAction('up').execute(api), + ]); + + const stopAction = new PTZDigitalAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_digital', + ptz_phase: 'stop', + }); + await stopAction.execute(api); + + // One step per start, and none after the stop. + expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2); + }); + + it('should continue movement when a start is concurrent with a stop', async () => { + const api = createCardAPI(); + const context = {}; + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + + const leftAction = new PTZDigitalAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_digital', + ptz_action: 'left', + ptz_phase: 'start', + }); + await leftAction.execute(api); + + const stopAction = new PTZDigitalAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_digital', + ptz_phase: 'stop', + }); + const upAction = new PTZDigitalAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_digital', + ptz_action: 'up', + ptz_phase: 'start', + }); + + // The left key is released as the up key is pressed. + await Promise.all([stopAction.execute(api), upAction.execute(api)]); + + // One step for the left start, one for the up start. + expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(3); + }); }); }); diff --git a/tests/card-controller/actions/actions/ptz.test.ts b/tests/card-controller/actions/actions/ptz.test.ts index a54dee05..592a7e41 100644 --- a/tests/card-controller/actions/actions/ptz.test.ts +++ b/tests/card-controller/actions/actions/ptz.test.ts @@ -611,5 +611,108 @@ describe('should handle ptz action', () => { // There should be no additional calls. expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3); }); + + it('should stop movement started by two concurrent starts', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: [PTZMovementType.Relative], + up: [PTZMovementType.Relative], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const context = {}; + const createStartAction = (ptzAction: 'left' | 'up'): PTZAction => + new PTZAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz', + ptz_action: ptzAction, + ptz_phase: 'start', + }); + + await Promise.all([ + createStartAction('left').execute(api), + createStartAction('up').execute(api), + ]); + + const stopAction = new PTZAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz', + ptz_action: 'left', + ptz_phase: 'stop', + }); + await stopAction.execute(api); + + // One move per start, and none after the stop. + expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2); + + await vi.runOnlyPendingTimersAsync(); + + expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2); + }); + + it('should continue movement when a start is concurrent with a stop', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: [PTZMovementType.Relative], + up: [PTZMovementType.Relative], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const context = {}; + await new PTZAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz', + ptz_action: 'left', + ptz_phase: 'start', + }).execute(api); + + const stopAction = new PTZAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz', + ptz_action: 'left', + ptz_phase: 'stop', + }); + const upAction = new PTZAction(context, { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz', + ptz_action: 'up', + ptz_phase: 'start', + }); + + // The left key is released as the up key is pressed. + await Promise.all([stopAction.execute(api), upAction.execute(api)]); + + // One move for the left start, one for the up start. + expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2); + + await vi.runOnlyPendingTimersAsync(); + + expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3); + expect(api.getCameraManager().executePTZAction).toHaveBeenLastCalledWith( + 'camera.office', + 'up', + { preset: undefined }, + ); + }); }); }); diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index 85fc5ea5..29d2d925 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -5,6 +5,7 @@ import type { ActionsExecutionRequest } from '../../src/card-controller/actions/ import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; import type { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js'; import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js'; +import type { Trigger } from '../../src/config/schema/condition-trigger/triggers/types.js'; import { createCardAPI, createHASS, @@ -375,4 +376,27 @@ describe('AutomationsManager', () => { stateManager.setState({ expand: true }); expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(2); }); + + describe('should report the triggers it subscribes to', () => { + it('with no automations', () => { + expect(new AutomationsManager(createCardAPI()).getTriggers()).toEqual([]); + }); + + it('with automations', () => { + const keyTrigger: Trigger = { trigger: 'key', key: 'ArrowLeft' }; + const expandTrigger: Trigger = { trigger: 'expand', expand: true }; + + const automationsManager = new AutomationsManager(createCardAPI()); + automationsManager.addAutomations([ + { triggers: [keyTrigger, expandTrigger], actions }, + { triggers: triggers, actions }, + ]); + + expect(automationsManager.getTriggers()).toEqual([ + keyTrigger, + expandTrigger, + ...triggers, + ]); + }); + }); }); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 737b900c..4ce46ae5 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -538,7 +538,7 @@ describe('ConfigManager', () => { view: { keyboard_shortcuts: { enabled: true, - ptz_home: { key: 'h' }, + ptz_home: { key: 'q' }, }, }, overrides: [ @@ -552,39 +552,28 @@ describe('ConfigManager', () => { manager.setConfig(config); await flushPromises(); - // Verify keyboard shortcuts automations were added initially with ptz_home - expect(addAutomationsSpy).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - triggers: expect.arrayContaining([ - expect.objectContaining({ trigger: 'key', key: 'h' }), - ]), - actions: expect.arrayContaining([ - expect.objectContaining({ - advanced_camera_card_action: 'ptz_multi', - }), - ]), - }), - ]), - ); + const hasKeyTrigger = (key: string): boolean => + addAutomationsSpy.mock.calls.some((call) => + call[0].some((automation: Automation) => + automation.triggers.some( + (trig: Trigger) => trig.trigger === 'key' && trig.key === key, + ), + ), + ); + + // The configured binding, rather than the default one. + expect(hasKeyTrigger('q')).toBe(true); addAutomationsSpy.mockClear(); - // Trigger the override - keyboard_shortcuts should be deleted + // Trigger the override, which deletes the shortcut configuration. stateManager.setState({ fullscreen: true }); await flushPromises(); - // Verify newly added automations don't contain keyboard shortcuts (key: 'h') - // This confirms the override removed them (directly verified through add calls) - const addCalls = addAutomationsSpy.mock.calls; - const hasKeyboardShortcut = addCalls.some((call) => - call[0].some((automation: Automation) => - automation.triggers.some( - (trig: Trigger) => trig.trigger === 'key' && trig.key === 'h', - ), - ), - ); - expect(hasKeyboardShortcut).toBe(false); + // Deleting the configuration restores the defaults rather than removing + // the shortcuts, so the loader re-runs and binds the default key. + expect(hasKeyTrigger('q')).toBe(false); + expect(hasKeyTrigger('h')).toBe(true); }); it('should re-run folders loader when overrides change', async () => { diff --git a/tests/card-controller/config/load-keyboard-shortcuts.test.ts b/tests/card-controller/config/load-keyboard-shortcuts.test.ts index 0e9a8c42..ee9525c5 100644 --- a/tests/card-controller/config/load-keyboard-shortcuts.test.ts +++ b/tests/card-controller/config/load-keyboard-shortcuts.test.ts @@ -114,6 +114,55 @@ describe('setKeyboardShortcutsFromConfig', () => { ]); }); + it('should give the start and stop of a shortcut the same modifiers', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + keyboard_shortcuts: { + enabled: true, + ptz_home: null, + ptz_right: null, + ptz_up: null, + ptz_down: null, + ptz_zoom_in: null, + ptz_zoom_out: null, + ptz_left: { key: 'z', ctrl: false, alt: false, meta: false }, + }, + }, + }), + ); + + setKeyboardShortcutsFromConfig(api); + + const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0]; + expect(automations.map((automation) => automation.triggers)).toEqual([ + [ + { + trigger: 'key', + key: 'z', + state: 'down', + ctrl: false, + alt: false, + meta: false, + shift: undefined, + }, + ], + [ + { + trigger: 'key', + key: 'z', + state: 'up', + ctrl: false, + alt: false, + meta: false, + shift: undefined, + }, + ], + ]); + }); + it('ptz_home', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); @@ -134,11 +183,11 @@ describe('setKeyboardShortcutsFromConfig', () => { ], triggers: [ { - alt: undefined, + alt: false, trigger: 'key', - ctrl: undefined, + ctrl: false, key: 'h', - meta: undefined, + meta: false, shift: undefined, state: 'down', }, diff --git a/tests/card-controller/keyboard-state-manager.browser.test.ts b/tests/card-controller/keyboard-state-manager.browser.test.ts index 23d6ba73..3cb284fa 100644 --- a/tests/card-controller/keyboard-state-manager.browser.test.ts +++ b/tests/card-controller/keyboard-state-manager.browser.test.ts @@ -1,7 +1,10 @@ -import { assert, describe, expect, it } from 'vitest'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; +import { STEP_PAN } from '../../src/card-controller/actions/actions/ptz-digital'; +import type { ZoomSettingsObserved } from '../../src/components-lib/zoom/types'; import type { LogActionConfig } from '../../src/config/schema/actions/custom/log'; import { createLogAction } from '../../src/utils/action'; +import { isRecord } from '../../src/utils/basic'; import { clickElement, dispatchPointerDown, @@ -117,6 +120,9 @@ const mountCard = async (options?: MountCardOptions): Promise => { return card; }; +const isZoomSettingsObserved = (detail: unknown): detail is ZoomSettingsObserved => + isRecord(detail) && isRecord(detail.pan) && typeof detail.pan.x === 'number'; + // What the live view draws the camera into, which is the part of the card a // user looks at and the largest part of it that is not a control. const LIVE_MEDIA_SELECTOR = 'advanced-camera-card-live-provider'; @@ -125,6 +131,10 @@ const clickMedia = async (card: MountedCard): Promise => await clickElement(await card.waitForSelector(LIVE_MEDIA_SELECTOR)); describe('KeyboardStateManager', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + it('should not act on a key until the card has been used', async () => { const card = await mountCard(); @@ -307,6 +317,51 @@ describe('KeyboardStateManager', () => { await card.console.waitForMessage(KEY_MESSAGE, { count: 2 }); }); + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2623 + it('should keep panning while an arrow key is held after previous press', async () => { + const card = await mountCard(); + + card.setEntityState(ZOOM_ENTITY, 'on'); + await card.events.waitForFirst('advanced-camera-card:zoom:zoomed'); + await clickMedia(card); + + // Press a key to ensure holds after initial press are functional. + await pressKey('ArrowUp'); + + // How far across the camera the picture sits, as a percentage, from the + // last change the card reported. It starts halfway across. + const panX = (): number | null => { + const detail = card.events + .getEntries('advanced-camera-card:zoom:change') + .at(-1)?.detail; + return isZoomSettingsObserved(detail) ? detail.pan.x : null; + }; + + //`shouldAdvanceTime` lets the clock run at its own pace until "controlled", + // necessary for the panning while a key is being "held" to work. + vi.useFakeTimers({ shouldAdvanceTime: true }); + + await holdKey('ArrowLeft'); + await card.waitForRender(() => { + const x = panX(); + + // Stop well short of the left edge, to allow detection of continuous pan + // that never stopped. + return x !== null && x < 40 ? x : null; + }, 'the picture to pan left'); + await releaseKey('ArrowLeft'); + + const atRelease = panX(); + assert(atRelease !== null); + + await card.advanceSeconds(5); + + const afterWaiting = panX(); + assert(afterWaiting !== null); + + expect(Math.abs(atRelease - afterWaiting)).toBeLessThanOrEqual(STEP_PAN); + }); + it('should not act on a key aimed at another card', async () => { const card = await mountCard(); const otherCard = await mountCard({ keyMessage: OTHER_CARD_KEY_MESSAGE }); diff --git a/tests/card-controller/keyboard-state-manager.test.ts b/tests/card-controller/keyboard-state-manager.test.ts index fc1de4ce..4213416f 100644 --- a/tests/card-controller/keyboard-state-manager.test.ts +++ b/tests/card-controller/keyboard-state-manager.test.ts @@ -1,20 +1,54 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, type MockInstance } from 'vitest'; +import type { CardController } from '../../src/card-controller/controller'; import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager'; +import type { Trigger } from '../../src/config/schema/condition-trigger/triggers/types'; import { createCardAPI, createLitElement } from '../test-utils'; // @vitest-environment jsdom describe('KeyboardStateManager', () => { + const createManager = ( + triggers: Trigger[] = [], + ): { api: CardController; element: HTMLElement; manager: KeyboardStateManager } => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getAutomationsManager().getTriggers).mockReturnValue(triggers); + + const manager = new KeyboardStateManager(api); + manager.initialize(); + + return { api, element, manager }; + }; + + // For the tests that assert whether a press was claimed. Counting the calls + // on the returned spy, rather than reading `defaultPrevented`, is what tells + // the manager's prevention apart from that of another listener on the same + // press. + const dispatchKeydownWithPreventionSpy = ( + target: HTMLElement, + options?: KeyboardEventInit, + ): MockInstance => { + const event = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + composed: true, + cancelable: true, + ...options, + }); + const preventDefault = vi.spyOn(event, 'preventDefault'); + + target.dispatchEvent(event); + + return preventDefault; + }; + it('should construct', () => { expect(new KeyboardStateManager(createCardAPI())).toBeTruthy(); }); it('should set state on keydown', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new KeyboardStateManager(api); - manager.initialize(); + const { api, element } = createManager(); element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); @@ -30,12 +64,109 @@ describe('KeyboardStateManager', () => { expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(1); }); + describe('should stop the browser acting on a keypress', () => { + it('should stop when a trigger acts on the press of the key', () => { + const { element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]); + + expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled(); + }); + + it('should stop when a trigger acts on the release of the key', () => { + const { element } = createManager([ + { trigger: 'key', key: 'ArrowDown', state: 'up' }, + ]); + + // The browser scrolls as the key goes down, so the press must be claimed + // then even though the card acts on the release. + expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled(); + }); + + it('should not stop when no trigger matches the key', () => { + const { element } = createManager([{ trigger: 'key', key: 'ArrowUp' }]); + + expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled(); + }); + + it('should not stop when the triggers are not about keys at all', () => { + const { element } = createManager([{ trigger: 'fullscreen', fullscreen: true }]); + + expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled(); + }); + + describe('should not stop when a modifier the trigger asks for is absent', () => { + it.each([ + ['ctrl' as const, { ctrlKey: true }], + ['alt' as const, { altKey: true }], + ['meta' as const, { metaKey: true }], + ['shift' as const, { shiftKey: true }], + ])('with %s', (modifier: string, held: KeyboardEventInit) => { + const { element } = createManager([ + { trigger: 'key', key: 'ArrowDown', [modifier]: true }, + ]); + + expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled(); + expect(dispatchKeydownWithPreventionSpy(element, held)).toHaveBeenCalled(); + }); + }); + + it('should not stop when a trigger for the key is turned off', () => { + const { element } = createManager([ + { trigger: 'key', key: 'ArrowDown', enabled: false }, + ]); + + expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled(); + }); + + it('should not stop when a trigger watches every key', () => { + const { api, element } = createManager([{ trigger: 'key' }]); + + expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled(); + expect(api.getConditionStateManager().setState).toHaveBeenCalled(); + }); + + it('should stop on every repeat of a held key', () => { + const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]); + + expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled(); + expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled(); + + expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(1); + }); + }); + + describe('should ignore a key press that belongs to another element', () => { + it('should ignore when something else has already answered the press', () => { + const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]); + element.addEventListener('keydown', (ev) => ev.preventDefault(), { + capture: true, + }); + + // Once from the listener above. + expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalledTimes(1); + expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); + }); + + it('should ignore when a composition is being typed', () => { + const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]); + + expect( + dispatchKeydownWithPreventionSpy(element, { isComposing: true }), + ).not.toHaveBeenCalled(); + expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); + }); + + it('should ignore when the press lands on an element with keys of its own', () => { + const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]); + const input = document.createElement('input'); + element.append(input); + + expect(dispatchKeydownWithPreventionSpy(input)).not.toHaveBeenCalled(); + expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); + }); + }); + it('should set state on keyup', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new KeyboardStateManager(api); - manager.initialize(); + const { api, element } = createManager(); element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })); @@ -53,12 +184,8 @@ describe('KeyboardStateManager', () => { }); }); - it('should set state on focus loss', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new KeyboardStateManager(api); - manager.initialize(); + it('should release held keys on focus loss', () => { + const { api, element } = createManager(); element.dispatchEvent(new FocusEvent('blur')); expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); @@ -68,16 +195,42 @@ describe('KeyboardStateManager', () => { expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(2); expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ - keys: {}, + keys: { + a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false }, + }, }); }); + it('should set state on keydown of a previously released key', () => { + const { api, element } = createManager(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + element.dispatchEvent(new FocusEvent('blur')); + vi.mocked(api.getConditionStateManager().setState).mockClear(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({ + keys: { + a: { state: 'down', ctrl: false, alt: false, meta: false, shift: false }, + }, + }); + }); + + it('should not set state on focus loss when no key is held', () => { + const { api, element } = createManager(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })); + vi.mocked(api.getConditionStateManager().setState).mockClear(); + + element.dispatchEvent(new FocusEvent('blur')); + + expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); + }); + it('should not clear state when focus moves within the card', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new KeyboardStateManager(api); - manager.initialize(); + const { api, element } = createManager(); element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); element.dispatchEvent(new FocusEvent('blur', { relatedTarget: element })); @@ -91,12 +244,8 @@ describe('KeyboardStateManager', () => { }); it('should take focus on pointerdown', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const { element } = createManager(); const focus = vi.spyOn(element, 'focus'); - const manager = new KeyboardStateManager(api); - manager.initialize(); element.dispatchEvent(new Event('pointerdown')); @@ -104,9 +253,7 @@ describe('KeyboardStateManager', () => { }); it('should not take focus on pointerdown when focus is already within the card', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const { element } = createManager(); document.body.append(element); const child = document.createElement('div'); @@ -115,8 +262,6 @@ describe('KeyboardStateManager', () => { child.focus(); const focus = vi.spyOn(element, 'focus'); - const manager = new KeyboardStateManager(api); - manager.initialize(); element.dispatchEvent(new Event('pointerdown')); @@ -126,12 +271,8 @@ describe('KeyboardStateManager', () => { }); it('should not take focus on pointerdown after uninitialization', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const { element, manager } = createManager(); const focus = vi.spyOn(element, 'focus'); - const manager = new KeyboardStateManager(api); - manager.initialize(); manager.uninitialize(); element.dispatchEvent(new Event('pointerdown')); @@ -140,11 +281,7 @@ describe('KeyboardStateManager', () => { }); it('should not act after uninitialization', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new KeyboardStateManager(api); - manager.initialize(); + const { api, element, manager } = createManager(); manager.uninitialize(); element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); @@ -152,27 +289,23 @@ describe('KeyboardStateManager', () => { expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); }); - it('should clear held keys on uninitialize', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new KeyboardStateManager(api); - manager.initialize(); + it('should release held keys on uninitialize', () => { + const { api, element, manager } = createManager(); element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); vi.mocked(api.getConditionStateManager().setState).mockClear(); manager.uninitialize(); - expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({ keys: {} }); + expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({ + keys: { + a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false }, + }, + }); }); it('should not set state on uninitialize when no keys held', () => { - const api = createCardAPI(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new KeyboardStateManager(api); - manager.initialize(); + const { api, manager } = createManager(); manager.uninitialize(); expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); diff --git a/tests/condition-trigger/triggers/triggers/key.test.ts b/tests/condition-trigger/triggers/triggers/key.test.ts new file mode 100644 index 00000000..7ea64898 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/key.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, type Mock } from 'vitest'; + +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { KeyTrigger } from '../../../../src/condition-trigger/triggers/triggers/key'; +import type { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; + +describe('KeyTrigger', () => { + const create = ( + trigger: TriggerOfType<'key'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new KeyTrigger(trigger, createTriggerEvaluatorContext({ stateManager })).subscribe( + callback, + ); + return { stateManager, callback }; + }; + + const modifiers = { ctrl: false, shift: false, alt: false, meta: false }; + const down = { state: 'down' as const, ...modifiers }; + const up = { state: 'up' as const, ...modifiers }; + + it('should trigger when the key is pressed', () => { + const { stateManager, callback } = create({ trigger: 'key', key: 'a' }); + + stateManager.setState({ keys: { a: down } }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger when the key is released', () => { + const { stateManager, callback } = create({ + trigger: 'key', + key: 'a', + state: 'up', + }); + + stateManager.setState({ keys: { a: down } }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ keys: { a: up } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger when another key changes', () => { + const { stateManager, callback } = create({ + trigger: 'key', + key: 'a', + state: 'up', + }); + + stateManager.setState({ keys: { a: down } }); + stateManager.setState({ keys: { a: up } }); + expect(callback).toHaveBeenCalledTimes(1); + + // 'a' is still released, so its condition still holds, but the user acted + // on a different key entirely. + stateManager.setState({ keys: { a: up, b: down } }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on any key without a key of its own', () => { + const { stateManager, callback } = create({ trigger: 'key' }); + + stateManager.setState({ keys: { a: down } }); + stateManager.setState({ keys: { a: down, b: down } }); + + expect(callback).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 3ce58efc..853c0ac9 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -523,25 +523,46 @@ describe('config defaults', () => { keyboard_shortcuts: { enabled: true, ptz_down: { + alt: false, + ctrl: false, key: 'ArrowDown', + meta: false, }, ptz_home: { + alt: false, + ctrl: false, key: 'h', + meta: false, }, ptz_left: { + alt: false, + ctrl: false, key: 'ArrowLeft', + meta: false, }, ptz_right: { + alt: false, + ctrl: false, key: 'ArrowRight', + meta: false, }, ptz_up: { + alt: false, + ctrl: false, key: 'ArrowUp', + meta: false, }, ptz_zoom_in: { + alt: false, + ctrl: false, key: '+', + meta: false, }, ptz_zoom_out: { + alt: false, + ctrl: false, key: '-', + meta: false, }, }, theme: {