Skip to content

Commit

Permalink
Merge pull request #124 from evan-liu/feat/leader-mode
Browse files Browse the repository at this point in the history
✨ Add layer().leaderMode()
  • Loading branch information
evan-liu authored May 11, 2024
2 parents 5431223 + 05e91e0 commit c38798b
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 16 deletions.
40 changes: 40 additions & 0 deletions docs/docs/rules/leader-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
title: layer().leaderMode()
---

# Leader Mode

`layer()` (or `hyperLayer()` / `modifierLayer()`) has a "leader mode", which works
similar to Vim leader keys: The layer stays activated even after the layer key is
released, until one of the action or escape keys is pressed.

```typescript
hyperLayer('o')
.description('Open App')
.leaderMode()
.notification() // Notification is highly recommanded when use leader mode
.manipulators({
f: toApp('Finder'),
})
```

## Sticky

To keep the layer activated after the first action, and only deactivate the layer
when one of the escape keys is pressed, set the `sticky` option.


```typescript
hyperLayer('o')
.leaderMode({ sticky: true })
```

## Escape

By default `escape` and `caps_lock` keys are used to deactivate the leader mode layer.
To use other keys, set the `escape` option.

```typescript
hyperLayer('o')
.leaderMode({ escape: ['spacebar', 'return_or_enter'] })
```
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const sidebars = {
'rules/simlayer',
'rules/hyper-layer',
'rules/duo-layer',
'rules/leader-mode',
],
},
{
Expand Down
126 changes: 122 additions & 4 deletions src/config/layer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from 'vitest'
import { describe, expect, test } from 'vitest'

import { BasicManipulator } from '../karabiner/karabiner-config'
import {
Expand All @@ -10,10 +10,15 @@ import {
import { complexModifications } from './complex-modifications'
import { ifVar } from './condition'
import { map } from './from'
import { hyperLayer, layer } from './layer'
import { hyperLayer, layer, LayerRuleBuilder } from './layer'
import { mouseMotionToScroll } from './mouse-motion-to-scroll'
import { simlayer } from './simlayer'
import { toKey } from './to'
import {
toKey,
toNotificationMessage,
toRemoveNotificationMessage,
toSetVar,
} from './to'

test('layer()', () => {
const rule = layer('a', 'b-mode', 2, -1)
Expand Down Expand Up @@ -242,6 +247,18 @@ test('layer().modifier()', () => {
).toThrow()
})

test('layer().modifier() to_if_alone', () => {
expect(toIfAlone(layer('a'))).toEqual([{ key_code: 'a' }])
expect(toIfAlone(layer('a').modifiers('??'))).toEqual([{ key_code: 'a' }])
expect(toIfAlone(layer('a').modifiers('⌘'))).toBeUndefined()
expect(toIfAlone(layer('a').modifiers('Hyper'))).toBeUndefined()

function toIfAlone(builder: LayerRuleBuilder) {
const rule = builder.manipulators({ 1: toKey(2) }).build()
return (rule.manipulators[0] as BasicManipulator).to_if_alone
}
})

// https://github.com/evan-liu/karabiner.ts/issues/89
test('layer().modifier(??)', () => {
expect(
Expand Down Expand Up @@ -277,7 +294,7 @@ test('layer().modifier(??)', () => {
})

test('layer() notification', () => {
const rule = layer('a').notification(true).build()
const rule = layer('a').notification().build()
const manipulators = rule.manipulators as BasicManipulator[]
expect(manipulators.length).toBe(1)
expect(manipulators[0].to?.[1]).toEqual({
Expand All @@ -299,3 +316,104 @@ test('layer() notification', () => {
},
})
})

describe('layer() leader mode', () => {
test('leader() with defaults', () => {
const rule = layer('a')
.leaderMode()
.manipulators({ 1: toKey(2), 3: toKey(4) })
.build()
const manipulators = rule.manipulators as BasicManipulator[]
expect(manipulators.length).toBe(5)

// layer toggle
expect(manipulators[0].to_after_key_up).toBeUndefined()
expect(manipulators[0].to_if_alone).toBeUndefined()

const ifOn = ifVar('layer-a', 1).build()
const toOff = toSetVar('layer-a', 0)
// layer keys
expect(manipulators[1].to?.[1]).toEqual(toOff)
expect(manipulators[2].to?.[1]).toEqual(toOff)
// escape keys
expect(manipulators[3].from).toEqual({ key_code: 'escape' })
expect(manipulators[3].to?.[0]).toEqual(toOff)
expect(manipulators[3].conditions).toEqual([ifOn])
expect(manipulators[4].from).toEqual({ key_code: 'caps_lock' })
expect(manipulators[4].to?.[0]).toEqual(toOff)
expect(manipulators[4].conditions).toEqual([ifOn])
})

test('leader() set escape keys', () => {
const rule = layer('a').leaderMode({ escape: 'spacebar' }).build()
const manipulators = rule.manipulators as BasicManipulator[]
expect(manipulators[1].from).toEqual({ key_code: 'spacebar' })
expect(manipulators[1].to?.[0]).toEqual(toSetVar('layer-a', 0))
expect(manipulators[1].conditions).toEqual([ifVar('layer-a', 1).build()])

const rule2 = layer('b')
.leaderMode({ escape: ['spacebar', { pointing_button: 2 }] })
.build()
const manipulators2 = rule2.manipulators as BasicManipulator[]
expect(manipulators2[1].from).toEqual({ key_code: 'spacebar' })
expect(manipulators2[1].to?.[0]).toEqual(toSetVar('layer-b', 0))
expect(manipulators2[1].conditions).toEqual([ifVar('layer-b', 1).build()])
expect(manipulators2[2].from).toEqual({ pointing_button: 2 })
expect(manipulators2[2].to?.[0]).toEqual(toSetVar('layer-b', 0))
expect(manipulators2[2].conditions).toEqual([ifVar('layer-b', 1).build()])
})

test('leader() with notification()', () => {
const rule = layer('a', 'v')
.leaderMode()
.notification()
.manipulators({ 1: toKey(2) })
.build()
const manipulators = rule.manipulators as BasicManipulator[]
expect(manipulators.length).toBe(4)

// layer toggle
expect(manipulators[0].to_after_key_up).toBeUndefined()
expect(manipulators[0].to?.[1]).toEqual(
toNotificationMessage('layer-v', 'Layer - v'),
)

const remove = toRemoveNotificationMessage('layer-v')
// layer key
expect(manipulators[1].to?.[2]).toEqual(remove)
// escape keys
expect(manipulators[2].to?.[1]).toEqual(remove)
expect(manipulators[3].to?.[1]).toEqual(remove)

const rule2 = layer('b').notification('Test B').build()
const manipulators2 = rule2.manipulators as BasicManipulator[]
expect(manipulators2[0].to?.[1]).toEqual(
toNotificationMessage('layer-layer-b', 'Test B'),
)
})

test('leader() with sticky', () => {
const rule = layer('a')
.leaderMode({ sticky: true })
.notification()
.manipulators({ 1: toKey(2) })
.build()
const manipulators = rule.manipulators as BasicManipulator[]
expect(manipulators.length).toBe(4)

const ifOn = ifVar('layer-a', 1).build()
const toOff = toSetVar('layer-a', 0)
const remove = toRemoveNotificationMessage('layer-layer-a')

// layer key
expect(manipulators[1].to?.length).toEqual(1)
expect(manipulators[1].conditions).toEqual([ifOn])
// escape keys
expect(manipulators[2].conditions).toEqual([ifOn])
expect(manipulators[2].to?.[0]).toEqual(toOff)
expect(manipulators[2].to?.[1]).toEqual(remove)
expect(manipulators[3].conditions).toEqual([ifOn])
expect(manipulators[3].to?.[0]).toEqual(toOff)
expect(manipulators[3].to?.[1]).toEqual(remove)
})
})
77 changes: 65 additions & 12 deletions src/config/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
parseFromModifierOverload,
} from '../utils/from-modifier-overload.ts'
import { getKeyWithAlias, ModifierKeyAlias } from '../utils/key-alias.ts'
import {
defaultLeaderModeOptions,
leaderModeEscape,
LeaderModeOptions,
} from '../utils/leader-mode.ts'
import { FromOptionalModifierParam } from '../utils/optional-modifiers.ts'
import { toArray } from '../utils/to-array.ts'

Expand Down Expand Up @@ -94,6 +99,8 @@ export class LayerRuleBuilder extends BasicRuleBuilder {

private layerNotification?: boolean | string

private leaderModeOptions?: LeaderModeOptions

constructor(
key: LayerKeyParam | LayerKeyParam[],
varName?: string,
Expand Down Expand Up @@ -152,18 +159,47 @@ export class LayerRuleBuilder extends BasicRuleBuilder {
}

/** Set the notification when the layer is active. */
public notification(v: boolean | string) {
public notification(v: boolean | string = true) {
this.layerNotification = v
return this
}

/** Set leader mode. Default escape keys: ['escape', 'caps_lock']. */
public leaderMode(v: boolean | LeaderModeOptions = true) {
if (v === true) {
this.leaderModeOptions = defaultLeaderModeOptions
} else if (!v) {
this.leaderModeOptions = undefined
} else {
this.leaderModeOptions = { ...defaultLeaderModeOptions, ...v }
}
return this
}

public build(context?: BuildContext): Rule {
const rule = super.build(context)

const conditions = this.conditions
.filter((v) => v !== this.layerCondition)
.map(buildCondition)
// Leader mode
if (this.leaderModeOptions) {
const toOff = [toSetVar(this.varName, this.offValue)]
if (this.layerNotification) {
toOff.push(toRemoveNotificationMessage(notificationId(this.varName)))
}
if (!this.leaderModeOptions.sticky) {
rule.manipulators.forEach(
(v) => v.type === 'basic' && (v.to = (v.to || []).concat(toOff)),
)
}
rule.manipulators.push(
...leaderModeEscape(
this.leaderModeOptions.escape,
ifVar(this.varName, this.onValue),
toOff,
),
)
}

// Modifier optional any
if (
this.layerModifiers?.mandatory?.length ||
this.layerModifiers?.optional?.length
Expand All @@ -174,6 +210,10 @@ export class LayerRuleBuilder extends BasicRuleBuilder {
)
}

// Layer toggle keys
const conditions = this.conditions
.filter((v) => v !== this.layerCondition)
.map(buildCondition)
for (const key_code of this.keys) {
rule.manipulators = [
...layerToggleManipulator(
Expand All @@ -189,6 +229,7 @@ export class LayerRuleBuilder extends BasicRuleBuilder {
this.layerNotification === true
? this.ruleDescription
: this.layerNotification || undefined,
this.leaderModeOptions,
),
...rule.manipulators,
]
Expand Down Expand Up @@ -236,6 +277,7 @@ export function layerToggleManipulator(
layerKeyManipulator?: BasicManipulatorBuilder,
replaceLayerKeyToIfAlone?: boolean,
notification?: string,
leaderMode?: LeaderModeOptions,
) {
function mergeManipulator<T extends BasicManipulator | BasicManipulator[]>(
to: T,
Expand Down Expand Up @@ -279,17 +321,24 @@ export function layerToggleManipulator(

const manipulator = map({ key_code, modifiers })
.toVar(varName, onValue)
.toAfterKeyUp(toSetVar(varName, offValue))
.toIfAlone({ key_code })
.condition(ifVar(varName, onValue).unless())
if (conditions?.length) manipulator.condition(...conditions)
if (!modifiers?.mandatory?.length && !leaderMode) {
manipulator.toIfAlone({ key_code })
}
if (!leaderMode) {
manipulator.toAfterKeyUp(toSetVar(varName, offValue))
}
if (conditions?.length) {
manipulator.condition(...conditions)
}
if (notification) {
const id = `layer-${varName}`
manipulator
.toNotificationMessage(id, notification)
.toAfterKeyUp(toRemoveNotificationMessage(id))
const id = notificationId(varName)
manipulator.toNotificationMessage(id, notification)
if (!leaderMode) manipulator.toAfterKeyUp(toRemoveNotificationMessage(id))
}
if (!context) {
return mergeManipulator(manipulator.build())
}
if (!context) return mergeManipulator(manipulator.build())

const key = [
`layer_${key_code}`,
Expand Down Expand Up @@ -322,3 +371,7 @@ function isModifiersAny({
if (optional?.length === 1 && optional[0] === 'any') return 'optional'
return null
}

function notificationId(varName: string) {
return `layer-${varName}`
}
34 changes: 34 additions & 0 deletions src/utils/leader-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ConditionBuilder } from '../config/condition.ts'
import { FromKeyParam, map } from '../config/from.ts'
import {
FromEvent,
Manipulator,
ToEvent,
} from '../karabiner/karabiner-config.ts'

export type LeaderModeOptions = {
/** Default ['escape', 'caps_lock']. */
escape?: FromKeyParam | FromEvent | Array<FromKeyParam | FromEvent>
/** Keep layer on until one of the `escape` keys pressed. */
sticky?: boolean
}

export const defaultLeaderModeOptions: LeaderModeOptions = {
escape: ['escape', 'caps_lock'],
}

export function leaderModeEscape(
keys: LeaderModeOptions['escape'],
ifOn: ConditionBuilder,
toOff: ToEvent[],
): Manipulator[] {
const result: Manipulator[] = []
if (!keys) return result

for (const key of Array.isArray(keys) ? keys : [keys]) {
const builder = typeof key === 'object' ? map(key) : map(key) // For TS fn overloads
result.push(...builder.condition(ifOn).to(toOff).build())
}

return result
}

0 comments on commit c38798b

Please sign in to comment.