Repository: evan-liu/karabiner.ts
Pull request #69 (2023-05-05 09:43:44)

✨ Add modifiers to mapDoubleTap()


Patch:
diff --git a/src/config/double-tap.ts b/src/config/double-tap.ts
index 3ad13fb..9fe6c1e 100644
--- a/src/config/double-tap.ts
+++ b/src/config/double-tap.ts
@@ -1,11 +1,21 @@
 import { BasicManipulatorBuilder } from './manipulator'
-import { BasicManipulator } from '../karabiner/karabiner-config'
-import { FromKeyParam } from './from'
+import {
+  BasicManipulator,
+  FromKeyCodeEvent,
+  Modifier,
+  ToEvent,
+} from '../karabiner/karabiner-config'
 import { getKeyWithAlias, KeyAlias, NumberKeyValue } from '../utils/key-alias'
 import { FromAndToKeyCode } from '../karabiner/key-code'
 import { ifVar } from './condition'
 import { toSetVar } from './to'
 import { BuildContext } from '../utils/build-context'
+import { FromModifierParam } from './modifier'
+import {
+  FromModifierOverloadParam,
+  FromOptionalModifierParam,
+  parseFromModifierOverload,
+} from '../utils/from-modifier-overload'
 
 export const defaultDoubleTapParameters = {
   'double_tap.delay_milliseconds': 200,
@@ -13,46 +23,130 @@ export const defaultDoubleTapParameters = {
 
 export type DoubleTapParam = FromAndToKeyCode | KeyAlias | NumberKeyValue
 
+/** Start a manipulator for double-tapping a key with modifiers */
+export function mapDoubleTap(
+  key: DoubleTapParam,
+  mandatoryModifiers: FromModifierParam | '' | null,
+  optionalModifiers?: FromModifierParam,
+  delay?: number,
+): DoubleTapManipulatorBuilder
+/** Start a manipulator for double-tapping a key with modifiers */
+export function mapDoubleTap(
+  key: DoubleTapParam,
+  modifiers: FromModifierParam | FromOptionalModifierParam,
+  delay?: number,
+): DoubleTapManipulatorBuilder
 /** Start a manipulator for double-tapping a key */
-export function mapDoubleTap(key: DoubleTapParam, delay?: number) {
-  return new DoubleTapManipulatorBuilder(key, delay)
+export function mapDoubleTap(
+  key: DoubleTapParam,
+  delay?: number,
+): DoubleTapManipulatorBuilder
+/** Start a manipulator for double-tapping a key */
+export function mapDoubleTap(
+  key: DoubleTapParam,
+  arg1?: FromModifierOverloadParam | number,
+  arg2?: FromModifierParam | number,
+  arg3?: number,
+) {
+  const builder = new DoubleTapManipulatorBuilder({
+    key_code: getKeyWithAlias<FromAndToKeyCode>(key),
+  })
+  if (arg3) {
+    builder.delay(arg3)
+    builder.from.modifiers = parseFromModifierOverload(
+      arg1 as FromModifierOverloadParam,
+      arg2 as FromModifierParam,
+    )
+  } else if (arg2) {
+    if (typeof arg2 === 'number') {
+      builder.delay(arg2)
+      builder.from.modifiers = parseFromModifierOverload(
+        arg1 as FromModifierOverloadParam,
+      )
+    } else {
+      builder.from.modifiers = parseFromModifierOverload(
+        arg1 as FromModifierOverloadParam,
+        arg2 as FromModifierParam,
+      )
+    }
+  } else if (arg1) {
+    if (typeof arg1 === 'number') {
+      builder.delay(arg1)
+    } else {
+      builder.from.modifiers = parseFromModifierOverload(
+        arg1 as FromModifierOverloadParam,
+      )
+    }
+  }
+  return builder
 }
 
 export class DoubleTapManipulatorBuilder extends BasicManipulatorBuilder {
-  private readonly keyCode: FromAndToKeyCode
-  private readonly varName: string
+  private singleTapEvent?: ToEvent | null = undefined
+  private delayParam?: number
 
-  constructor(key: FromKeyParam, private readonly delay?: number) {
-    const keyCode = getKeyWithAlias<FromAndToKeyCode>(key)
-    super({ key_code: keyCode })
+  constructor(from: FromKeyCodeEvent & { key_code: FromAndToKeyCode }) {
+    super(from)
+  }
+
+  /** Set single tap which is from key by default; null to disable */
+  public singleTap(v: ToEvent | null): this {
+    this.singleTapEvent = v
+    return this
+  }
 
-    this.keyCode = keyCode
-    this.varName = `double-tap-${keyCode}`
+  /** Set the delay parameter */
+  public delay(v: number): this {
+    this.delayParam = v
+    return this
   }
 
   public build(context?: BuildContext): BasicManipulator[] {
     const params =
       context?.getParameters(defaultDoubleTapParameters) ??
       defaultDoubleTapParameters
-    const delay = this.delay || params['double_tap.delay_milliseconds']
+    const delay = this.delayParam || params['double_tap.delay_milliseconds']
 
-    const onCondition = ifVar(this.varName).build()
-    const offCondition = ifVar(this.varName).unless().build()
+    const keyCode = (this.from as FromKeyCodeEvent).key_code as FromAndToKeyCode
+
+    if (this.singleTapEvent === undefined) {
+      this.singleTapEvent = { key_code: keyCode }
+      const modifiers = this.manipulator.from.modifiers?.mandatory as Array<
+        Modifier | 'any'
+      >
+      if (modifiers) {
+        this.singleTapEvent.modifiers = modifiers.filter(
+          (v) => v !== 'any',
+        ) as Modifier[]
+      }
+    }
+
+    const varNameParts = ['double-tap', keyCode]
+    if (this.from.modifiers) {
+      ;[this.from.modifiers.mandatory, this.from.modifiers.optional]
+        .map((v) => (v?.length ? v.join(',') : ''))
+        .forEach((v) => v && varNameParts.push(v))
+    }
+    const varName = varNameParts.join('-')
+
+    const onCondition = ifVar(varName).build()
+    const offCondition = ifVar(varName).unless().build()
 
     const baseManipulator: BasicManipulator = {
       ...this.manipulator,
       conditions: [...(this.manipulator.conditions || []), onCondition],
-      to: [...(this.manipulator.to || []), toSetVar(this.varName)],
     }
 
     const toggleManipulator: BasicManipulator = {
-      type: 'basic',
-      from: { key_code: this.keyCode },
-      to: [toSetVar(this.varName, 1)],
+      ...this.manipulator,
+      to: [toSetVar(varName, 1)],
       conditions: [offCondition],
       to_delayed_action: {
-        to_if_invoked: [{ key_code: this.keyCode }, toSetVar(this.varName, 0)],
-        to_if_canceled: [toSetVar(this.varName, 0)],
+        to_if_invoked: [
+          ...(this.singleTapEvent ? [this.singleTapEvent] : []),
+          toSetVar(varName, 0),
+        ],
+        to_if_canceled: [toSetVar(varName, 0)],
       },
     }
     toggleManipulator.parameters = {
diff --git a/src/config/from.ts b/src/config/from.ts
index 55adb6c..e822605 100644
--- a/src/config/from.ts
+++ b/src/config/from.ts
@@ -1,6 +1,6 @@
 import { FromKeyCode } from '../karabiner/key-code'
 import { getKeyWithAlias, KeyAlias, NumberKeyValue } from '../utils/key-alias'
-import { FromModifierParam, parseFromModifierParams } from './modifier'
+import { FromModifierParam } from './modifier'
 import { BasicManipulatorBuilder } from './manipulator'
 import {
   FromEvent,
@@ -9,6 +9,11 @@ import {
 } from '../karabiner/karabiner-config'
 import { FromConsumerKeyCode } from '../karabiner/consumer-key-code'
 import { PointingButton } from '../karabiner/pointing-button'
+import {
+  FromModifierOverloadParam,
+  FromOptionalModifierParam,
+  parseFromModifierOverload,
+} from '../utils/from-modifier-overload'
 
 export type FromKeyParam = FromKeyCode | KeyAlias | NumberKeyValue
 
@@ -23,7 +28,7 @@ export function map(
 /** Start a manipulator with a from.key_code and optional modifiers */
 export function map(
   key: FromKeyParam,
-  modifiers: 'optionalAny' | { optional: FromModifierParam },
+  modifiers: FromOptionalModifierParam,
 ): BasicManipulatorBuilder
 export function map(
   keyOrEvent: FromKeyParam | FromEvent,
@@ -34,7 +39,7 @@ export function map(
     return new BasicManipulatorBuilder(keyOrEvent)
   return new BasicManipulatorBuilder({
     key_code: getKeyWithAlias<FromKeyCode>(keyOrEvent),
-    modifiers: parseModifierOverload(mandatoryModifiers, optionalModifiers),
+    modifiers: parseFromModifierOverload(mandatoryModifiers, optionalModifiers),
   })
 }
 
@@ -65,7 +70,7 @@ export function mapConsumerKey(
 ) {
   return new BasicManipulatorBuilder({
     consumer_key_code: code,
-    modifiers: parseModifierOverload(mandatoryModifiers, optionalModifiers),
+    modifiers: parseFromModifierOverload(mandatoryModifiers, optionalModifiers),
   })
 }
 
@@ -83,31 +88,6 @@ export function mapPointingButton(
 ) {
   return new BasicManipulatorBuilder({
     pointing_button: button,
-    modifiers: parseModifierOverload(mandatoryModifiers, optionalModifiers),
+    modifiers: parseFromModifierOverload(mandatoryModifiers, optionalModifiers),
   })
 }
-
-type FromModifierOverloadParam =
-  | FromModifierParam
-  | ''
-  | null
-  | 'optionalAny'
-  | { optional: FromModifierParam }
-
-function parseModifierOverload(
-  mandatoryModifiers?: FromModifierOverloadParam,
-  optionalModifiers?: FromModifierParam,
-) {
-  if (!mandatoryModifiers) {
-    return parseFromModifierParams(mandatoryModifiers, optionalModifiers)
-  } else if (mandatoryModifiers === 'optionalAny') {
-    return parseFromModifierParams('', 'any')
-  } else if (
-    typeof mandatoryModifiers === 'object' &&
-    'optional' in mandatoryModifiers
-  ) {
-    return parseFromModifierParams('', mandatoryModifiers.optional)
-  } else {
-    return parseFromModifierParams(mandatoryModifiers, optionalModifiers)
-  }
-}
diff --git a/src/utils/from-modifier-overload.ts b/src/utils/from-modifier-overload.ts
new file mode 100644
index 0000000..47a362d
--- /dev/null
+++ b/src/utils/from-modifier-overload.ts
@@ -0,0 +1,29 @@
+import { FromModifierParam, parseFromModifierParams } from '../config/modifier'
+
+export type FromOptionalModifierParam =
+  | 'optionalAny'
+  | { optional: FromModifierParam }
+
+export type FromModifierOverloadParam =
+  | FromModifierParam
+  | ''
+  | null
+  | FromOptionalModifierParam
+
+export function parseFromModifierOverload(
+  mandatoryModifiers?: FromModifierOverloadParam,
+  optionalModifiers?: FromModifierParam,
+) {
+  if (!mandatoryModifiers) {
+    return parseFromModifierParams(mandatoryModifiers, optionalModifiers)
+  } else if (mandatoryModifiers === 'optionalAny') {
+    return parseFromModifierParams('', 'any')
+  } else if (
+    typeof mandatoryModifiers === 'object' &&
+    'optional' in mandatoryModifiers
+  ) {
+    return parseFromModifierParams('', mandatoryModifiers.optional)
+  } else {
+    return parseFromModifierParams(mandatoryModifiers, optionalModifiers)
+  }
+}
diff --git a/src/utils/with-modifier.ts b/src/utils/with-modifier.ts
index 36c1e45..f2ebbbb 100644
--- a/src/utils/with-modifier.ts
+++ b/src/utils/with-modifier.ts
@@ -1,7 +1,11 @@
-import { FromEvent, Manipulator, Modifier } from '../karabiner/karabiner-config'
-import { FromModifierParam, parseFromModifierParams } from '../config/modifier'
+import { Manipulator, Modifier } from '../karabiner/karabiner-config'
+import { FromModifierParam } from '../config/modifier'
 import { buildManipulators, ManipulatorBuilder } from '../config/manipulator'
 import { BuildContext } from './build-context'
+import {
+  FromOptionalModifierParam,
+  parseFromModifierOverload,
+} from './from-modifier-overload'
 
 /**
  * A high-order function to add modifiers to a group of manipulators
@@ -26,37 +30,20 @@ export function withModifier(
  *   ])
  */
 export function withModifier(
-  modifiers: 'optionalAny' | { optional: FromModifierParam },
+  modifiers: FromOptionalModifierParam,
 ): (manipulators: Array<Manipulator | ManipulatorBuilder>) => ManipulatorBuilder
 export function withModifier(
-  mandatoryModifiers:
-    | FromModifierParam
-    | 'optionalAny'
-    | { optional: FromModifierParam },
+  mandatoryModifiers: FromModifierParam | FromOptionalModifierParam,
   optionalModifiers?: FromModifierParam,
 ): (
   manipulators: Array<Manipulator | ManipulatorBuilder>,
 ) => ManipulatorBuilder {
   return (manipulators) => ({
     build: (context?: BuildContext) => {
-      let sharedModifiers: Exclude<FromEvent['modifiers'], undefined>
-      if (mandatoryModifiers === 'optionalAny') {
-        sharedModifiers = parseFromModifierParams('', 'any')!
-      } else if (
-        typeof mandatoryModifiers === 'object' &&
-        'optional' in mandatoryModifiers
-      ) {
-        sharedModifiers = parseFromModifierParams(
-          '',
-          mandatoryModifiers.optional,
-        )!
-      } else {
-        sharedModifiers = parseFromModifierParams(
-          mandatoryModifiers,
-          optionalModifiers,
-        )!
-      }
-
+      const sharedModifiers = parseFromModifierOverload(
+        mandatoryModifiers,
+        optionalModifiers,
+      )!
       return manipulators
         .map((v) => buildManipulators(v, context))
         .reduce((result, manipulator) => result.concat(manipulator), [])


Test patch:
diff --git a/src/config/double-tap.test.ts b/src/config/double-tap.test.ts
index 9516637..a58b1e8 100644
--- a/src/config/double-tap.test.ts
+++ b/src/config/double-tap.test.ts
@@ -1,43 +1,121 @@
-import { expect, test } from 'vitest'
-test('mapDoubleTap()', () => {
-  expect(mapDoubleTap(1).to(2).build()).toEqual([
-    {
-      type: 'basic',
-      from: { key_code: '1' },
-      to: [
-        { key_code: '2' },
-        { set_variable: { name: 'double-tap-1', value: 1 } },
-      ],
-      conditions: [{ name: 'double-tap-1', type: 'variable_if', value: 1 }],
-    },
-    {
-      type: 'basic',
-      from: { key_code: '1' },
-      to: [{ set_variable: { name: 'double-tap-1', value: 1 } }],
-      conditions: [
-        {
-          description: undefined,
-          name: 'double-tap-1',
-          type: 'variable_unless',
-          value: 1,
-        },
-      ],
-      to_delayed_action: {
-        to_if_invoked: [
-          { key_code: '1' },
-          { set_variable: { name: 'double-tap-1', value: 0 } },
+import { describe, expect, test } from 'vitest'
+import { mapDoubleTap } from './double-tap'
+import { toKey } from './to'
+
+describe('mapDoubleTap()', () => {
+  test('to_delayed_action', () => {
+    expect(mapDoubleTap(1).to(2).build()).toEqual([
+      {
+        type: 'basic',
+        from: { key_code: '1' },
+        to: [{ key_code: '2' }],
+        conditions: [{ name: 'double-tap-1', type: 'variable_if', value: 1 }],
+      },
+      {
+        type: 'basic',
+        from: { key_code: '1' },
+        to: [{ set_variable: { name: 'double-tap-1', value: 1 } }],
+        conditions: [
+          {
+            description: undefined,
+            name: 'double-tap-1',
+            type: 'variable_unless',
+            value: 1,
+          },
         ],
-        to_if_canceled: [{ set_variable: { name: 'double-tap-1', value: 0 } }],
+        to_delayed_action: {
+          to_if_invoked: [
+            { key_code: '1' },
+            { set_variable: { name: 'double-tap-1', value: 0 } },
+          ],
+          to_if_canceled: [
+            { set_variable: { name: 'double-tap-1', value: 0 } },
+          ],
+        },
+        parameters: { 'basic.to_delayed_action_delay_milliseconds': 200 },
       },
-      parameters: { 'basic.to_delayed_action_delay_milliseconds': 200 },
-    },
-  ])
+    ])
+  })
 
-  expect(mapDoubleTap(1, 11).to(2).build()[1].parameters).toEqual({
-    'basic.to_delayed_action_delay_milliseconds': 11,
+  test('parameters', () => {
+    expect(mapDoubleTap(1, 11).to(2).build()[1].parameters).toEqual({
+      'basic.to_delayed_action_delay_milliseconds': 11,
+    })
   })
 
-  expect(mapDoubleTap(1).build()[0].to?.length).toBe(1)
-})
+  test('toSingleTap()', () => {
+    expect(
+      mapDoubleTap(1).singleTap(toKey(0)).to(2).build()[1].to_delayed_action
+        ?.to_if_invoked[0],
+    ).toEqual({ key_code: '0' })
 
-import { mapDoubleTap } from './double-tap'
+    expect(mapDoubleTap(1).singleTap(null).delay(11).to(2).build()).toEqual([
+      {
+        type: 'basic',
+        from: { key_code: '1' },
+        to: [{ key_code: '2' }],
+        conditions: [{ name: 'double-tap-1', type: 'variable_if', value: 1 }],
+      },
+      {
+        type: 'basic',
+        from: { key_code: '1' },
+        to: [{ set_variable: { name: 'double-tap-1', value: 1 } }],
+        conditions: [
+          {
+            description: undefined,
+            name: 'double-tap-1',
+            type: 'variable_unless',
+            value: 1,
+          },
+        ],
+        to_delayed_action: {
+          to_if_invoked: [{ set_variable: { name: 'double-tap-1', value: 0 } }],
+          to_if_canceled: [
+            { set_variable: { name: 'double-tap-1', value: 0 } },
+          ],
+        },
+        parameters: { 'basic.to_delayed_action_delay_milliseconds': 11 },
+      },
+    ])
+  })
+
+  test('modifiers', () => {
+    expect(
+      mapDoubleTap(1, '⌘').to(2).build()[1].to_delayed_action?.to_if_invoked[0],
+    ).toEqual({ key_code: '1', modifiers: ['command'] })
+
+    expect(mapDoubleTap(1).to(2).build()[1].from).toEqual({ key_code: '1' })
+    expect(mapDoubleTap(1, '⌘').to(2).build()[1].from).toEqual({
+      key_code: '1',
+      modifiers: { mandatory: ['command'] },
+    })
+    expect(mapDoubleTap(1, '⌘', '⌥').to(2).build()[1].from).toEqual({
+      key_code: '1',
+      modifiers: { mandatory: ['command'], optional: ['option'] },
+    })
+    expect(mapDoubleTap(1, 'optionalAny').to(2).build()[1].from).toEqual({
+      key_code: '1',
+      modifiers: { optional: ['any'] },
+    })
+
+    expect(
+      [
+        mapDoubleTap(1, 11),
+        mapDoubleTap(1, '⌘', 22),
+        mapDoubleTap(1, '⌘', '⌥', 33),
+        mapDoubleTap(1, 'optionalAny', 44),
+      ].map((v) => {
+        const { from, parameters } = v.to(2).build()[1]
+        return [
+          from.modifiers,
+          parameters?.['basic.to_delayed_action_delay_milliseconds'],
+        ]
+      }),
+    ).toEqual([
+      [undefined, 11],
+      [{ mandatory: ['command'] }, 22],
+      [{ mandatory: ['command'], optional: ['option'] }, 33],
+      [{ optional: ['any'] }, 44],
+    ])
+  })
+})