diff --git a/app/Kconfig b/app/Kconfig index d1b6682f..b2a936d0 100644 --- a/app/Kconfig +++ b/app/Kconfig @@ -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 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..dc25b447 --- /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..ae64c2f9 --- /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 \ No newline at end of file diff --git a/app/src/combo.c b/app/src/combo.c index 90c89c15..31ceec40 100644 --- a/app/src/combo.c +++ b/app/src/combo.c @@ -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); diff --git a/docs/docs/config/combos.md b/docs/docs/config/combos.md index cd351125..b0b93b22 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,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. 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..f266be0f 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,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`.