fix: Stop PTZ movement and browser key handling from fighting (#2674)

- Closes: #2623
This commit is contained in:
Dermot Duffy
2026-08-10 14:49:50 -07:00
committed by GitHub
parent a8aa9ec7c2
commit 3808a1b030
20 changed files with 822 additions and 148 deletions
+7 -7
View File
@@ -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` 🎨
+19
View File
@@ -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. |
@@ -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<PTZDigitialActionConfig> {
private _timer = new Timer();
private _stopped = false;
private async _stepChange(api: CardActionsAPI, targetID: string): Promise<void> {
api
@@ -46,6 +47,7 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
}
public async stop(): Promise<void> {
this._stopped = true;
this._timer.stop();
}
@@ -72,16 +74,20 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
/* v8 ignore else: the else path cannot be reached -- @preserve */
if (action.ptz_phase === 'start') {
await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
this._stopped = false;
await replaceInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
await this._stepChange(api, targetID);
this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
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');
}
}
+10 -16
View File
@@ -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<PTZActionConfig> {
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<void> => {
/* v8 ignore else: the else path cannot be reached as ptz_action
@@ -108,10 +103,10 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
});
}
// 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<PTZActionConfig> {
}
};
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;
@@ -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<void> => {
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<void> => {
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<void> => {
const stopped = context[contextKey]?.[targetID]?.inProgressAction;
delete context[contextKey]?.[targetID];
await stopped?.stop();
};
@@ -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) {
@@ -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: [
+92 -11
View File
@@ -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,
+1
View File
@@ -251,6 +251,7 @@ export interface CardInteractionAPI {
}
export interface CardKeyboardStateAPI {
getAutomationsManager(): AutomationsManager;
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
@@ -4,6 +4,9 @@ import type { TriggerOfType } from './types';
export class KeyTrigger extends ConditionStateTriggerBase<TriggerOfType<'key'>> {
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];
}
}
+11 -7
View File
@@ -12,15 +12,19 @@ const keyboardShortcut = z.object({
});
export type KeyboardShortcut = z.infer<typeof keyboardShortcut>;
// 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({
@@ -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);
});
});
});
@@ -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 },
);
});
});
});
@@ -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,
]);
});
});
});
@@ -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 () => {
@@ -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',
},
@@ -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<MountedCard> => {
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<void> =>
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 });
@@ -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();
@@ -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);
});
});
+21
View File
@@ -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: {