feat: add partial combo holds

This commit is contained in:
Theo Lemay 2023-05-22 15:26:55 -07:00
parent 864394b40a
commit df26967cc5
8 changed files with 191 additions and 37 deletions

View file

@ -346,6 +346,10 @@ config ZMK_COMBO_MAX_KEYS_PER_COMBO
int "Maximum number of keys per combo"
default 4
config ZMK_COMBO_MAX_LAYERS_PER_COMBO
int "Maximum number of layers specified per combo"
default 4
#Combo options
endmenu

View file

@ -18,4 +18,5 @@
#include <behaviors/caps_word.dtsi>
#include <behaviors/key_repeat.dtsi>
#include <behaviors/backlight.dtsi>
#include <behaviors/macros.dtsi>
#include <behaviors/macros.dtsi>
#include <behaviors/combos.dtsi>

View file

@ -0,0 +1,15 @@
/*
* Copyright (c) 2022 The ZMK Contributors
*
* SPDX-License-Identifier: MIT
*/
/ {
behaviors {
partial_hold_position: combo_partial_hold_position {
compatible = "zmk,combo-partial-hold-position";
label = "PARTIAL_HOLD_POSITION";
#binding-cells = <1>;
};
};
};

View file

@ -0,0 +1,8 @@
# Copyright (c) 2022 The ZMK Contributors
# SPDX-License-Identifier: MIT
description: Add a partial hold position to a combo
compatible: "zmk,combo-partial-hold-position"
include: one_param.yaml

View file

@ -28,16 +28,17 @@ LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);
struct combo_cfg {
int32_t key_positions[CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO];
int32_t key_position_len;
struct zmk_behavior_binding behavior;
int32_t timeout_ms;
// if slow release is set, the combo releases when the last key is released.
// otherwise, the combo releases when the first key is released.
bool slow_release;
int8_t layers[CONFIG_ZMK_COMBO_MAX_LAYERS_PER_COMBO];
int32_t layers_len;
// the virtual key position is a key position outside the range used by the keyboard.
// it is necessary so hold-taps can uniquely identify a behavior.
int32_t virtual_key_position;
int32_t layers_len;
int8_t layers[];
int32_t behaviors_len;
struct zmk_behavior_binding behaviors[];
};
struct active_combo {
@ -266,22 +267,32 @@ static int release_pressed_keys() {
return CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO;
}
static inline int press_combo_behavior(struct combo_cfg *combo, int32_t timestamp) {
static inline int process_behavior(int32_t position, int32_t timestamp,
struct zmk_behavior_binding *behavior, bool pressed) {
struct zmk_behavior_binding_event event = {
.position = combo->virtual_key_position,
.position = position,
.timestamp = timestamp,
};
return behavior_keymap_binding_pressed(&combo->behavior, event);
if (pressed) {
return behavior_keymap_binding_pressed(behavior, event);
} else {
return behavior_keymap_binding_released(behavior, event);
}
}
static inline int release_combo_behavior(struct combo_cfg *combo, int32_t timestamp) {
struct zmk_behavior_binding_event event = {
.position = combo->virtual_key_position,
.timestamp = timestamp,
};
static inline int process_combo_behavior(struct combo_cfg *combo, int32_t timestamp, bool pressed) {
return process_behavior(combo->virtual_key_position, timestamp, &combo->behaviors[0], pressed);
}
return behavior_keymap_binding_released(&combo->behavior, event);
// press or release the behavior at the given position, used for partial holds
static inline int process_position(int32_t position, int32_t timestamp, const zmk_event_t *ev,
bool pressed) {
const struct zmk_position_state_changed *pos_ev;
if ((pos_ev = as_zmk_position_state_changed(ev)) != NULL) {
return zmk_keymap_position_state_changed(pos_ev->source, position, pressed, timestamp);
}
return -ENOTSUP;
}
static void move_pressed_keys_to_active_combo(struct active_combo *active_combo) {
@ -322,8 +333,9 @@ static void activate_combo(struct combo_cfg *combo) {
return;
}
move_pressed_keys_to_active_combo(active_combo);
press_combo_behavior(
combo, as_zmk_position_state_changed(active_combo->key_positions_pressed[0])->timestamp);
process_combo_behavior(
combo, as_zmk_position_state_changed(active_combo->key_positions_pressed[0])->timestamp,
true);
}
static void deactivate_combo(int active_combo_index) {
@ -336,8 +348,12 @@ static void deactivate_combo(int active_combo_index) {
active_combos[active_combo_count] = (struct active_combo){0};
}
#define ZM_IS_NODE_MATCH(a, b) (strcmp(a, b) == 0)
#define PARTIAL_HOLD_POSITION DT_PROP(DT_INST(0, zmk_combo_partial_hold_position), label)
#define IS_PARTIAL_HOLD_POSITION(dev) ZM_IS_NODE_MATCH(dev, PARTIAL_HOLD_POSITION)
/* returns true if a key was released. */
static bool release_combo_key(int32_t position, int64_t timestamp) {
static bool release_combo_key(int32_t position, int64_t timestamp, const zmk_event_t *ev) {
for (int combo_idx = 0; combo_idx < active_combo_count; combo_idx++) {
struct active_combo *active_combo = &active_combos[combo_idx];
@ -358,10 +374,51 @@ static bool release_combo_key(int32_t position, int64_t timestamp) {
}
if (key_released) {
if ((active_combo->combo->slow_release && all_keys_released) ||
(!active_combo->combo->slow_release && all_keys_pressed)) {
release_combo_behavior(active_combo->combo, timestamp);
// partial holds
for (int i = 1; i < active_combo->combo->behaviors_len; i++) {
// loop through every behavior except the first. If the behavior is a partial hold
// position, process it. If the next behavior is also a behavior, press that
// behavior instead of the partial hold position.
struct zmk_behavior_binding *binding = &active_combo->combo->behaviors[i];
// if the behavior is not a partial hold position, skip it
if (!IS_PARTIAL_HOLD_POSITION(binding->behavior_dev)) {
continue;
}
int32_t partial_hold_position = binding->param1;
// if the next behavior is a behavior (not a partial hold position)
bool has_explicit_behavior =
i == active_combo->combo->behaviors_len - 1
? false
: !IS_PARTIAL_HOLD_POSITION(
active_combo->combo->behaviors[i + 1].behavior_dev);
// if all keys are pressed, press all other partial hold keys that are still held
if (all_keys_pressed) {
// except the one that was released
if (partial_hold_position != position) {
// either press the position, or press the behavior
if (has_explicit_behavior) {
process_behavior(position, timestamp,
&active_combo->combo->behaviors[i + 1], true);
} else {
process_position(partial_hold_position, timestamp, ev, true);
}
}
} else {
// now release any partial hold keys that are released
if (partial_hold_position == position) {
// either release the position, or release the behavior
if (has_explicit_behavior) {
process_behavior(position, timestamp,
&active_combo->combo->behaviors[i + 1], false);
} else {
process_position(partial_hold_position, timestamp, ev, false);
}
}
}
}
if (all_keys_released) {
deactivate_combo(combo_idx);
}
@ -432,7 +489,7 @@ static int position_state_down(const zmk_event_t *ev, struct zmk_position_state_
static int position_state_up(const zmk_event_t *ev, struct zmk_position_state_changed *data) {
int released_keys = cleanup();
if (release_combo_key(data->position, data->timestamp)) {
if (release_combo_key(data->position, data->timestamp, ev)) {
return ZMK_EV_EVENT_HANDLED;
}
if (released_keys > 1) {
@ -471,16 +528,22 @@ static int position_state_changed_listener(const zmk_event_t *ev) {
ZMK_LISTENER(combo, position_state_changed_listener);
ZMK_SUBSCRIPTION(combo, zmk_position_state_changed);
#define BINDING_WITH_COMMA(idx, drv_inst) ZMK_KEYMAP_EXTRACT_BINDING(idx, drv_inst)
#define TRANSFORMED_BEHAVIORS(n) \
{ LISTIFY(DT_PROP_LEN(n, bindings), BINDING_WITH_COMMA, (, ), n) }
#define COMBO_INST(n) \
static struct combo_cfg combo_config_##n = { \
.timeout_ms = DT_PROP(n, timeout_ms), \
.key_positions = DT_PROP(n, key_positions), \
.key_position_len = DT_PROP_LEN(n, key_positions), \
.behavior = ZMK_KEYMAP_EXTRACT_BINDING(0, n), \
.virtual_key_position = ZMK_VIRTUAL_KEY_POSITION_COMBO(__COUNTER__), \
.slow_release = DT_PROP(n, slow_release), \
.layers = DT_PROP(n, layers), \
.layers_len = DT_PROP_LEN(n, layers), \
.virtual_key_position = ZMK_VIRTUAL_KEY_POSITION_COMBO(__COUNTER__), \
.behaviors = TRANSFORMED_BEHAVIORS(n), \
.behaviors_len = DT_PROP_LEN(n, bindings), \
};
#define INITIALIZE_COMBO(n) initialize_combo(&combo_config_##n);

View file

@ -11,11 +11,12 @@ See [Configuration Overview](index.md) for instructions on how to change these s
Definition file: [zmk/app/Kconfig](https://github.com/zmkfirmware/zmk/blob/main/app/Kconfig)
| Config | Type | Description | Default |
| ------------------------------------- | ---- | -------------------------------------------------------------- | ------- |
| `CONFIG_ZMK_COMBO_MAX_PRESSED_COMBOS` | int | Maximum number of combos that can be active at the same time | 4 |
| `CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY` | int | Maximum number of active combos that use the same key position | 5 |
| `CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO` | int | Maximum number of keys to press to activate a combo | 4 |
| Config | Type | Description | Default |
| --------------------------------------- | ---- | -------------------------------------------------------------- | ------- |
| `CONFIG_ZMK_COMBO_MAX_PRESSED_COMBOS` | int | Maximum number of combos that can be active at the same time | 4 |
| `CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY` | int | Maximum number of active combos that use the same key position | 5 |
| `CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO` | int | Maximum number of keys to press to activate a combo | 4 |
| `CONFIG_ZMK_COMBO_MAX_LAYERS_PER_COMBO` | int | Maximum number of layers specified per combo | 4 |
If `CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY` is 5, you can have 5 separate combos that use position `0`, 5 combos that use position `1`, and so on.
@ -31,12 +32,12 @@ The `zmk,combos` node itself has no properties. It should have one child node pe
Each child node can have the following properties:
| Property | Type | Description | Default |
| --------------- | ------------- | ----------------------------------------------------------------------------------------------------- | ------- |
| `bindings` | phandle-array | A [behavior](../features/keymaps.md#behaviors) to run when the combo is triggered | |
| `key-positions` | array | A list of key position indices for the keys which should trigger the combo | |
| `timeout-ms` | int | All the keys in `key-positions` must be pressed within this time in milliseconds to trigger the combo | 50 |
| `slow-release` | bool | Releases the combo when all keys are released instead of when any key is released | false |
| `layers` | array | A list of layers on which the combo may be triggered. `-1` allows all layers. | `<-1>` |
| Property | Type | Description | Default |
| ------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| `bindings` | phandle-array | A [behavior](../features/keymaps.md#behaviors) to run when the combo is triggered, along with optional `partial-hold-position` specifiers with associated bindings | |
| `key-positions` | array | A list of key position indices for the keys which should trigger the combo | |
| `timeout-ms` | int | All the keys in `key-positions` must be pressed within this time in milliseconds to trigger the combo | 50 |
| `slow-release` | bool | Releases the combo when all keys are released instead of when any key is released | false |
| `layers` | array | A list of layers on which the combo may be triggered. `-1` allows all layers. | `<-1>` |
The `key-positions` array must not be longer than the `CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO` setting, which defaults to 4. If you want a combo that triggers when pressing 5 keys, then you must change the setting to 5.

View file

@ -404,6 +404,7 @@ After creating the `.dtsi` from above, update `app/dts/behaviors.dtsi` to includ
#include <behaviors/key_repeat.dtsi>
#include <behaviors/backlight.dtsi>
#include <behaviors/macros.dtsi>
#include <behaviors/combos.dtsi>
// highlight-next-line
#include <behaviors/new_behavior_instance.dtsi>
```

View file

@ -6,7 +6,7 @@ title: Combos
Combo keys are a way to combine multiple keypresses to output a different key. For example, you can hit the Q and W keys on your keyboard to output escape.
### Configuration
## Configuration
Combos configured in your `.keymap` file, but are separate from the `keymap` node found there, since they are processed before the normal keymap. They are specified like this:
@ -29,7 +29,8 @@ Combos configured in your `.keymap` file, but are separate from the `keymap` nod
- `key-positions` is an array of key positions. See the info section below about how to figure out the positions on your board.
- `layers = <0 1...>` will allow limiting a combo to specific layers. This is an _optional_ parameter, when omitted it defaults to global scope.
- `bindings` is the behavior that is activated when the behavior is pressed.
- (advanced) you can specify `slow-release` if you want the combo binding to be released when all key-positions are released. The default is to release the combo as soon as any of the keys in the combo is released.
- (advanced) you can specify `slow-release` if you want the combo binding to be released when all key-positions are released. The default is to release the combo as soon as any of the keys in the combo is released. You can also specify `slow-release-positions` to select which key-positions _must_ be held to maintain the combo.
- (advanced) you can add `partial-hold-position` to the `bindings` array, optionally along with additional behaviors, to control what happens when a combo is partially released.
:::info
@ -37,7 +38,7 @@ Key positions are numbered like the keys in your keymap, starting at 0. So, if t
:::
### Advanced usage
## Advanced usage
- Partially overlapping combos like `0 1` and `0 2` are supported.
- Fully overlapping combos like `0 1` and `0 1 2` are supported.
@ -48,3 +49,63 @@ Invoking a source-specific behavior such as one of the [reset behaviors](behavio
:::
See [combo configuration](/docs/config/combos) for advanced configuration options.
### Partial Holds
After pressing a combo, you may want to specify the behavior that is activated when the combo is partially released. For example, if the keys corresponding to the combo positions 0, 1, and 2 are `&tog NAV`, `&kp A`, and `&kp LSFT` and the combo behavior is `&kp LEFT`, you may want to activate `&mo NAV` when you release `A` or `LSFT` but continue to hold `NAV`, or activate `LSFT` when you release `NAV` or `A` but continue to hold `LSFT`.
To do this, you can add `partial-hold-position` to the `bindings` array, optionally along with associated behaviors. When no explicit behavior is specified, it will default to the behavior belonging to the key position.
In this example, you would specify:
```
combo_nav {
timeout-ms = <50>;
key-positions = <0 1 2>;
bindings
= <&kp LEFT>
, <&partial-hold-position 0 &mo NAV>
, <&partial-hold-position 2> // defaults to &kp LSFT
;
};
```
### Partial Hold with Slow Release Positions
Partial holds compliment `slow-release-positions`, by letting you control what happens when a combo is partially released. The best motivating example is a combo that is used to "accelerate" an existing thumb momentary layer with a key on that layer, allowing you to mash the keys together at the same time. The following layout is an example of this:
```
#define NAV 1
/ {
combos {
compatible = "zmk,combos";
combo_nav {
timeout-ms = <50>;
key-positions = <0 1>;
bindings = <&kp LEFT>, <&partial-hold-position 0>;
slow-release;
slow-release-positions = <1>;
};
};
keymap {
compatible = "zmk,keymap";
default_layer {
bindings = <
&mo NAV &kp A &kp B
>;
};
nav_layer {
bindings = <
&mo NAV &kp LEFT &kp RIGHT
>;
};
};
};
```
In this example, you can press `LEFT` by pressing `NAV`, pressing `LEFT`, then releasing `NAV`. However, this requires a slight pause to ensure `NAV` was pressed before `LEFT`, so that `A` isn't pressed instead. What if you wanted to be able to mash the keys together at the same time, and achieve a consistent result? This is what the combo does — it allows you to press `LEFT` up to 50ms before `NAV` and still activate `LEFT`.
However, the introduction of the combo means that you can no longer release `LEFT` while holding `NAV` then press `RIGHT`. This is because the combo will be released when `LEFT` is released, and `B` will be pressed instead. To solve this, you can use `partial-hold-position` to specify that `NAV` should be pressed when `LEFT` is released. Finally, you can use `slow-release-positions` to specify that the combo should be held as long as `LEFT` is held, allowing you to use key-repeat while holding `LEFT` but releasing `NAV`.