Skip to content

Commit 7b7c163

Browse files
committed
feat: Add duration to press and release internal action #3389
1 parent 2d0ac6a commit 7b7c163

2 files changed

Lines changed: 132 additions & 8 deletions

File tree

‎companion/lib/Internal/Controls.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import EventEmitter from 'node:events'
13+
import { setTimeout } from 'node:timers/promises'
1314
import debounceFn from 'debounce-fn'
1415
import { formatLocation, ParseControlId } from '@companion-app/shared/ControlId.js'
1516
import { ControlLocationOption } from '@companion-app/shared/ControlLocation.js'
@@ -155,6 +156,15 @@ export class InternalControls extends EventEmitter<InternalModuleFragmentEvents>
155156
default: false,
156157
disableAutoExpression: true,
157158
},
159+
{
160+
type: 'number',
161+
label: 'Hold duration (ms)',
162+
id: 'duration',
163+
default: 0,
164+
min: 0,
165+
max: Number.MAX_SAFE_INTEGER,
166+
description: 'How long to hold the button down before releasing it. 0 releases immediately',
167+
},
158168
],
159169
optionsSupportExpressions: true,
160170
},
@@ -569,16 +579,37 @@ export class InternalControls extends EventEmitter<InternalModuleFragmentEvents>
569579
action.options.delta = { value: delta, isExpression: false }
570580
return action
571581
}
582+
583+
// Hold duration was added later; existing actions released immediately
584+
if (action.definitionId === 'button_pressrelease' && action.options.duration === undefined) {
585+
action.options.duration = { value: 0, isExpression: false }
586+
return action
587+
}
572588
}
573589

574-
executeAction(action: ActionForInternalExecution, extras: RunActionExtras): InternalActionResult {
590+
executeAction(
591+
action: ActionForInternalExecution,
592+
extras: RunActionExtras
593+
): Promise<InternalActionResult> | InternalActionResult {
575594
switch (action.definitionId) {
576595
case 'button_pressrelease': {
577596
const { theControlId } = this.#fetchLocationAndControlId(action.options, extras)
578597
if (theControlId) {
579598
const forcePress = !!action.options.force
580599

581600
this.#controlsStore.pressControl(theControlId, true, extras.surfaceId, forcePress)
601+
602+
const duration = Number(action.options.duration)
603+
if (Number.isFinite(duration) && duration > 0 && !extras.abortDelayed.aborted) {
604+
// Release even if aborted, otherwise the button is left held and its hold timers keep running
605+
return setTimeout(duration, undefined, { signal: extras.abortDelayed })
606+
.catch(() => undefined)
607+
.then(() => {
608+
this.#controlsStore.pressControl(theControlId, false, extras.surfaceId, forcePress)
609+
return { result: undefined }
610+
})
611+
}
612+
582613
this.#controlsStore.pressControl(theControlId, false, extras.surfaceId, forcePress)
583614
}
584615
break

‎companion/test/Internal/Controls.test.ts‎

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EventEmitter } from 'node:events'
2-
import { describe, expect, test } from 'vitest'
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
33
import { mockDeep } from 'vitest-mock-extended'
44
import { EntityModelType, type ActionEntityModel } from '@companion-app/shared/Model/EntityModel.js'
55
import { exprVal } from '@companion-app/shared/Model/Options.js'
@@ -11,6 +11,18 @@ import { InternalControls } from '../../lib/Internal/Controls.js'
1111
import type { ActionForInternalExecution } from '../../lib/Internal/Types.js'
1212
import type { IPageStore } from '../../lib/Page/Store.js'
1313

14+
// Fake timers don't reach node:timers/promises, so route it through the (faked) global setTimeout
15+
vi.mock('node:timers/promises', () => ({
16+
setTimeout: async <T>(delay: number, value: T, options: { signal?: AbortSignal }) =>
17+
new Promise<T>((resolve, reject) => {
18+
const timer = globalThis.setTimeout(() => resolve(value), delay)
19+
options.signal?.addEventListener('abort', () => {
20+
globalThis.clearTimeout(timer)
21+
reject(new Error('aborted'))
22+
})
23+
}),
24+
}))
25+
1426
function createControls() {
1527
const graphicsController = mockDeep<GraphicsController>()
1628
const controlsStore = mockDeep<IControlStore>()
@@ -33,10 +45,10 @@ function makeStoredAction(definitionId: string): ActionEntityModel {
3345
}
3446
}
3547

36-
function makeExecAction(options: Record<string, unknown>): ActionForInternalExecution {
48+
function makeExecAction(definitionId: string, options: Record<string, unknown>): ActionForInternalExecution {
3749
return {
3850
id: 'action1',
39-
definitionId: 'button_rotate',
51+
definitionId,
4052
options: options as any,
4153
rawEntity: { rawOptions: {} } as any,
4254
}
@@ -79,14 +91,95 @@ describe('actionUpgrade', () => {
7991
expect(controls.actionUpgrade(makeStoredAction('button_press'), 'ctrl1')).toBeUndefined()
8092
expect(controls.actionUpgrade(makeStoredAction('button_rotate'), 'ctrl1')).toBeUndefined()
8193
})
94+
95+
test('button_pressrelease gains a zero hold duration, matching the old immediate release', () => {
96+
const { controls } = createControls()
97+
98+
const upgraded = controls.actionUpgrade(makeStoredAction('button_pressrelease'), 'ctrl1')
99+
100+
expect(upgraded?.definitionId).toBe('button_pressrelease')
101+
expect(upgraded?.options.duration).toEqual({ value: 0, isExpression: false })
102+
expect(upgraded?.options.location).toEqual(exprVal('1/2/3'))
103+
})
104+
105+
test('button_pressrelease with a duration is left untouched', () => {
106+
const { controls } = createControls()
107+
108+
const action = makeStoredAction('button_pressrelease')
109+
action.options.duration = exprVal(500)
110+
111+
expect(controls.actionUpgrade(action, 'ctrl1')).toBeUndefined()
112+
})
113+
})
114+
115+
describe('button_pressrelease execution', () => {
116+
beforeEach(() => {
117+
vi.useFakeTimers()
118+
})
119+
afterEach(() => {
120+
vi.useRealTimers()
121+
})
122+
123+
test('a zero duration presses and releases immediately', () => {
124+
const { controls, controlsStore, pageStore } = createControls()
125+
pageStore.getControlIdAt.mockReturnValue('control:target')
126+
127+
const result = controls.executeAction(
128+
makeExecAction('button_pressrelease', { location: '1/2/3', force: false, duration: 0 }),
129+
fakeExtras
130+
)
131+
132+
expect(result).toEqual({ result: undefined })
133+
expect(controlsStore.pressControl.mock.calls).toEqual([
134+
['control:target', true, 'surface0', false],
135+
['control:target', false, 'surface0', false],
136+
])
137+
})
138+
139+
test('holds the button for the duration before releasing', async () => {
140+
const { controls, controlsStore, pageStore } = createControls()
141+
pageStore.getControlIdAt.mockReturnValue('control:target')
142+
143+
const result = controls.executeAction(
144+
makeExecAction('button_pressrelease', { location: '1/2/3', force: true, duration: 500 }),
145+
fakeExtras
146+
)
147+
148+
expect(controlsStore.pressControl.mock.calls).toEqual([['control:target', true, 'surface0', true]])
149+
150+
await vi.advanceTimersByTimeAsync(499)
151+
expect(controlsStore.pressControl).toHaveBeenCalledTimes(1)
152+
153+
await vi.advanceTimersByTimeAsync(1)
154+
expect(controlsStore.pressControl).toHaveBeenLastCalledWith('control:target', false, 'surface0', true)
155+
await expect(result).resolves.toEqual({ result: undefined })
156+
})
157+
158+
test('releases straight away when the run is aborted mid-hold', async () => {
159+
const { controls, controlsStore, pageStore } = createControls()
160+
pageStore.getControlIdAt.mockReturnValue('control:target')
161+
162+
const abort = new AbortController()
163+
const result = controls.executeAction(
164+
makeExecAction('button_pressrelease', { location: '1/2/3', force: false, duration: 5000 }),
165+
{ ...fakeExtras, abortDelayed: abort.signal }
166+
)
167+
168+
abort.abort()
169+
await expect(result).resolves.toEqual({ result: undefined })
170+
expect(controlsStore.pressControl.mock.calls).toEqual([
171+
['control:target', true, 'surface0', false],
172+
['control:target', false, 'surface0', false],
173+
])
174+
})
82175
})
83176

84177
describe('button_rotate execution', () => {
85178
test('rotates the target control by the signed delta', () => {
86179
const { controls, controlsStore, pageStore } = createControls()
87180
pageStore.getControlIdAt.mockReturnValue('control:target')
88181

89-
controls.executeAction(makeExecAction({ location: '1/2/3', delta: 5 }), fakeExtras)
182+
void controls.executeAction(makeExecAction('button_rotate', { location: '1/2/3', delta: 5 }), fakeExtras)
90183

91184
expect(controlsStore.rotateControl).toHaveBeenCalledWith('control:target', 5, 'surface0')
92185
})
@@ -95,7 +188,7 @@ describe('button_rotate execution', () => {
95188
const { controls, controlsStore, pageStore } = createControls()
96189
pageStore.getControlIdAt.mockReturnValue('control:target')
97190

98-
controls.executeAction(makeExecAction({ location: '1/2/3', delta: -3 }), fakeExtras)
191+
void controls.executeAction(makeExecAction('button_rotate', { location: '1/2/3', delta: -3 }), fakeExtras)
99192

100193
expect(controlsStore.rotateControl).toHaveBeenCalledWith('control:target', -3, 'surface0')
101194
})
@@ -104,8 +197,8 @@ describe('button_rotate execution', () => {
104197
const { controls, controlsStore, pageStore } = createControls()
105198
pageStore.getControlIdAt.mockReturnValue('control:target')
106199

107-
controls.executeAction(makeExecAction({ location: '1/2/3', delta: 0 }), fakeExtras)
108-
controls.executeAction(makeExecAction({ location: '1/2/3', delta: 'nope' }), fakeExtras)
200+
void controls.executeAction(makeExecAction('button_rotate', { location: '1/2/3', delta: 0 }), fakeExtras)
201+
void controls.executeAction(makeExecAction('button_rotate', { location: '1/2/3', delta: 'nope' }), fakeExtras)
109202

110203
expect(controlsStore.rotateControl).not.toHaveBeenCalled()
111204
})

0 commit comments

Comments
 (0)