Repository: evan-liu/karabiner.ts
Pull request #82 (2023-05-14 04:56:25)

✨ Add duoLayer()


Patch:
diff --git a/src/config/complex-modifications.ts b/src/config/complex-modifications.ts
index 224c035..d15e181 100644
--- a/src/config/complex-modifications.ts
+++ b/src/config/complex-modifications.ts
@@ -7,6 +7,7 @@ import { buildRule, RuleBuilder } from './rule'
 import { defaultDoubleTapParameters } from './double-tap'
 import { defaultSimlayerParameters } from './simlayer'
 import { BuildContext } from '../utils/build-context'
+import { defaultDuoLayerParameters } from './duo-layer'
 
 export const defaultComplexModificationsParameters: ComplexModificationsParameters =
   {
@@ -19,7 +20,8 @@ export const defaultComplexModificationsParameters: ComplexModificationsParamete
 
 export type ModificationParameters = ComplexModificationsParameters &
   Partial<typeof defaultDoubleTapParameters> &
-  Partial<typeof defaultSimlayerParameters>
+  Partial<typeof defaultSimlayerParameters> &
+  Partial<typeof defaultDuoLayerParameters>
 
 export function complexModifications(
   rules: Array<Rule | RuleBuilder>,
@@ -39,8 +41,9 @@ export function complexModifications(
     'simlayer.threshold_milliseconds': simlayerThreshold,
   })
 
+  const builtRules = rules.map((v) => buildRule(v, context))
   const modifications: ComplexModifications = {
-    rules: rules.map((v) => buildRule(v, context)),
+    rules: builtRules.filter((v) => v.manipulators.length),
     parameters: {
       ...defaultComplexModificationsParameters,
       ...complexModificationsParameters,
@@ -51,10 +54,13 @@ export function complexModifications(
     throw new Error(`complex_modifications "rules" is empty `)
   }
 
-  for (const rule of modifications.rules) {
-    if (!rule.manipulators.length) {
-      throw new Error(`"manipulators" is empty in "${rule.description}"`)
-    }
+  if (modifications.rules.length < builtRules.length) {
+    console.warn(`Rules with empty manipulators are ignored: 
+${builtRules
+  .filter((v) => v.manipulators.length === 0)
+  .map((v) => '- ' + v.description)
+  .join('\n')}
+`)
   }
 
   return modifications
diff --git a/src/config/duo-layer.ts b/src/config/duo-layer.ts
new file mode 100644
index 0000000..38123ec
--- /dev/null
+++ b/src/config/duo-layer.ts
@@ -0,0 +1,125 @@
+import { LayerKeyParam } from './layer'
+import {
+  BasicManipulator,
+  FromSimultaneousEvent,
+  Rule,
+  SimultaneousOptions,
+  ToVariable,
+} from '../karabiner/karabiner-config'
+import { BasicRuleBuilder } from './rule'
+import { buildCondition, ConditionBuilder, ifVar } from './condition'
+import { BuildContext } from '../utils/build-context'
+import { mapSimultaneous } from './simultaneous'
+import { toSetVar } from './to'
+
+export const defaultDuoLayerParameters = {
+  'duo_layer.threshold_milliseconds': 200,
+}
+
+export function duoLayer(
+  key1: LayerKeyParam,
+  key2: LayerKeyParam,
+  varName?: string,
+  onValue: ToVariable['value'] = 1,
+  offValue: ToVariable['value'] = 0,
+) {
+  return new DuoLayerRuleBuilder(key1, key2, varName, onValue, offValue)
+}
+
+export class DuoLayerRuleBuilder extends BasicRuleBuilder {
+  private readonly varName: string
+  private readonly layerCondition: ConditionBuilder
+  private readonly simultaneousOptions: SimultaneousOptions = {}
+  private simultaneousThreshold?: number
+
+  constructor(
+    private readonly key1: LayerKeyParam,
+    private readonly key2: LayerKeyParam,
+    varName?: string,
+    private readonly onValue: ToVariable['value'] = 1,
+    private readonly offValue: ToVariable['value'] = 0,
+  ) {
+    if (!varName) {
+      varName = `duo-layer-${key1}-${key2}`
+    }
+
+    super(`Layer - ${varName}`)
+    this.varName = varName
+    this.layerCondition = ifVar(this.varName, this.onValue)
+    this.condition(this.layerCondition)
+    this.allowEmptyManipulators = true
+  }
+
+  public threshold(v: number): this {
+    this.simultaneousThreshold = v
+    return this
+  }
+
+  public options(v: SimultaneousOptions): this {
+    Object.assign(this.simultaneousOptions, v)
+    return this
+  }
+
+  public build(context?: BuildContext): Rule {
+    const rule = super.build(context)
+
+    const params =
+      context?.getParameters(defaultDuoLayerParameters) ??
+      defaultDuoLayerParameters
+    const threshold =
+      this.simultaneousThreshold || params['duo_layer.threshold_milliseconds']
+
+    const conditions = this.conditions
+      .filter((v) => v !== this.layerCondition)
+      .map(buildCondition)
+
+    // Layer toggle
+    const manipulator = mapSimultaneous(
+      [this.key1, this.key2],
+      {
+        ...this.simultaneousOptions,
+        to_after_key_up: [
+          ...(this.simultaneousOptions.to_after_key_up || []),
+          toSetVar(this.varName, this.offValue),
+        ],
+      },
+      threshold,
+    )
+      .modifiers('??')
+      .to(toSetVar(this.varName, this.onValue))
+    if (conditions.length) {
+      manipulator.condition(...conditions)
+    }
+
+    // Build context
+    if (!context) {
+      rule.manipulators = [...manipulator.build(), ...rule.manipulators]
+      return rule
+    }
+
+    // Add variables to the existing manipulator
+    const key = [
+      `duo_layer_${this.key1}_${this.key2}`,
+      ...conditions.map((v) => JSON.stringify(v)).sort(),
+    ].join('_')
+    const exiting = context.getCache<BasicManipulator>(key)
+    if (exiting) {
+      const sameVar = exiting.to?.find(
+        (v) => 'set_variable' in v && v.set_variable.name === this.varName,
+      )
+      if (!sameVar) {
+        exiting.to?.push(toSetVar(this.varName, this.onValue))
+        const from = exiting.from as FromSimultaneousEvent
+        from.simultaneous_options?.to_after_key_up?.push(
+          toSetVar(this.varName, this.offValue),
+        )
+      }
+    } else {
+      const result = manipulator.build(context)[0]
+      context.setCache(key, result)
+      rule.manipulators = [result, ...rule.manipulators]
+    }
+
+    return rule
+  }
+}
diff --git a/src/config/layer.ts b/src/config/layer.ts
index f0af0f2..717cbbb 100644
--- a/src/config/layer.ts
+++ b/src/config/layer.ts
@@ -79,19 +79,19 @@ export function hyperLayer(
 }
 
 export class LayerRuleBuilder extends BasicRuleBuilder {
-  protected readonly keys: LayerKeyCode[]
-  protected varName: string
-  protected layerCondition: ConditionBuilder
-  protected layerKeyManipulator?: BasicManipulatorBuilder
-  protected replaceLayerKeyToIfAlone = false
+  private readonly keys: LayerKeyCode[]
+  private readonly varName: string
+  private readonly layerCondition: ConditionBuilder
+  private layerKeyManipulator?: BasicManipulatorBuilder
+  private replaceLayerKeyToIfAlone = false
 
-  protected layerModifiers?: FromEvent['modifiers']
+  private layerModifiers?: FromEvent['modifiers']
 
   constructor(
     key: LayerKeyParam | LayerKeyParam[],
     varName?: string,
-    protected readonly onValue: ToVariable['value'] = 1,
-    protected readonly offValue: ToVariable['value'] = 0,
+    private readonly onValue: ToVariable['value'] = 1,
+    private readonly offValue: ToVariable['value'] = 0,
   ) {
     const keys = toArray(key).map((v) =>
       getKeyWithAlias<LayerKeyCode>(v, excludeFromLayerKeys, 'as layer key'),
diff --git a/src/config/simlayer.ts b/src/config/simlayer.ts
index fab0dda..689a82c 100644
--- a/src/config/simlayer.ts
+++ b/src/config/simlayer.ts
@@ -40,24 +40,24 @@ export function simlayer(
 }
 
 export class SimlayerRuleBuilder extends BasicRuleBuilder {
-  protected readonly keys: LayerKeyCode[]
-  protected varName: string
-  protected readonly layerCondition: ConditionBuilder
-  protected readonly sharedLayerKeys: LayerKeyCode[] = []
-  protected readonly simultaneousOptions: SimultaneousOptions = {
+  private readonly keys: LayerKeyCode[]
+  private readonly varName: string
+  private readonly layerCondition: ConditionBuilder
+  private readonly sharedLayerKeys: LayerKeyCode[] = []
+  private readonly simultaneousOptions: SimultaneousOptions = {
     detect_key_down_uninterruptedly: true,
     key_down_order: 'strict',
     key_up_order: 'strict_inverse',
     key_up_when: 'any',
   }
-  protected layerModifiers: FromEvent['modifiers'] = { optional: ['any'] }
+  private layerModifiers: FromEvent['modifiers'] = { optional: ['any'] }
 
   constructor(
     key: LayerKeyParam | LayerKeyParam[],
     varName?: string,
-    protected readonly threshold?: number,
-    protected readonly onValue: ToVariable['value'] = 1,
-    protected readonly offValue: ToVariable['value'] = 0,
+    private readonly threshold?: number,
+    private readonly onValue: ToVariable['value'] = 1,
+    private readonly offValue: ToVariable['value'] = 0,
   ) {
     const keys = toArray(key).map((v) =>
       getKeyWithAlias<LayerKeyCode>(v, excludeFromLayerKeys, 'as simlayer key'),
diff --git a/src/index.ts b/src/index.ts
index 1fec815..9a9ef95 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -44,8 +44,9 @@ export * from './config/condition'
 // Rules
 export { rule } from './config/rule'
 export type { LayerKeyCode, LayerKeyParam } from './config/layer'
-export { layer } from './config/layer'
+export { layer, hyperLayer, modifierLayer } from './config/layer'
 export { simlayer } from './config/simlayer'
+export { duoLayer } from './config/duo-layer'
 
 // Output
 export { writeToProfile } from './output'


Test patch:
diff --git a/src/config/complex-modifications.test.ts b/src/config/complex-modifications.test.ts
index 5d44984..4d128ba 100644
--- a/src/config/complex-modifications.test.ts
+++ b/src/config/complex-modifications.test.ts
@@ -45,6 +45,13 @@ describe('complexModifications()', () => {
     expect(() =>
       complexModifications([{ manipulators: [], description: '' }]),
     ).toThrow()
+
+    expect(() =>
+      complexModifications([
+        { manipulators: map(1).to(2).build(), description: '' },
+        { manipulators: [], description: '' },
+      ]),
+    ).not.toThrow()
   })
 
   test('Check key', () => {
diff --git a/src/config/duo-layer.test.ts b/src/config/duo-layer.test.ts
new file mode 100644
index 0000000..cdd9a95
--- /dev/null
+++ b/src/config/duo-layer.test.ts
@@ -0,0 +1,102 @@
+import { expect, test } from 'vitest'
+import { defaultDuoLayerParameters, duoLayer } from './duo-layer'
+import { map } from './from'
+import {
+  BasicManipulator,
+  FromSimultaneousEvent,
+} from '../karabiner/karabiner-config'
+import { toSetVar } from './to'
+import { ifVar } from './condition'
+import { complexModifications } from './complex-modifications'
+
+test('duoLayer()', () => {
+  const layer = duoLayer(1, 2).manipulators([map(3).to(4)])
+  const rule = layer.build()
+  expect(rule.description).toBe('Layer - duo-layer-1-2')
+  expect(layer.description('test').build().description).toBe('test')
+
+  const manipulators = rule.manipulators as BasicManipulator[]
+  // One layer manipulator to set variable
+  expect(manipulators.length).toBe(2)
+  expect(manipulators[0]).toEqual({
+    type: 'basic',
+    from: {
+      simultaneous: [{ key_code: '1' }, { key_code: '2' }],
+      simultaneous_options: {
+        to_after_key_up: [toSetVar('duo-layer-1-2', 0)],
+      },
+      modifiers: { optional: ['any'] },
+    },
+    to: [toSetVar('duo-layer-1-2', 1)],
+    parameters: {
+      'basic.simultaneous_threshold_milliseconds':
+        defaultDuoLayerParameters['duo_layer.threshold_milliseconds'],
+    },
+  })
+  // Add variable condition to manipulators
+  expect(manipulators[1].conditions).toEqual([ifVar('duo-layer-1-2').build()])
+})
+
+test('duoLayer() varName and values', () => {
+  const rule = duoLayer(1, 2, 'var12', 2, -1).build()
+  expect(rule.description).toBe(`Layer - var12`)
+  const manipulator = rule.manipulators[0] as BasicManipulator
+  expect(manipulator.to).toEqual([
+    { set_variable: { name: 'var12', value: 2 } },
+  ])
+  expect(
+    (manipulator.from as FromSimultaneousEvent).simultaneous_options
+      ?.to_after_key_up,
+  ).toEqual([{ set_variable: { name: 'var12', value: -1 } }])
+})
+
+test('duoLayer().threshold()', () => {
+  const rule = duoLayer(1, 2).threshold(100).build()
+  const manipulators = rule.manipulators as BasicManipulator[]
+  expect(manipulators[0].parameters).toEqual({
+    'basic.simultaneous_threshold_milliseconds': 100,
+  })
+})
+
+test('duoLayer().options()', () => {
+  const rule = duoLayer(1, 2)
+    .options({ key_up_order: 'strict_inverse' })
+    .build()
+  const manipulators = rule.manipulators as BasicManipulator[]
+  expect(
+    (manipulators[0].from as FromSimultaneousEvent).simultaneous_options
+      ?.key_up_order,
+  ).toEqual('strict_inverse')
+})
+
+test('duoLayer().condition()', () => {
+  const rule = duoLayer(1, 2).condition(ifVar('c')).build()
+  const manipulators = rule.manipulators as BasicManipulator[]
+  expect(manipulators[0].conditions).toEqual([
+    { type: 'variable_if', name: 'c', value: 1 },
+  ])
+})
+
+test('duoLayer() with same keys', () => {
+  const { rules } = complexModifications([
+    duoLayer(1, 2).manipulators([map(3).to(4)]),
+    duoLayer(1, 2).manipulators([map(3).to(4)]),
+    duoLayer(1, 2)
+      .condition(ifVar('x'))
+      .manipulators([map(3).to(4)]),
+    duoLayer(1, 2)
+      .condition(ifVar('x'))
+      .manipulators([map(3).to(4)]),
+    duoLayer(1, 2, 'v3').manipulators([map(3).to(4)]),
+  ])
+  expect(rules.map((v) => v.manipulators.length)).toEqual([2, 1, 2, 1, 1])
+
+  expect(
+    (rules[0].manipulators[0].from as FromSimultaneousEvent)
+      .simultaneous_options?.to_after_key_up,
+  ).toEqual([toSetVar('duo-layer-1-2', 0), toSetVar('v3', 0)])
+  expect(
+    (rules[2].manipulators[0].from as FromSimultaneousEvent)
+      .simultaneous_options?.to_after_key_up,
+  ).toEqual([toSetVar('duo-layer-1-2', 0)])
+})