diff --git a/app/Kconfig b/app/Kconfig index 0dd9316a..661e5a97 100644 --- a/app/Kconfig +++ b/app/Kconfig @@ -357,6 +357,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 diff --git a/app/dts/behaviors.dtsi b/app/dts/behaviors.dtsi index b3502cbb..a3497816 100644 --- a/app/dts/behaviors.dtsi +++ b/app/dts/behaviors.dtsi @@ -18,4 +18,5 @@ #include #include #include -#include \ No newline at end of file +#include +#include \ No newline at end of file diff --git a/app/dts/behaviors/combos.dtsi b/app/dts/behaviors/combos.dtsi new file mode 100644 index 00000000..04f41cd9 --- /dev/null +++ b/app/dts/behaviors/combos.dtsi @@ -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>; + }; + }; +}; diff --git a/app/dts/bindings/zmk,combo-partial-hold-position.yaml b/app/dts/bindings/zmk,combo-partial-hold-position.yaml new file mode 100644 index 00000000..9282719e --- /dev/null +++ b/app/dts/bindings/zmk,combo-partial-hold-position.yaml @@ -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 diff --git a/app/dts/bindings/zmk,combos.yaml b/app/dts/bindings/zmk,combos.yaml index d094b5c4..fa18070b 100644 --- a/app/dts/bindings/zmk,combos.yaml +++ b/app/dts/bindings/zmk,combos.yaml @@ -20,6 +20,10 @@ child-binding: default: 50 slow-release: type: boolean + slow-release-positions: + type: array + required: false + default: [] layers: type: array default: [-1] diff --git a/app/src/combo.c b/app/src/combo.c index 90c89c15..1a09f8e8 100644 --- a/app/src/combo.c +++ b/app/src/combo.c @@ -28,16 +28,19 @@ 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. + // if slow release is set, the combo releases when any key in slow_release_positions is released + // or all keys are released. otherwise, the combo releases when the first key is released. bool slow_release; + int32_t slow_release_positions[CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO]; + int32_t slow_release_positions_len; + 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 { @@ -46,6 +49,8 @@ struct active_combo { // The keys are removed from this array when they are released. // Once this array is empty, the behavior is released. const zmk_event_t *key_positions_pressed[CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO]; + // keep track if the behavior has already been released (used for slow release) + bool behavior_released; }; struct combo_candidate { @@ -266,22 +271,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) { @@ -304,6 +319,7 @@ static struct active_combo *store_active_combo(struct combo_cfg *combo) { for (int i = 0; i < CONFIG_ZMK_COMBO_MAX_PRESSED_COMBOS; i++) { if (active_combos[i].combo == NULL) { active_combos[i].combo = combo; + active_combos[i].behavior_released = false; active_combo_count++; return &active_combos[i]; } @@ -322,8 +338,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 +353,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 +379,72 @@ 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); + // slow release + if (!active_combo->combo->slow_release && all_keys_pressed) { + // if slow release is not enabled, release the behavior + process_combo_behavior(active_combo->combo, timestamp, false); + } else if (active_combo->combo->slow_release && !active_combo->behavior_released) { + // if slow release is enabled and the behavior has not yet been released + if (all_keys_released) { + // if all keys are released, release the behavior + process_combo_behavior(active_combo->combo, timestamp, false); + active_combo->behavior_released = true; + } else { + // if the key being released is a slow release key, release the behavior, + // otherwise ignore + for (int i = 0; i < active_combo->combo->slow_release_positions_len; i++) { + if (active_combo->combo->slow_release_positions[i] == position) { + process_combo_behavior(active_combo->combo, timestamp, false); + active_combo->behavior_released = true; + } + } + } } + + // 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 key at the 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 were 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 +515,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 +554,24 @@ 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), \ + .slow_release_positions = DT_PROP(n, slow_release_positions), \ + .slow_release_positions_len = DT_PROP_LEN(n, slow_release_positions), \ .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); diff --git a/docs/docs/config/combos.md b/docs/docs/config/combos.md index cd351125..86809ab1 100644 --- a/docs/docs/config/combos.md +++ b/docs/docs/config/combos.md @@ -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,13 @@ 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 | +| `slow-release-positions` | array | A list of key position indices for the keys that must be held during `slow-release`. If any key in `slow-release-positions` is released, the combo is released. | | +| `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. diff --git a/docs/docs/development/new-behavior.md b/docs/docs/development/new-behavior.md index 0d70aa3b..16fa50aa 100644 --- a/docs/docs/development/new-behavior.md +++ b/docs/docs/development/new-behavior.md @@ -404,6 +404,7 @@ After creating the `.dtsi` from above, update `app/dts/behaviors.dtsi` to includ #include #include #include +#include // highlight-next-line #include ``` diff --git a/docs/docs/features/combos.md b/docs/docs/features/combos.md index 44313cc1..0d7df930 100644 --- a/docs/docs/features/combos.md +++ b/docs/docs/features/combos.md @@ -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,69 @@ Invoking a source-specific behavior such as one of the [reset behaviors](behavio ::: See [combo configuration](/docs/config/combos) for advanced configuration options. + +### Slow Release + +If you want the combo binding to be released when all positions are released, instead of when any position is released, enable `slow-release`. This is useful for combos that are used to toggle a layer, for example. + +However, you may want to continue to hold the combo when one position is held but not the other. For example, if the keys corresponding to the combo positions 0 and 1 are `&mo NAV` and `&kp A`, and the combo behavior is `&kp LEFT`, you may want to continue holding `LEFT` while you hold `A` and release `NAV`, but not if you hold `NAV` and release `A`. To solve this, you can specify `slow-release-positions` to select which keys must be held to maintain `slow-release`. In this example, you would specify `slow-release-positions = <1>`. In other words, the combo will be held as long _all_ keys in `slow-release-positions` are held, and released when _any_ key in `slow-release-positions` is released. + +### 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 Holds 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`.