Skip to content

Commit

Permalink
Merge pull request #125 from evan-liu/feat/duo-layer-leader-mode
Browse files Browse the repository at this point in the history
✨ Add duoLayer().leaderMode()
  • Loading branch information
evan-liu authored May 11, 2024
2 parents fd91ef9 + 82e6c1f commit 7b5cb86
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 10 deletions.
2 changes: 1 addition & 1 deletion docs/docs/rules/leader-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: layer().leaderMode()

# Leader Mode

`layer()` (or `hyperLayer()` / `modifierLayer()`) has a "leader mode", which works
`layer()` (or `hyperLayer()` / `modifierLayer()` / `duoLayer()`) 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.

Expand Down
115 changes: 114 additions & 1 deletion src/config/duo-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,
Expand All @@ -13,6 +13,7 @@ import { ifVar } from './condition'
import { defaultDuoLayerParameters, duoLayer } from './duo-layer'
import { map } from './from'
import {
toKey,
toNotificationMessage,
toRemoveNotificationMessage,
toSetVar,
Expand Down Expand Up @@ -41,6 +42,7 @@ test('duoLayer()', () => {
'basic.simultaneous_threshold_milliseconds':
defaultDuoLayerParameters['duo_layer.threshold_milliseconds'],
},
conditions: [{ type: 'variable_unless', name: 'duo-layer-1-2', value: 1 }],
})
// Add variable condition to manipulators
expect(manipulators[1].conditions).toEqual([ifVar('duo-layer-1-2').build()])
Expand Down Expand Up @@ -82,6 +84,7 @@ 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_unless', name: 'duo-layer-1-2', value: 1 },
{ type: 'variable_if', name: 'c', value: 1 },
])
})
Expand Down Expand Up @@ -184,3 +187,113 @@ test('duoLayer().toIfActivated() toIfDeactivated()', () => {
set_notification_message: { id: 'testId', text: '' },
})
})

describe('duoLayer().leaderMode()', () => {
test('leader() with defaults', () => {
const rule = duoLayer('a', 'b')
.leaderMode()
.manipulators({ 1: toKey(2), 3: toKey(4) })
.build()

const manipulators = rule.manipulators as BasicManipulator[]
expect(manipulators.length).toBe(5)

// layer toggle
const from = manipulators[0].from as FromSimultaneousEvent
expect(from.simultaneous_options?.to_after_key_up).toEqual([])

const ifOn = ifVar('duo-layer-a-b', 1).build()
const toOff = toSetVar('duo-layer-a-b', 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 = duoLayer('a', 'b').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('duo-layer-a-b', 0))
expect(manipulators[1].conditions).toEqual([
ifVar('duo-layer-a-b', 1).build(),
])

const rule2 = duoLayer('c', 'd')
.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('duo-layer-c-d', 0))
expect(manipulators2[1].conditions).toEqual([
ifVar('duo-layer-c-d', 1).build(),
])
expect(manipulators2[2].from).toEqual({ pointing_button: 2 })
expect(manipulators2[2].to?.[0]).toEqual(toSetVar('duo-layer-c-d', 0))
expect(manipulators2[2].conditions).toEqual([
ifVar('duo-layer-c-d', 1).build(),
])
})

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

// layer toggle
const from = manipulators[0].from as FromSimultaneousEvent
expect(from.simultaneous_options?.to_after_key_up).toEqual([])
expect(manipulators[0].to?.[1]).toEqual(
toNotificationMessage('duo-layer-v', 'DuoLayer v'),
)

const remove = toRemoveNotificationMessage('duo-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 = duoLayer('c', 'd').notification('Test CD').build()
const manipulators2 = rule2.manipulators as BasicManipulator[]
expect(manipulators2[0].to?.[1]).toEqual(
toNotificationMessage('duo-layer-duo-layer-c-d', 'Test CD'),
)
})

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

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

// 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)
})
})
57 changes: 49 additions & 8 deletions src/config/duo-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
ToVariable,
} from '../karabiner/karabiner-config.ts'
import { BuildContext } from '../utils/build-context.ts'
import {
defaultLeaderModeOptions,
leaderModeEscape,
LeaderModeOptions,
} from '../utils/leader-mode.ts'

import { buildCondition, ConditionBuilder, ifVar } from './condition.ts'
import { LayerKeyParam } from './layer.ts'
Expand Down Expand Up @@ -44,6 +49,8 @@ export class DuoLayerRuleBuilder extends BasicRuleBuilder {
private ifActivated = [] as ToEvent[]
private ifDeactivated = [] as ToEvent[]

private leaderModeOptions?: LeaderModeOptions

constructor(
private readonly key1: LayerKeyParam,
private readonly key2: LayerKeyParam,
Expand Down Expand Up @@ -74,7 +81,7 @@ export class DuoLayerRuleBuilder 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
}
Expand All @@ -91,6 +98,18 @@ export class DuoLayerRuleBuilder extends BasicRuleBuilder {
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)

Expand All @@ -106,19 +125,40 @@ export class DuoLayerRuleBuilder extends BasicRuleBuilder {
.filter((v) => v !== this.layerCondition)
.map(buildCondition)

// Layer toggle
const to = [toSetVar(this.varName, this.onValue), ...this.ifActivated]
const toAfterKeyUp = [
const activate = [toSetVar(this.varName, this.onValue), ...this.ifActivated]
const deactivate = [
toSetVar(this.varName, this.offValue),
...(this.simultaneousOptions.to_after_key_up || []),
...this.ifDeactivated,
]

if (notification) {
const id = `duo-layer-${this.varName}`
const message =
notification === true ? this.ruleDescription : notification
to.push(toNotificationMessage(id, message))
toAfterKeyUp.push(toRemoveNotificationMessage(id))
activate.push(toNotificationMessage(id, message))
deactivate.push(toRemoveNotificationMessage(id))
}

// Leader mode
if (this.leaderModeOptions) {
if (!this.leaderModeOptions.sticky) {
rule.manipulators.forEach(
(v) => v.type === 'basic' && (v.to = (v.to || []).concat(deactivate)),
)
}
rule.manipulators.push(
...leaderModeEscape(
this.leaderModeOptions.escape,
ifVar(this.varName, this.onValue),
deactivate,
),
)
}

// Layer toggle
const toAfterKeyUp = this.simultaneousOptions.to_after_key_up || []
if (!this.leaderModeOptions) {
toAfterKeyUp.push(...deactivate)
}
const manipulator = mapSimultaneous(
[this.key1, this.key2],
Expand All @@ -129,7 +169,8 @@ export class DuoLayerRuleBuilder extends BasicRuleBuilder {
threshold,
)
.modifiers('??')
.to(to)
.to(activate)
.condition(ifVar(this.varName, this.onValue).unless())
if (conditions.length) {
manipulator.condition(...conditions)
}
Expand Down

0 comments on commit 7b5cb86

Please sign in to comment.