Repository: evan-liu/karabiner.ts
Pull request #78 (2023-05-12 07:41:37)

✨ Add side modifier aliases
Solves #75 


Patch:
diff --git a/src/config/from.ts b/src/config/from.ts
index 2a604f6..82da106 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 } from './modifier'
+import { FromModifierParam, SideModifierAlias } from './modifier'
 import { BasicManipulatorBuilder } from './manipulator'
 import { FromEvent } from '../karabiner/karabiner-config'
 import { FromConsumerKeyCode } from '../karabiner/consumer-key-code'
@@ -11,7 +11,11 @@ import {
 } from '../utils/from-modifier-overload'
 import { FromOptionalModifierParam } from '../utils/optional-modifiers'
 
-export type FromKeyParam = FromKeyCode | KeyAlias | NumberKeyValue
+export type FromKeyParam =
+  | FromKeyCode
+  | KeyAlias
+  | NumberKeyValue
+  | SideModifierAlias
 
 /** Start a manipulator with a FromEvent */
 export function map(from: FromEvent): BasicManipulatorBuilder
diff --git a/src/config/layer.ts b/src/config/layer.ts
index fcb1e4b..0ecdca2 100644
--- a/src/config/layer.ts
+++ b/src/config/layer.ts
@@ -17,6 +17,7 @@ import { BasicRuleBuilder } from './rule'
 import { toArray } from '../utils/to-array'
 import { BuildContext } from '../utils/build-context'
 import { BasicManipulatorBuilder } from './manipulator'
+import { SideModifierAlias } from './modifier'
 
 export type LayerKeyCode = Exclude<
   FromKeyCode,
@@ -25,7 +26,10 @@ export type LayerKeyCode = Exclude<
 export type LayerKeyParam =
   | Exclude<
       FromKeyParam,
-      FromOnlyKeyCode | StickyModifierKeyCode | ModifierKeyAlias
+      | FromOnlyKeyCode
+      | StickyModifierKeyCode
+      | ModifierKeyAlias
+      | SideModifierAlias
     >
   | '⇪'
 
diff --git a/src/config/modifier.ts b/src/config/modifier.ts
index a5cdce2..4f9f3ae 100644
--- a/src/config/modifier.ts
+++ b/src/config/modifier.ts
@@ -3,6 +3,7 @@ import { ModifierKeyAlias, modifierKeyAliases } from '../utils/key-alias'
 import {
   MultiModifierAlias,
   multiModifierAliases,
+  NamedMultiModifierAlias,
 } from '../utils/multi-modifier'
 
 export type ModifierParam =
@@ -10,28 +11,68 @@ export type ModifierParam =
   | ModifierKeyAlias
   | Array<Modifier | ModifierKeyAlias>
   | MultiModifierAlias
+  | { left?: ModifierParam; right?: ModifierParam }
+  | { l?: ModifierParam; r?: ModifierParam }
+  | SideMultiModifierAlias
+  | SideMultiModifierAlias[]
 
 export function parseModifierParam(
   src?: ModifierParam,
 ): Modifier[] | undefined {
   if (!src) return undefined
 
-  if (typeof src !== 'string') return src.map(getModifierWithAlias)
+  if (typeof src === 'string') {
+    if (isSideMultiModifierAlias(src)) {
+      return parseSideMultiModifierAlias(src)
+    }
+    if (src in modifierKeyAliases)
+      return [modifierKeyAliases[src as ModifierKeyAlias]]
 
-  if (src in modifierKeyAliases)
-    return [modifierKeyAliases[src as ModifierKeyAlias]]
+    if (src in multiModifierAliases) {
+      return multiModifierAliases[src as MultiModifierAlias]
+    }
+    return [src as Modifier]
+  }
+
+  if (Array.isArray(src)) {
+    if (isSideMultiModifierAliases(src)) {
+      return parseSideMultiModifierAliases(src)
+    }
+    return src.map(getModifierWithAlias)
+  }
 
-  if (src in multiModifierAliases) {
-    return multiModifierAliases[src as MultiModifierAlias]
+  let left: Modifier[] | undefined = undefined
+  if ('left' in src) {
+    left = parseSideModifier('left', src.left)
+  } else if ('l' in src) {
+    left = parseSideModifier('left', src.l)
+  }
+  let right: Modifier[] | undefined = undefined
+  if ('right' in src) {
+    right = parseSideModifier('right', src.right)
+  } else if ('r' in src) {
+    right = parseSideModifier('right', src.r)
   }
+  if (!left?.length && !right?.length) return undefined
 
-  return [src as Modifier]
+  return [...(left || []), ...(right || [])]
 }
 
-export type FromModifierParam =
-  | ModifierParam
-  | { left?: ModifierParam; right?: ModifierParam }
-  | 'any'
+export type LeftModifierFlag = 'left' | 'l' | '<' | '‹'
+export type RightModifierFlag = 'right' | 'r' | '>' | '›'
+export type SideModifierFlag = LeftModifierFlag | RightModifierFlag
+export type SideModifierAlias = `${SideModifierFlag}${Exclude<
+  ModifierKeyAlias,
+  '⇪'
+>}`
+export type SideMultiModifierAlias = `${SideModifierFlag}${
+  | Exclude<ModifierKeyAlias, '⇪'>
+  | Exclude<MultiModifierAlias, NamedMultiModifierAlias>}`
+
+export type FromModifierParam = ModifierParam | 'any'
+
+const leftModifierRegExp = /^(left|l|<|‹)([⌘⌥⌃⇧]*)$/
+const rightModifierRegExp = /^(right|r|>|›)([⌘⌥⌃⇧]*)$/
 
 export function parseFromModifierParams(
   mandatoryParam?: FromModifierParam | '' | null,
@@ -49,16 +90,7 @@ function parseFromModifiers(
 ): Modifier[] | ['any'] | undefined {
   if (!param) return undefined
   if (param === 'any') return ['any']
-
-  if (typeof param === 'string' || Array.isArray(param)) {
-    return parseModifierParam(param)
-  }
-
-  const left = parseSideModifier('left', param.left)
-  const right = parseSideModifier('right', param.right)
-  if (!left?.length && !right?.length) return undefined
-
-  return [...(left || []), ...(right || [])]
+  return parseModifierParam(param)
 }
 
 const sidedModifiers = new Set<string>([
@@ -68,6 +100,45 @@ const sidedModifiers = new Set<string>([
   'shift',
 ] /* c8 ignore next */ satisfies Modifier[])
 
+export function isSideMultiModifierAlias(
+  src: string,
+): src is SideMultiModifierAlias {
+  return leftModifierRegExp.test(src) || rightModifierRegExp.test(src)
+}
+
+export function parseSideMultiModifierAlias(
+  src: SideMultiModifierAlias,
+): Modifier[] | undefined {
+  const leftMatched = src.match(leftModifierRegExp)
+  if (leftMatched) {
+    return parseSideModifier('left', leftMatched[2] as ModifierParam)
+  }
+  const rightMatched = src.match(rightModifierRegExp)
+  if (rightMatched) {
+    return parseSideModifier('right', rightMatched[2] as ModifierParam)
+  }
+}
+
+function isSideMultiModifierAliases(
+  src: string[],
+): src is SideMultiModifierAlias[] {
+  return src.some(isSideMultiModifierAlias)
+}
+
+function parseSideMultiModifierAliases(
+  src: SideMultiModifierAlias[],
+): Modifier[] | undefined {
+  return src.reduce(
+    (r, v) => [
+      ...r,
+      ...(isSideMultiModifierAlias(v)
+        ? parseSideMultiModifierAlias(v) || []
+        : parseModifierParam(v) || []),
+    ],
+    [] as Modifier[],
+  )
+}
+
 function parseSideModifier(
   side: 'left' | 'right',
   src?: ModifierParam,
diff --git a/src/config/mouse-motion-to-scroll.ts b/src/config/mouse-motion-to-scroll.ts
index a146b07..4c4b98a 100644
--- a/src/config/mouse-motion-to-scroll.ts
+++ b/src/config/mouse-motion-to-scroll.ts
@@ -5,9 +5,14 @@ import {
   MouseMotionToScrollManipulator,
   MouseMotionToScrollOptions,
 } from '../karabiner/karabiner-config'
-import { FromModifierParam, parseFromModifierParams } from './modifier'
+import { FromModifierParam } from './modifier'
 import { buildCondition, ConditionBuilder } from './condition'
 import { BuildContext } from '../utils/build-context'
+import {
+  FromModifierOverloadParam,
+  parseFromModifierOverload,
+} from '../utils/from-modifier-overload'
+import { FromOptionalModifierParam } from '../utils/optional-modifiers'
 
 export function mouseMotionToScroll() {
   return new MouseMotionToScrollManipulatorBuilder()
@@ -20,12 +25,20 @@ export class MouseMotionToScrollManipulatorBuilder
     type: 'mouse_motion_to_scroll',
   }
 
-  modifiers(
-    mandatoryModifiers?: FromModifierParam,
+  public modifiers(
+    mandatoryModifiers?: FromModifierOverloadParam,
+    optionalModifiers?: FromModifierParam,
+  ): this
+  public modifiers(modifiers: FromOptionalModifierParam): this
+  public modifiers(
+    mandatoryModifiers?: FromModifierOverloadParam,
     optionalModifiers?: FromModifierParam,
   ): this {
     this.manipulator.from = {
-      modifiers: parseFromModifierParams(mandatoryModifiers, optionalModifiers),
+      modifiers: parseFromModifierOverload(
+        mandatoryModifiers,
+        optionalModifiers,
+      ),
     }
     return this
   }
diff --git a/src/config/to.ts b/src/config/to.ts
index 2d3d1b0..9658f9d 100644
--- a/src/config/to.ts
+++ b/src/config/to.ts
@@ -1,4 +1,8 @@
-import { ModifierParam, parseModifierParam } from './modifier'
+import {
+  ModifierParam,
+  parseModifierParam,
+  SideModifierAlias,
+} from './modifier'
 import {
   ToEvent,
   ToEventOptions,
@@ -12,7 +16,11 @@ import { StickyModifierKeyCode, ToKeyCode } from '../karabiner/key-code'
 import { ToConsumerKeyCode } from '../karabiner/consumer-key-code'
 import { PointingButton } from '../karabiner/pointing-button'
 
-export type ToKeyParam = ToKeyCode | KeyAlias | NumberKeyValue
+export type ToKeyParam =
+  | ToKeyCode
+  | KeyAlias
+  | NumberKeyValue
+  | SideModifierAlias
 
 /** Create ToEvent with key_code */
 export function toKey(
diff --git a/src/index.ts b/src/index.ts
index 9fb116a..1fec815 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,7 +3,15 @@ export type * from './karabiner/key-code'
 export type * from './karabiner/karabiner-config'
 
 // Types
-export type { ModifierParam, FromModifierParam } from './config/modifier'
+export type {
+  LeftModifierFlag,
+  RightModifierFlag,
+  SideModifierFlag,
+  SideModifierAlias,
+  SideMultiModifierAlias,
+  ModifierParam,
+  FromModifierParam,
+} from './config/modifier'
 export type { ModificationParameters } from './config/complex-modifications'
 export type { MultiModifierAlias } from './utils/multi-modifier'
 export type {
diff --git a/src/utils/key-alias.ts b/src/utils/key-alias.ts
index 4a64f51..fe9d569 100644
--- a/src/utils/key-alias.ts
+++ b/src/utils/key-alias.ts
@@ -4,6 +4,11 @@ import {
   KeyCode,
 } from '../karabiner/key-code'
 import { Modifier } from '../karabiner/karabiner-config'
+import {
+  isSideMultiModifierAlias,
+  parseSideMultiModifierAlias,
+  SideModifierAlias,
+} from '../config/modifier'
 
 export const modifierKeyAliases = {
   '⌘': 'command',
@@ -58,7 +63,18 @@ const keyAliases: Record<string, string> = {
 } /* c8 ignore next */ satisfies Record<KeyAlias, KeyCode>
 
 export function getKeyWithAlias<T extends KeyCode = KeyCode>(
-  key: KeyCode | KeyAlias | NumberKeyValue,
+  key: KeyCode | KeyAlias | NumberKeyValue | SideModifierAlias,
 ): T {
-  return (keyAliases[key] || '' + key) as T
+  if (typeof key === 'number') return `${key}` as T
+
+  if (key.length > 1 && isSideMultiModifierAlias(key)) {
+    const modifiers = parseSideMultiModifierAlias(key)
+    if (modifiers?.length === 1) {
+      return modifiers[0] as T
+    } else {
+      throw new Error(`Invalid key ${key}`)
+    }
+  }
+
+  return (keyAliases[key] || key) as T
 }
diff --git a/src/utils/multi-modifier.ts b/src/utils/multi-modifier.ts
index b066607..84f385d 100644
--- a/src/utils/multi-modifier.ts
+++ b/src/utils/multi-modifier.ts
@@ -1,5 +1,12 @@
 import { Modifier } from '../karabiner/karabiner-config'
 
+export const namedMultiModifierAliases = {
+  Meh: ['option', 'control', 'shift'],
+  Hyper: ['command', 'option', 'control', 'shift'],
+  SuperHyper: ['command', 'option', 'control', 'shift', 'fn'],
+} /* c8 ignore next */ satisfies Record<string, Modifier[]>
+export type NamedMultiModifierAlias = keyof typeof namedMultiModifierAliases
+
 export const multiModifierAliases = {
   '⌘⇧': ['command', 'shift'],
   '⌥⇧': ['option', 'shift'],
@@ -12,9 +19,7 @@ export const multiModifierAliases = {
   '⌘⌃⇧': ['command', 'control', 'shift'],
   '⌥⌃⇧': ['option', 'control', 'shift'],
   '⌘⌥⌃⇧': ['command', 'option', 'control', 'shift'],
-  Meh: ['option', 'control', 'shift'],
-  Hyper: ['command', 'option', 'control', 'shift'],
-  SuperHyper: ['command', 'option', 'control', 'shift', 'fn'],
+  ...namedMultiModifierAliases,
 } /* c8 ignore next */ satisfies Record<string, Modifier[]>
 
 export type MultiModifierAlias = keyof typeof multiModifierAliases
diff --git a/src/utils/optional-modifiers.ts b/src/utils/optional-modifiers.ts
index 7d7c5a8..3f34297 100644
--- a/src/utils/optional-modifiers.ts
+++ b/src/utils/optional-modifiers.ts
@@ -1,11 +1,14 @@
-import { FromModifierParam } from '../config/modifier'
-import { ModifierKeyAlias, modifierKeyAliases } from './key-alias'
-import { MultiModifierAlias, multiModifierAliases } from './multi-modifier'
+import { FromModifierParam, SideMultiModifierAlias } from '../config/modifier'
+import { ModifierKeyAlias } from './key-alias'
+import { MultiModifierAlias, NamedMultiModifierAlias } from './multi-modifier'
 
 const optionalAnyAliases = ['optionalAny', '?any', '??'] as const
 export type OptionalAnyAlias = (typeof optionalAnyAliases)[number]
 
-export type OptionalModifierAlias = `?${ModifierKeyAlias | MultiModifierAlias}`
+export type OptionalModifierAlias = `?${
+  | ModifierKeyAlias
+  | Exclude<MultiModifierAlias, NamedMultiModifierAlias>
+  | SideMultiModifierAlias}`
 
 export function isOptionalAnyAlias(src: any): src is OptionalAnyAlias {
   if (!src || typeof src !== 'string') return false
@@ -17,13 +20,8 @@ export type FromOptionalModifierParam =
   | { optional: FromModifierParam }
   | OptionalModifierAlias
 
-const optionalModifierAliases = [
-  ...Object.keys(modifierKeyAliases),
-  ...Object.keys(multiModifierAliases),
-].map((v) => `?${v}`)
-
 export function isOptionalModifierAlias(
   src: string,
 ): src is OptionalModifierAlias {
-  return optionalModifierAliases.includes(src)
+  return /^\?(left|l|<|‹|right|r|>|›)?([⌘⌥⌃⇧⇪]*)$/.test(src)
 }
diff --git a/src/utils/with-mapper.ts b/src/utils/with-mapper.ts
index 57313f9..621e029 100644
--- a/src/utils/with-mapper.ts
+++ b/src/utils/with-mapper.ts
@@ -1,4 +1,3 @@
-import { FromKeyParam } from '../config/from'
 import { buildManipulators, ManipulatorBuilder } from '../config/manipulator'
 import { Manipulator } from '../karabiner/karabiner-config'
 
@@ -33,13 +32,13 @@ export function withMapper<const K extends string | number, const V>(
 export function withMapper(
   src: any[] | Partial<Record<any, any>>,
 ): (
-  mapper: (key: FromKeyParam, value?: any) => Manipulator | ManipulatorBuilder,
+  mapper: (key: any, value?: any) => Manipulator | ManipulatorBuilder,
 ) => ManipulatorBuilder {
   return (mapper) => ({
     build: () => {
-      const entries = (
-        Array.isArray(src) ? src.map((v, i) => [v, i]) : Object.entries(src)
-      ) as Array<[FromKeyParam, any]>
+      const entries = Array.isArray(src)
+        ? src.map((v, i) => [v, i])
+        : Object.entries(src)
       return entries.reduce(
         (r, [k, v]) => [...r, ...buildManipulators(mapper(k, v))],
         [] as Manipulator[],


Test patch:
diff --git a/src/config/double-tap.test.ts b/src/config/double-tap.test.ts
index a58b1e8..d2793ef 100644
--- a/src/config/double-tap.test.ts
+++ b/src/config/double-tap.test.ts
@@ -97,6 +97,14 @@ describe('mapDoubleTap()', () => {
       key_code: '1',
       modifiers: { optional: ['any'] },
     })
+    expect(mapDoubleTap(1, '‹⌘', '›⌥').to(2).build()[1].from).toEqual({
+      key_code: '1',
+      modifiers: { mandatory: ['left_command'], optional: ['right_option'] },
+    })
+    expect(mapDoubleTap(1, '?>⌘').to(2).build()[1].from).toEqual({
+      key_code: '1',
+      modifiers: { optional: ['right_command'] },
+    })
 
     expect(
       [
diff --git a/src/config/from.test.ts b/src/config/from.test.ts
index 1a03b98..3ecd7e4 100644
--- a/src/config/from.test.ts
+++ b/src/config/from.test.ts
@@ -37,6 +37,22 @@ test('map()', () => {
   })
 
   expect(() => map(1, '?⌘⌥x' as any)).toThrow()
+
+  expect(map(1, '?‹⌘⌥').to(2).build()[0].from).toEqual({
+    key_code: '1',
+    modifiers: { optional: ['left_command', 'left_option'] },
+  })
+
+  expect(map(1, '?r⇧').to(2).build()[0].from).toEqual({
+    key_code: '1',
+    modifiers: { optional: ['right_shift'] },
+  })
+
+  expect(map('r⌘').build()[0].from).toEqual({
+    key_code: 'right_command',
+  })
+
+  expect(() => map('‹⌘⌥' as any).build()).toThrow()
 })
 
 test('mapConsumerKey()', () => {
diff --git a/src/config/modifier.test.ts b/src/config/modifier.test.ts
index 5dbf9ae..8676c22 100644
--- a/src/config/modifier.test.ts
+++ b/src/config/modifier.test.ts
@@ -40,4 +40,19 @@ test('parseFromModifierParams()', () => {
   expect(parseFromModifierParams({ left: 'win' as any })).toEqual({
     mandatory: ['win'],
   })
+
+  expect(parseFromModifierParams({ l: '⌘', r: '⌥⌃' })).toEqual({
+    mandatory: ['left_command', 'right_option', 'right_control'],
+  })
+
+  expect(parseFromModifierParams('l⌘', ['‹⌘', '›⌥⌃'])).toEqual({
+    mandatory: ['left_command'],
+    optional: ['left_command', 'right_option', 'right_control'],
+  })
+
+  expect(parseFromModifierParams('l' as any)).toEqual({})
+
+  expect(parseFromModifierParams(['l⌘', '⌥', 'l', ''] as any)).toEqual({
+    mandatory: ['left_command', 'option'],
+  })
 })
diff --git a/src/config/mouse-motion-to-scroll.test.ts b/src/config/mouse-motion-to-scroll.test.ts
index 2f6e957..4b2f14c 100644
--- a/src/config/mouse-motion-to-scroll.test.ts
+++ b/src/config/mouse-motion-to-scroll.test.ts
@@ -22,4 +22,17 @@ test('mouseMotionToScroll()', () => {
       speed_multiplier: 2,
     },
   })
+
+  expect(
+    mouseMotionToScroll().modifiers('›⌘⌥', '⌃').build()[0].from?.modifiers,
+  ).toEqual({
+    mandatory: ['right_command', 'right_option'],
+    optional: ['control'],
+  })
+
+  expect(
+    mouseMotionToScroll().modifiers('?›⌥').build()[0].from?.modifiers,
+  ).toEqual({
+    optional: ['right_option'],
+  })
 })
diff --git a/src/config/simlayer.test.ts b/src/config/simlayer.test.ts
index a9d7b49..deaf33c 100644
--- a/src/config/simlayer.test.ts
+++ b/src/config/simlayer.test.ts
@@ -113,10 +113,10 @@ test('simlayer().enableLayer()', () => {
 test('simlayer().modifiers()', () => {
   expect(
     simlayer('a', 'b')
-      .modifiers('⌘')
+      .modifiers('›⌘')
       .manipulators([map(1).to(2)])
       .build().manipulators[1].from?.modifiers,
-  ).toEqual({ mandatory: ['command'] })
+  ).toEqual({ mandatory: ['right_command'] })
 
   expect(
     simlayer('a', 'b')
@@ -125,6 +125,13 @@ test('simlayer().modifiers()', () => {
       .build().manipulators[1].from?.modifiers,
   ).toEqual({ optional: ['caps_lock'] })
 
+  expect(
+    simlayer('a', 'b')
+      .modifiers('?⇪')
+      .manipulators([map(1).to(2)])
+      .build().manipulators[1].from?.modifiers,
+  ).toEqual({ optional: ['caps_lock'] })
+
   expect(
     simlayer('a', 'b')
       .modifiers(null)
diff --git a/src/config/to.test.ts b/src/config/to.test.ts
index 42edd5d..2ebfae7 100644
--- a/src/config/to.test.ts
+++ b/src/config/to.test.ts
@@ -12,6 +12,21 @@ test('toKey()', () => {
   expect(toKey('/')).toEqual({ key_code: 'slash' })
   // NumberKeyValue
   expect(toKey(1)).toEqual({ key_code: '1' })
+
+  expect(toKey(1).modifiers).toBeUndefined()
+  expect(toKey(1, '⌘⌥').modifiers).toEqual(['command', 'option'])
+  expect(toKey(1, { left: '⌘', right: '⌥' }).modifiers).toEqual([
+    'left_command',
+    'right_option',
+  ])
+  expect(toKey(1, ['‹⌘', '›⌥']).modifiers).toEqual([
+    'left_command',
+    'right_option',
+  ])
+  expect(toKey(1, '›⌘⇧').modifiers).toEqual(['right_command', 'right_shift'])
+
+  expect(toKey('r')).toEqual({ key_code: 'r' })
+  expect(toKey('r⌘')).toEqual({ key_code: 'right_command' })
 })
 
 test('setVar()', () => {