Repository: evan-liu/karabiner.ts
Pull request #68 (2023-05-05 06:34:27)

✨ Add 'optionalAny' modifier param to map() and withModifier()


Patch:
diff --git a/src/config/double-tap.ts b/src/config/double-tap.ts
index ab05a9f..3ad13fb 100644
--- a/src/config/double-tap.ts
+++ b/src/config/double-tap.ts
@@ -13,6 +13,7 @@ export const defaultDoubleTapParameters = {
 
 export type DoubleTapParam = FromAndToKeyCode | KeyAlias | NumberKeyValue
 
+/** Start a manipulator for double-tapping a key */
 export function mapDoubleTap(key: DoubleTapParam, delay?: number) {
   return new DoubleTapManipulatorBuilder(key, delay)
 }
@@ -22,7 +23,7 @@ export class DoubleTapManipulatorBuilder extends BasicManipulatorBuilder {
   private readonly varName: string
 
   constructor(key: FromKeyParam, private readonly delay?: number) {
-    const keyCode = getKeyWithAlias(key) as FromAndToKeyCode
+    const keyCode = getKeyWithAlias<FromAndToKeyCode>(key)
     super({ key_code: keyCode })
 
     this.keyCode = keyCode
diff --git a/src/config/from.ts b/src/config/from.ts
index 0059115..55adb6c 100644
--- a/src/config/from.ts
+++ b/src/config/from.ts
@@ -2,39 +2,52 @@ import { FromKeyCode } from '../karabiner/key-code'
 import { getKeyWithAlias, KeyAlias, NumberKeyValue } from '../utils/key-alias'
 import { FromModifierParam, parseFromModifierParams } from './modifier'
 import { BasicManipulatorBuilder } from './manipulator'
-import { FromEvent, SimultaneousOptions } from '../karabiner/karabiner-config'
+import {
+  FromEvent,
+  FromKeyType,
+  SimultaneousOptions,
+} from '../karabiner/karabiner-config'
 import { FromConsumerKeyCode } from '../karabiner/consumer-key-code'
 import { PointingButton } from '../karabiner/pointing-button'
 
 export type FromKeyParam = FromKeyCode | KeyAlias | NumberKeyValue
 
+/** Start a manipulator with a FromEvent */
 export function map(from: FromEvent): BasicManipulatorBuilder
+/** Start a manipulator with a from.key_code and modifiers */
 export function map(
   key: FromKeyParam,
   mandatoryModifiers?: FromModifierParam | '' | null,
   optionalModifiers?: FromModifierParam,
 ): BasicManipulatorBuilder
+/** Start a manipulator with a from.key_code and optional modifiers */
+export function map(
+  key: FromKeyParam,
+  modifiers: 'optionalAny' | { optional: FromModifierParam },
+): BasicManipulatorBuilder
 export function map(
   keyOrEvent: FromKeyParam | FromEvent,
-  mandatoryModifiers?: FromModifierParam | '' | null,
+  mandatoryModifiers?: FromModifierOverloadParam,
   optionalModifiers?: FromModifierParam,
 ) {
   if (typeof keyOrEvent === 'object')
     return new BasicManipulatorBuilder(keyOrEvent)
-
   return new BasicManipulatorBuilder({
-    key_code: getKeyWithAlias(keyOrEvent) as FromKeyCode,
-    modifiers: parseFromModifierParams(mandatoryModifiers, optionalModifiers),
+    key_code: getKeyWithAlias<FromKeyCode>(keyOrEvent),
+    modifiers: parseModifierOverload(mandatoryModifiers, optionalModifiers),
   })
 }
 
+/** Start a manipulator with from.simultaneous */
 export function mapSimultaneous(
-  keys: FromKeyParam[],
+  keys: Array<FromKeyParam | FromKeyType>,
   options?: SimultaneousOptions,
   threshold?: number,
 ) {
   const manipulatorBuilder = new BasicManipulatorBuilder({
-    simultaneous: keys.map((v) => ({ key_code: getKeyWithAlias(v) })),
+    simultaneous: keys.map((v) =>
+      typeof v === 'object' ? v : { key_code: getKeyWithAlias<FromKeyCode>(v) },
+    ),
     simultaneous_options: options,
   })
   return threshold
@@ -44,29 +57,57 @@ export function mapSimultaneous(
     : manipulatorBuilder
 }
 
+/** Start a manipulator with from.consumer_key_code and modifiers */
 export function mapConsumerKey(
   code: FromConsumerKeyCode,
-  mandatoryModifiers?: FromModifierParam,
+  mandatoryModifiers?: FromModifierOverloadParam,
   optionalModifiers?: FromModifierParam,
 ) {
   return new BasicManipulatorBuilder({
     consumer_key_code: code,
-    modifiers: parseFromModifierParams(mandatoryModifiers, optionalModifiers),
+    modifiers: parseModifierOverload(mandatoryModifiers, optionalModifiers),
   })
 }
 
 /**
+ * Start a manipulator with from.pointing_button and modifiers
+ *
  * **Caution**
  * Be careful using "pointing_button": "button1" and "any": "pointing_button".
  * You may lose the left click button and system will be unusable.
  */
 export function mapPointingButton(
   button: PointingButton,
-  mandatoryModifiers?: FromModifierParam,
+  mandatoryModifiers?: FromModifierOverloadParam,
   optionalModifiers?: FromModifierParam,
 ) {
   return new BasicManipulatorBuilder({
     pointing_button: button,
-    modifiers: parseFromModifierParams(mandatoryModifiers, optionalModifiers),
+    modifiers: parseModifierOverload(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/config/layer.ts b/src/config/layer.ts
index 2cdb8ad..fcb1e4b 100644
--- a/src/config/layer.ts
+++ b/src/config/layer.ts
@@ -52,7 +52,7 @@ export class LayerRuleBuilder extends BasicRuleBuilder {
     protected readonly offValue: ToVariable['value'] = 0,
   ) {
     super(`Layer - ${varName}`)
-    this.keys = toArray(key).map((v) => getKeyWithAlias(v) as LayerKeyCode)
+    this.keys = toArray(key).map((v) => getKeyWithAlias<LayerKeyCode>(v))
     this.condition(this.layerCondition)
     this.allowEmptyManipulators = true
   }
diff --git a/src/config/manipulator.ts b/src/config/manipulator.ts
index ba4094e..2f80f9c 100644
--- a/src/config/manipulator.ts
+++ b/src/config/manipulator.ts
@@ -52,7 +52,14 @@ export class BasicManipulatorBuilder implements ManipulatorBuilder {
     this.manipulator = { type: 'basic', from }
   }
 
+  /** The FromEvent of the Manipulator. */
+  get from(): FromEvent {
+    return this.manipulator.from
+  }
+  /** Set or add ToEvent(s) to the Manipulator. */
+
   to(event: ToEvent | ToEvent[]): this
+  /** Set or add a ToEvent with a key_code. */
   to(key: ToKeyParam, modifiers?: ModifierParam, options?: ToEventOptions): this
   to(
     keyOrEvent: ToEvent | ToKeyParam | ToEvent[],
diff --git a/src/config/simlayer.ts b/src/config/simlayer.ts
index f572569..302e238 100644
--- a/src/config/simlayer.ts
+++ b/src/config/simlayer.ts
@@ -36,14 +36,14 @@ export class SimlayerRuleBuilder extends BasicRuleBuilder {
     protected readonly offValue: ToVariable['value'] = 0,
   ) {
     super(`Simlayer - ${varName}`)
-    this.keys = toArray(key).map((v) => getKeyWithAlias(v) as LayerKeyCode)
+    this.keys = toArray(key).map((v) => getKeyWithAlias<LayerKeyCode>(v))
     this.condition(this.layerCondition)
   }
 
   /** Enable layer with the same variable and manipulators with this simlayer */
   public enableLayer(...key: LayerKeyParam[]): this {
     key
-      .map((v) => getKeyWithAlias(v) as LayerKeyCode)
+      .map((v) => getKeyWithAlias<LayerKeyCode>(v))
       .forEach((v) => {
         if (this.keys.includes(v))
           throw new Error(`Key ${v} is already used in ${this.ruleDescription}`)
diff --git a/src/config/to.ts b/src/config/to.ts
index ce2dc0d..2d3d1b0 100644
--- a/src/config/to.ts
+++ b/src/config/to.ts
@@ -22,7 +22,7 @@ export function toKey(
 ): ToEvent {
   return {
     ...options,
-    key_code: getKeyWithAlias(key) as ToKeyCode,
+    key_code: getKeyWithAlias<ToKeyCode>(key),
     modifiers: modifiers ? parseModifierParam(modifiers) : undefined,
   }
 }
diff --git a/src/karabiner/karabiner-config.ts b/src/karabiner/karabiner-config.ts
index 2d3bb58..78854d1 100644
--- a/src/karabiner/karabiner-config.ts
+++ b/src/karabiner/karabiner-config.ts
@@ -4,6 +4,8 @@ import {
   StickyModifierKeyCode,
   ToKeyCode,
 } from './key-code'
+import { ConsumerKeyCode } from './consumer-key-code'
+import { PointingButton } from './pointing-button'
 
 export type Modifier =
   | ModifierKeyCode
@@ -20,15 +22,18 @@ export type SimultaneousOptions = {
   to_after_key_up?: ToEvent[]
 }
 
-/** @see https://karabiner-elements.pqrs.org/docs/json/complex-modifications-manipulator-definition/from/ */
-export type FromEvent = (
+export type FromKeyType =
   | { key_code: FromKeyCode | number }
-  | { consumer_key_code: string | number }
-  | { pointing_button: string | number }
+  | { consumer_key_code: ConsumerKeyCode | number }
+  | { pointing_button: PointingButton | number }
   | { any: 'key_code' | 'consumer_key_code' | 'pointing_button' }
+
+/** @see https://karabiner-elements.pqrs.org/docs/json/complex-modifications-manipulator-definition/from/ */
+export type FromEvent = (
+  | FromKeyType
   | {
       /** @see https://karabiner-elements.pqrs.org/docs/json/complex-modifications-manipulator-definition/from/simultaneous/ */
-      simultaneous: Array<{ key_code: string }>
+      simultaneous: FromKeyType[]
       /** @see https://karabiner-elements.pqrs.org/docs/json/complex-modifications-manipulator-definition/from/simultaneous-options/ */
       simultaneous_options?: SimultaneousOptions
     }
@@ -57,7 +62,7 @@ export type FromAnyKeyEvent = Extract<
 >
 export type FromSimultaneousEvent = Extract<
   FromEvent,
-  { simultaneous: Array<{ key_code: string }> }
+  { simultaneous: FromKeyType[] }
 >
 
 /** @see https://karabiner-elements.pqrs.org/docs/json/complex-modifications-manipulator-definition/to/select-input-source/ */
diff --git a/src/utils/key-alias.ts b/src/utils/key-alias.ts
index 1babe3b..4a64f51 100644
--- a/src/utils/key-alias.ts
+++ b/src/utils/key-alias.ts
@@ -57,8 +57,8 @@ const keyAliases: Record<string, string> = {
   '⇪': modifierKeyAliases['⇪'],
 } /* c8 ignore next */ satisfies Record<KeyAlias, KeyCode>
 
-export function getKeyWithAlias(
+export function getKeyWithAlias<T extends KeyCode = KeyCode>(
   key: KeyCode | KeyAlias | NumberKeyValue,
-): KeyCode {
-  return (keyAliases[key] || '' + key) as KeyCode
+): T {
+  return (keyAliases[key] || '' + key) as T
 }
diff --git a/src/utils/with-modifier.ts b/src/utils/with-modifier.ts
index 142a82e..36c1e45 100644
--- a/src/utils/with-modifier.ts
+++ b/src/utils/with-modifier.ts
@@ -1,4 +1,4 @@
-import { Manipulator, Modifier } from '../karabiner/karabiner-config'
+import { FromEvent, Manipulator, Modifier } from '../karabiner/karabiner-config'
 import { FromModifierParam, parseFromModifierParams } from '../config/modifier'
 import { buildManipulators, ManipulatorBuilder } from '../config/manipulator'
 import { BuildContext } from './build-context'
@@ -15,15 +15,48 @@ import { BuildContext } from './build-context'
 export function withModifier(
   mandatoryModifiers: FromModifierParam,
   optionalModifiers?: FromModifierParam,
+): (manipulators: Array<Manipulator | ManipulatorBuilder>) => ManipulatorBuilder
+/**
+ * A high-order function to add optional modifiers to a group of manipulators
+ *
+ * @example
+ *   withModifier('optionalAny')([
+ *     map(1).to(2), // 1+{optional:['any']} => 2+{}
+ *     map(3).to(4)  // 3+{optional:['any']} => 4+{}
+ *   ])
+ */
+export function withModifier(
+  modifiers: 'optionalAny' | { optional: FromModifierParam },
+): (manipulators: Array<Manipulator | ManipulatorBuilder>) => ManipulatorBuilder
+export function withModifier(
+  mandatoryModifiers:
+    | FromModifierParam
+    | 'optionalAny'
+    | { optional: FromModifierParam },
+  optionalModifiers?: FromModifierParam,
 ): (
   manipulators: Array<Manipulator | ManipulatorBuilder>,
 ) => ManipulatorBuilder {
   return (manipulators) => ({
     build: (context?: BuildContext) => {
-      const sharedModifiers = parseFromModifierParams(
-        mandatoryModifiers,
-        optionalModifiers,
-      )!
+      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,
+        )!
+      }
+
       return manipulators
         .map((v) => buildManipulators(v, context))
         .reduce((result, manipulator) => result.concat(manipulator), [])


Test patch:
diff --git a/src/config/from.test.ts b/src/config/from.test.ts
index d3295c1..a3bbc6a 100644
--- a/src/config/from.test.ts
+++ b/src/config/from.test.ts
@@ -13,11 +13,26 @@ test('map()', () => {
     key_code: '1',
     modifiers: { optional: ['command'] },
   })
+
+  expect(map(1, 'optionalAny').to(2).build()[0].from).toEqual({
+    key_code: '1',
+    modifiers: { optional: ['any'] },
+  })
+
+  expect(map(1, { optional: 'any' }).to(2).build()[0].from).toEqual({
+    key_code: '1',
+    modifiers: { optional: ['any'] },
+  })
+
+  expect(map(1, { optional: '⌘⌥' }).to(2).build()[0].from).toEqual({
+    key_code: '1',
+    modifiers: { optional: ['command', 'option'] },
+  })
 })
 
 test('mapSimultaneous()', () => {
   const { from, parameters } = mapSimultaneous(
-    ['a', '←', 2],
+    ['a', '←', 2, { any: 'pointing_button' }],
     { key_up_order: 'strict' },
     123,
   ).build()[0]
@@ -26,6 +41,7 @@ test('mapSimultaneous()', () => {
       { key_code: 'a' },
       { key_code: 'left_arrow' },
       { key_code: '2' },
+      { any: 'pointing_button' },
     ],
     simultaneous_options: { key_up_order: 'strict' },
   })
diff --git a/src/config/manipulator.test.ts b/src/config/manipulator.test.ts
index 766e2be..5ffec0a 100644
--- a/src/config/manipulator.test.ts
+++ b/src/config/manipulator.test.ts
@@ -8,10 +8,9 @@ describe('ManipulatorBuilder', () => {
   const toEvent: ToEvent = { key_code: 'b', modifiers: ['command'], lazy: true }
 
   test('type and from', () => {
-    expect(new BasicManipulatorBuilder(from).build()[0]).toEqual({
-      type: 'basic',
-      from,
-    })
+    const builder = new BasicManipulatorBuilder(from)
+    expect(builder.from).toEqual(from)
+    expect(builder.build()[0]).toEqual({ type: 'basic', from })
   })
 
   test('to()', () => {
diff --git a/src/utils/with-modifier.test.ts b/src/utils/with-modifier.test.ts
index 16b0cc7..19fade6 100644
--- a/src/utils/with-modifier.test.ts
+++ b/src/utils/with-modifier.test.ts
@@ -34,6 +34,27 @@ describe('withModifier()', () => {
         to: [{ key_code: 'a' }],
       },
     ])
+
+    expect(withModifier([], 'any')([map(1).to('a')]).build()[0].from).toEqual({
+      key_code: '1',
+      modifiers: { optional: ['any'] },
+    })
+    expect(
+      withModifier('optionalAny')([map(1).to('a')]).build()[0].from,
+    ).toEqual({ key_code: '1', modifiers: { optional: ['any'] } })
+    expect(
+      withModifier({ optional: 'any' })([map(1).to('a')]).build()[0].from,
+    ).toEqual({ key_code: '1', modifiers: { optional: ['any'] } })
+    expect(
+      withModifier({ optional: '⌘⌥' })([map(1, '⌘', '⌃').to('a')]).build()[0]
+        .from,
+    ).toEqual({
+      key_code: '1',
+      modifiers: {
+        mandatory: ['command'],
+        optional: ['control', 'command', 'option'],
+      },
+    })
   })
 
   test('Remove duplicated modifier', () => {