summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/CMakeLists.txt1
-rw-r--r--app/Kconfig17
-rw-r--r--app/dts/bindings/zmk,combos.yaml22
-rw-r--r--app/src/combo.c466
-rw-r--r--app/tests/combo/combos-and-holdtaps-0/events.patterns2
-rw-r--r--app/tests/combo/combos-and-holdtaps-0/keycode_events.snapshot4
-rw-r--r--app/tests/combo/combos-and-holdtaps-0/native_posix.keymap47
-rw-r--r--app/tests/combo/combos-and-holdtaps-1/events.patterns2
-rw-r--r--app/tests/combo/combos-and-holdtaps-1/keycode_events.snapshot4
-rw-r--r--app/tests/combo/combos-and-holdtaps-1/native_posix.keymap42
-rw-r--r--app/tests/combo/combos-and-holdtaps-2/events.patterns2
-rw-r--r--app/tests/combo/combos-and-holdtaps-2/keycode_events.snapshot2
-rw-r--r--app/tests/combo/combos-and-holdtaps-2/native_posix.keymap45
-rw-r--r--app/tests/combo/multiple-timeouts/events.patterns1
-rw-r--r--app/tests/combo/multiple-timeouts/keycode_events.snapshot4
-rw-r--r--app/tests/combo/multiple-timeouts/native_posix.keymap40
-rw-r--r--app/tests/combo/overlapping-combos-0/events.patterns2
-rw-r--r--app/tests/combo/overlapping-combos-0/keycode_events.snapshot20
-rw-r--r--app/tests/combo/overlapping-combos-0/native_posix.keymap117
-rw-r--r--app/tests/combo/overlapping-combos-1/events.patterns2
-rw-r--r--app/tests/combo/overlapping-combos-1/keycode_events.snapshot8
-rw-r--r--app/tests/combo/overlapping-combos-1/native_posix.keymap65
-rw-r--r--app/tests/combo/overlapping-combos-2/events.patterns2
-rw-r--r--app/tests/combo/overlapping-combos-2/keycode_events.snapshot4
-rw-r--r--app/tests/combo/overlapping-combos-2/native_posix.keymap52
-rw-r--r--app/tests/combo/overlapping-combos-3/events.patterns2
-rw-r--r--app/tests/combo/overlapping-combos-3/keycode_events.snapshot4
-rw-r--r--app/tests/combo/overlapping-combos-3/native_posix.keymap53
-rw-r--r--app/tests/combo/partially-overlapping-combos/events.patterns1
-rw-r--r--app/tests/combo/partially-overlapping-combos/keycode_events.snapshot16
-rw-r--r--app/tests/combo/partially-overlapping-combos/native_posix.keymap84
-rw-r--r--app/tests/combo/press-release/events.patterns1
-rw-r--r--app/tests/combo/press-release/keycode_events.snapshot8
-rw-r--r--app/tests/combo/press-release/native_posix.keymap51
-rw-r--r--app/tests/combo/press-timeout/events.patterns1
-rw-r--r--app/tests/combo/press-timeout/keycode_events.snapshot4
-rw-r--r--app/tests/combo/press-timeout/native_posix.keymap35
-rw-r--r--app/tests/combo/press1-press2-release1-release2/events.patterns2
-rw-r--r--app/tests/combo/press1-press2-release1-release2/keycode_events.snapshot4
-rw-r--r--app/tests/combo/press1-press2-release1-release2/native_posix.keymap45
-rw-r--r--app/tests/combo/press1-press2-release2-release1/events.patterns2
-rw-r--r--app/tests/combo/press1-press2-release2-release1/keycode_events.snapshot4
-rw-r--r--app/tests/combo/press1-press2-release2-release1/native_posix.keymap46
-rw-r--r--app/tests/combo/press1-release1-press2-release2/events.patterns2
-rw-r--r--app/tests/combo/press1-release1-press2-release2/keycode_events.snapshot4
-rw-r--r--app/tests/combo/press1-release1-press2-release2/native_posix.keymap46
-rw-r--r--app/tests/combo/slowrelease-disabled/events.patterns1
-rw-r--r--app/tests/combo/slowrelease-disabled/keycode_events.snapshot4
-rw-r--r--app/tests/combo/slowrelease-disabled/native_posix.keymap38
-rw-r--r--app/tests/combo/slowrelease-enabled/events.patterns1
-rw-r--r--app/tests/combo/slowrelease-enabled/keycode_events.snapshot4
-rw-r--r--app/tests/combo/slowrelease-enabled/native_posix.keymap38
-rw-r--r--docs/docs/behaviors/combos.md52
-rw-r--r--docs/docs/intro.md2
-rw-r--r--docs/sidebars.js1
55 files changed, 1528 insertions, 1 deletions
diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
index 5fb3827..b217a1a 100644
--- a/app/CMakeLists.txt
+++ b/app/CMakeLists.txt
@@ -52,6 +52,7 @@ if ((NOT CONFIG_ZMK_SPLIT) OR CONFIG_ZMK_SPLIT_BLE_ROLE_CENTRAL)
target_sources(app PRIVATE src/behaviors/behavior_none.c)
target_sources(app PRIVATE src/behaviors/behavior_sensor_rotate_key_press.c)
target_sources_ifdef(CONFIG_ZMK_EXT_POWER app PRIVATE src/behaviors/behavior_ext_power.c)
+ target_sources(app PRIVATE src/combo.c)
target_sources(app PRIVATE src/keymap.c)
endif()
target_sources_ifdef(CONFIG_ZMK_RGB_UNDERGLOW app PRIVATE src/behaviors/behavior_rgb_underglow.c)
diff --git a/app/Kconfig b/app/Kconfig
index f5d92a8..0aa291d 100644
--- a/app/Kconfig
+++ b/app/Kconfig
@@ -251,6 +251,23 @@ config ZMK_EXT_POWER
#Power Management
endmenu
+menu "Combo options"
+
+config ZMK_COMBO_MAX_PRESSED_COMBOS
+ int "Maximum number of currently pressed combos"
+ default 4
+
+config ZMK_COMBO_MAX_COMBOS_PER_KEY
+ int "Maximum number of combos per key"
+ default 5
+
+config ZMK_COMBO_MAX_KEYS_PER_COMBO
+ int "Maximum number of keys per combo"
+ default 4
+
+#Display/LED Options
+endmenu
+
menu "Advanced"
menu "Initialization Priorities"
diff --git a/app/dts/bindings/zmk,combos.yaml b/app/dts/bindings/zmk,combos.yaml
new file mode 100644
index 0000000..75eaa3e
--- /dev/null
+++ b/app/dts/bindings/zmk,combos.yaml
@@ -0,0 +1,22 @@
+# Copyright (c) 2020, The ZMK Contributors
+# SPDX-License-Identifier: MIT
+
+description: Combos container
+
+compatible: "zmk,combos"
+
+child-binding:
+ description: "A combo"
+
+ properties:
+ bindings:
+ type: phandle-array
+ required: true
+ key-positions:
+ type: array
+ required: true
+ timeout-ms:
+ type: int
+ default: 50
+ slow-release:
+ type: boolean
diff --git a/app/src/combo.c b/app/src/combo.c
new file mode 100644
index 0000000..4963870
--- /dev/null
+++ b/app/src/combo.c
@@ -0,0 +1,466 @@
+/*
+ * Copyright (c) 2020 The ZMK Contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+#define DT_DRV_COMPAT zmk_combos
+
+#include <device.h>
+#include <drivers/behavior.h>
+#include <logging/log.h>
+#include <sys/dlist.h>
+#include <kernel.h>
+
+#include <zmk/behavior.h>
+#include <zmk/event_manager.h>
+#include <zmk/events/position_state_changed.h>
+#include <zmk/hid.h>
+#include <zmk/matrix.h>
+
+LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);
+
+#if DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT)
+
+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;
+ // 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;
+};
+
+struct active_combo {
+ struct combo_cfg *combo;
+ // key_positions_pressed is filled with key_positions when the combo is pressed.
+ // The keys are removed from this array when they are released.
+ // Once this array is empty, the behavior is released.
+ struct position_state_changed *key_positions_pressed[CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO];
+};
+
+struct combo_candidate {
+ struct combo_cfg *combo;
+ // the time after which this behavior should be removed from candidates.
+ // by keeping track of when the candidate should be cleared there is no
+ // possibility of accidental releases.
+ int64_t timeout_at;
+};
+
+// set of keys pressed
+struct position_state_changed *pressed_keys[CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO] = {NULL};
+// the set of candidate combos based on the currently pressed_keys
+struct combo_candidate candidates[CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY];
+// the last candidate that was completely pressed
+struct combo_cfg *fully_pressed_combo = NULL;
+// a lookup dict that maps a key position to all combos on that position
+struct combo_cfg *combo_lookup[ZMK_KEYMAP_LEN][CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY] = {NULL};
+// combos that have been activated and still have (some) keys pressed
+// this array is always contiguous from 0.
+struct active_combo active_combos[CONFIG_ZMK_COMBO_MAX_PRESSED_COMBOS] = {NULL};
+int active_combo_count = 0;
+
+struct k_delayed_work timeout_task;
+int64_t timeout_task_timeout_at;
+
+// Store the combo key pointer in the combos array, one pointer for each key position
+// The combos are sorted shortest-first, then by virtual-key-position.
+static int initialize_combo(struct combo_cfg *new_combo) {
+ for (int i = 0; i < new_combo->key_position_len; i++) {
+ int32_t position = new_combo->key_positions[i];
+ if (position >= ZMK_KEYMAP_LEN) {
+ LOG_ERR("Unable to initialize combo, key position %d does not exist", position);
+ return -EINVAL;
+ }
+
+ struct combo_cfg *insert_combo = new_combo;
+ bool set = false;
+ for (int j = 0; j < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; j++) {
+ struct combo_cfg *combo_at_j = combo_lookup[position][j];
+ if (combo_at_j == NULL) {
+ combo_lookup[position][j] = insert_combo;
+ set = true;
+ break;
+ }
+ if (combo_at_j->key_position_len < insert_combo->key_position_len ||
+ (combo_at_j->key_position_len == insert_combo->key_position_len &&
+ combo_at_j->virtual_key_position < insert_combo->virtual_key_position)) {
+ continue;
+ }
+ // put insert_combo in this spot, move all other combos up.
+ combo_lookup[position][j] = insert_combo;
+ insert_combo = combo_at_j;
+ }
+ if (!set) {
+ LOG_ERR("Too many combos for key position %d, CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY %d.",
+ position, CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY);
+ return -ENOMEM;
+ }
+ }
+ return 0;
+}
+
+static int setup_candidates_for_first_keypress(int32_t position, int64_t timestamp) {
+ for (int i = 0; i < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; i++) {
+ struct combo_cfg *combo = combo_lookup[position][i];
+ if (combo == NULL) {
+ return i;
+ }
+ candidates[i].combo = combo;
+ candidates[i].timeout_at = timestamp + combo->timeout_ms;
+ // LOG_DBG("combo timeout %d %d %d", position, i, candidates[i].timeout_at);
+ }
+ return CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY;
+}
+
+static int filter_candidates(int32_t position) {
+ // this code iterates over candidates and the lookup together to filter in O(n)
+ // assuming they are both sorted on key_position_len, virtal_key_position
+ int matches = 0, lookup_idx = 0, candidate_idx = 0;
+ while (lookup_idx < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY &&
+ candidate_idx < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY) {
+ struct combo_cfg *candidate = candidates[candidate_idx].combo;
+ struct combo_cfg *lookup = combo_lookup[position][lookup_idx];
+ if (candidate == NULL || lookup == NULL) {
+ break;
+ }
+ if (candidate->virtual_key_position == lookup->virtual_key_position) {
+ candidates[matches] = candidates[candidate_idx];
+ matches++;
+ candidate_idx++;
+ lookup_idx++;
+ } else if (candidate->key_position_len > lookup->key_position_len) {
+ lookup_idx++;
+ } else if (candidate->key_position_len < lookup->key_position_len) {
+ candidate_idx++;
+ } else if (candidate->virtual_key_position > lookup->virtual_key_position) {
+ lookup_idx++;
+ } else if (candidate->virtual_key_position < lookup->virtual_key_position) {
+ candidate_idx++;
+ }
+ }
+ // clear unmatched candidates
+ for (int i = matches; i < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; i++) {
+ candidates[i].combo = NULL;
+ }
+ // LOG_DBG("combo matches after filter %d", matches);
+ return matches;
+}
+
+static int64_t first_candidate_timeout() {
+ int64_t first_timeout = LONG_MAX;
+ for (int i = 0; i < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; i++) {
+ if (candidates[i].combo == NULL) {
+ break;
+ }
+ if (candidates[i].timeout_at < first_timeout) {
+ first_timeout = candidates[i].timeout_at;
+ }
+ }
+ return first_timeout;
+}
+
+static inline bool candidate_is_completely_pressed(struct combo_cfg *candidate) {
+ // this code assumes set(pressed_keys) <= set(candidate->key_positions)
+ // this invariant is enforced by filter_candidates
+ // the only thing we need to do is check if len(pressed_keys) == len(combo->key_positions)
+ return pressed_keys[candidate->key_position_len - 1] != NULL;
+}
+
+static void cleanup();
+
+static int filter_timed_out_candidates(int64_t timestamp) {
+ int num_candidates = 0;
+ for (int i = 0; i < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; i++) {
+ struct combo_candidate *candidate = &candidates[i];
+ if (candidate->combo == NULL) {
+ break;
+ }
+ if (candidate->timeout_at > timestamp) {
+ // reorder candidates so they're contiguous
+ candidates[num_candidates].combo = candidate->combo;
+ candidates[num_candidates].timeout_at = candidates->timeout_at;
+ num_candidates++;
+ } else {
+ candidate->combo = NULL;
+ }
+ }
+ return num_candidates;
+}
+
+static int clear_candidates() {
+ for (int i = 0; i < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; i++) {
+ if (candidates[i].combo == NULL) {
+ return i;
+ }
+ candidates[i].combo = NULL;
+ }
+ return CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY;
+}
+
+static int capture_pressed_key(struct position_state_changed *ev) {
+ for (int i = 0; i < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; i++) {
+ if (pressed_keys[i] != NULL) {
+ continue;
+ }
+ pressed_keys[i] = ev;
+ return ZMK_EV_EVENT_CAPTURED;
+ }
+ return 0;
+}
+
+const struct zmk_listener zmk_listener_combo;
+
+static void release_pressed_keys() {
+ // release the first key that was pressed
+ if (pressed_keys[0] == NULL) {
+ return;
+ }
+ ZMK_EVENT_RELEASE(pressed_keys[0])
+ pressed_keys[0] = NULL;
+
+ // reprocess events (see tests/combo/fully-overlapping-combos-3 for why this is needed)
+ for (int i = 1; i < CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY; i++) {
+ if (pressed_keys[i] == NULL) {
+ return;
+ }
+ struct position_state_changed *captured_event = pressed_keys[i];
+ pressed_keys[i] = NULL;
+ ZMK_EVENT_RAISE(captured_event);
+ }
+}
+
+static inline int press_combo_behavior(struct combo_cfg *combo, int32_t timestamp) {
+ struct zmk_behavior_binding_event event = {
+ .position = combo->virtual_key_position,
+ .timestamp = timestamp,
+ };
+
+ return behavior_keymap_binding_pressed(&combo->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,
+ };
+
+ return behavior_keymap_binding_released(&combo->behavior, event);
+}
+
+static void move_pressed_keys_to_active_combo(struct active_combo *active_combo) {
+ int combo_length = active_combo->combo->key_position_len;
+ for (int i = 0; i < combo_length; i++) {
+ active_combo->key_positions_pressed[i] = pressed_keys[i];
+ pressed_keys[i] = NULL;
+ }
+ // move any other pressed keys up
+ for (int i = 0; i + combo_length < CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO; i++) {
+ if (pressed_keys[i + combo_length] == NULL) {
+ return;
+ }
+ pressed_keys[i] = pressed_keys[i + combo_length];
+ pressed_keys[i + combo_length] = NULL;
+ }
+}
+
+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_combo_count++;
+ return &active_combos[i];
+ }
+ }
+ LOG_ERR("Unable to store combo; already %d active. Increase "
+ "CONFIG_ZMK_COMBO_MAX_PRESSED_COMBOS",
+ CONFIG_ZMK_COMBO_MAX_PRESSED_COMBOS);
+ return NULL;
+}
+
+static void activate_combo(struct combo_cfg *combo) {
+ struct active_combo *active_combo = store_active_combo(combo);
+ if (active_combo == NULL) {
+ // unable to store combo
+ release_pressed_keys();
+ return;
+ }
+ move_pressed_keys_to_active_combo(active_combo);
+ press_combo_behavior(combo, active_combo->key_positions_pressed[0]->timestamp);
+}
+
+static void deactivate_combo(int active_combo_index) {
+ active_combo_count--;
+ if (active_combo_index != active_combo_count) {
+ memcpy(&active_combos[active_combo_index], &active_combos[active_combo_count],
+ sizeof(struct active_combo));
+ }
+ active_combos[active_combo_count].combo = NULL;
+ active_combos[active_combo_count] = (struct active_combo){0};
+}
+
+/* returns true if a key was released. */
+static bool release_combo_key(int32_t position, int64_t timestamp) {
+ for (int combo_idx = 0; combo_idx < active_combo_count; combo_idx++) {
+ struct active_combo *active_combo = &active_combos[combo_idx];
+
+ bool key_released = false;
+ bool all_keys_pressed = true;
+ bool all_keys_released = true;
+ for (int i = 0; i < active_combo->combo->key_position_len; i++) {
+ if (active_combo->key_positions_pressed[i] == NULL) {
+ all_keys_pressed = false;
+ } else if (active_combo->key_positions_pressed[i]->position != position) {
+ all_keys_released = false;
+ } else { // not null and position matches
+ k_free(active_combo->key_positions_pressed[i]);
+ active_combo->key_positions_pressed[i] = NULL;
+ key_released = true;
+ }
+ }
+
+ 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);
+ }
+ if (all_keys_released) {
+ deactivate_combo(combo_idx);
+ }
+ return true;
+ }
+ }
+ return false;
+}
+
+static void cleanup() {
+ k_delayed_work_cancel(&timeout_task);
+ clear_candidates();
+ if (fully_pressed_combo != NULL) {
+ activate_combo(fully_pressed_combo);
+ fully_pressed_combo = NULL;
+ }
+ release_pressed_keys();
+}
+
+static void update_timeout_task() {
+ int64_t first_timeout = first_candidate_timeout();
+ if (timeout_task_timeout_at == first_timeout) {
+ return;
+ }
+ if (first_timeout == LLONG_MAX) {
+ timeout_task_timeout_at = 0;
+ k_delayed_work_cancel(&timeout_task);
+ return;
+ }
+ if (k_delayed_work_submit(&timeout_task, K_MSEC(first_timeout - k_uptime_get())) == 0) {
+ timeout_task_timeout_at = first_timeout;
+ }
+}
+
+static int position_state_down(struct position_state_changed *ev) {
+ int num_candidates;
+ if (candidates[0].combo == NULL) {
+ num_candidates = setup_candidates_for_first_keypress(ev->position, ev->timestamp);
+ if (num_candidates == 0) {
+ return 0;
+ }
+ } else {
+ filter_timed_out_candidates(ev->timestamp);
+ num_candidates = filter_candidates(ev->position);
+ }
+ update_timeout_task();
+
+ struct combo_cfg *candidate_combo = candidates[0].combo;
+ int ret = capture_pressed_key(ev);
+ switch (num_candidates) {
+ case 0:
+ cleanup();
+ return ret;
+ case 1:
+ if (candidate_is_completely_pressed(candidate_combo)) {
+ fully_pressed_combo = candidate_combo;
+ cleanup();
+ }
+ return ret;
+ default:
+ if (candidate_is_completely_pressed(candidate_combo)) {
+ fully_pressed_combo = candidate_combo;
+ }
+ return ret;
+ }
+}
+
+static int position_state_up(struct position_state_changed *ev) {
+ cleanup();
+ if (release_combo_key(ev->position, ev->timestamp)) {
+ return ZMK_EV_EVENT_HANDLED;
+ } else {
+ return 0;
+ }
+}
+
+static void combo_timeout_handler(struct k_work *item) {
+ if (timeout_task_timeout_at == 0 || k_uptime_get() < timeout_task_timeout_at) {
+ // timer was cancelled or rescheduled.
+ return;
+ }
+ if (filter_timed_out_candidates(timeout_task_timeout_at) < 2) {
+ cleanup();
+ }
+ update_timeout_task();
+}
+
+static int position_state_changed_listener(const struct zmk_event_header *eh) {
+ if (!is_position_state_changed(eh)) {
+ return 0;
+ }
+
+ struct position_state_changed *ev = cast_position_state_changed(eh);
+ if (ev->state) { // keydown
+ return position_state_down(ev);
+ } else { // keyup
+ return position_state_up(ev);
+ }
+}
+
+ZMK_LISTENER(combo, position_state_changed_listener);
+ZMK_SUBSCRIPTION(combo, position_state_changed);
+
+// todo: remove this once #506 is merged and #include <zmk/keymap.h>
+#define KEY_BINDING_TO_STRUCT(idx, drv_inst) \
+ { \
+ .behavior_dev = DT_LABEL(DT_PHANDLE_BY_IDX(drv_inst, bindings, idx)), \
+ .param1 = COND_CODE_0(DT_PHA_HAS_CELL_AT_IDX(drv_inst, bindings, idx, param1), (0), \
+ (DT_PHA_BY_IDX(drv_inst, bindings, idx, param1))), \
+ .param2 = COND_CODE_0(DT_PHA_HAS_CELL_AT_IDX(drv_inst, bindings, idx, param2), (0), \
+ (DT_PHA_BY_IDX(drv_inst, bindings, idx, param2))), \
+ }
+
+#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 = KEY_BINDING_TO_STRUCT(0, n), \
+ .virtual_key_position = ZMK_KEYMAP_LEN + __COUNTER__, \
+ .slow_release = DT_PROP(n, slow_release), \
+ };
+
+#define INITIALIZE_COMBO(n) initialize_combo(&combo_config_##n);
+
+DT_INST_FOREACH_CHILD(0, COMBO_INST)
+
+static int combo_init() {
+ k_delayed_work_init(&timeout_task, combo_timeout_handler);
+ DT_INST_FOREACH_CHILD(0, INITIALIZE_COMBO);
+ return 0;
+}
+
+SYS_INIT(combo_init, APPLICATION, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT);
+
+#endif \ No newline at end of file
diff --git a/app/tests/combo/combos-and-holdtaps-0/events.patterns b/app/tests/combo/combos-and-holdtaps-0/events.patterns
new file mode 100644
index 0000000..b90d786
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-0/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo//p \ No newline at end of file
diff --git a/app/tests/combo/combos-and-holdtaps-0/keycode_events.snapshot b/app/tests/combo/combos-and-holdtaps-0/keycode_events.snapshot
new file mode 100644
index 0000000..ad86b26
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-0/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0xe0 mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0xe0 mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
diff --git a/app/tests/combo/combos-and-holdtaps-0/native_posix.keymap b/app/tests/combo/combos-and-holdtaps-0/native_posix.keymap
new file mode 100644
index 0000000..d35c727
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-0/native_posix.keymap
@@ -0,0 +1,47 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+&mt {
+ flavor = "hold-preferred";
+};
+
+/*
+This test fails if the order of event handlers for hold-taps
+and combos is wrong. Hold-taps need to process key position events
+first so the decision to hold or tap can be made.
+*/
+/ {
+ combos {
+ compatible = "zmk,combos";
+
+ combo_two {
+ timeout-ms = <100>;
+ key-positions = <1 2>;
+ bindings = <&kp Y>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &mt LEFT_CONTROL A &kp B
+ &kp C &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/combos-and-holdtaps-1/events.patterns b/app/tests/combo/combos-and-holdtaps-1/events.patterns
new file mode 100644
index 0000000..b90d786
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-1/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo//p \ No newline at end of file
diff --git a/app/tests/combo/combos-and-holdtaps-1/keycode_events.snapshot b/app/tests/combo/combos-and-holdtaps-1/keycode_events.snapshot
new file mode 100644
index 0000000..dc4dbb4
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-1/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
diff --git a/app/tests/combo/combos-and-holdtaps-1/native_posix.keymap b/app/tests/combo/combos-and-holdtaps-1/native_posix.keymap
new file mode 100644
index 0000000..a99c15d
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-1/native_posix.keymap
@@ -0,0 +1,42 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+&mt {
+ flavor = "hold-preferred";
+};
+
+/* this test checks if hold-taps can be part of a combo */
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_two {
+ timeout-ms = <100>;
+ key-positions = <0 1>;
+ bindings = <&kp Y>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &mt LEFT_CONTROL A &kp B
+ &kp C &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/combos-and-holdtaps-2/events.patterns b/app/tests/combo/combos-and-holdtaps-2/events.patterns
new file mode 100644
index 0000000..b90d786
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-2/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo//p \ No newline at end of file
diff --git a/app/tests/combo/combos-and-holdtaps-2/keycode_events.snapshot b/app/tests/combo/combos-and-holdtaps-2/keycode_events.snapshot
new file mode 100644
index 0000000..a650880
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-2/keycode_events.snapshot
@@ -0,0 +1,2 @@
+pressed: usage_page 0x07 keycode 0xe0 mods 0x00
+pressed: usage_page 0x07 keycode 0xe4 mods 0x00
diff --git a/app/tests/combo/combos-and-holdtaps-2/native_posix.keymap b/app/tests/combo/combos-and-holdtaps-2/native_posix.keymap
new file mode 100644
index 0000000..f8dbe45
--- /dev/null
+++ b/app/tests/combo/combos-and-holdtaps-2/native_posix.keymap
@@ -0,0 +1,45 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+&mt {
+ flavor = "hold-preferred";
+};
+
+/* This test verifies that hold-tap keys can observe
+ * events which were released from combos.
+ */
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <100>;
+ key-positions = <0 2>;
+ bindings = <&kp Y>;
+ };
+ combo_two {
+ timeout-ms = <100>;
+ key-positions = <1 3>;
+ bindings = <&kp Z>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &mt LEFT_CONTROL A &mt RIGHT_CONTROL B
+ &none &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,0)
+ ZMK_MOCK_PRESS(0,1,300)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/multiple-timeouts/events.patterns b/app/tests/combo/multiple-timeouts/events.patterns
new file mode 100644
index 0000000..b1342af
--- /dev/null
+++ b/app/tests/combo/multiple-timeouts/events.patterns
@@ -0,0 +1 @@
+s/.*hid_listener_keycode_//p
diff --git a/app/tests/combo/multiple-timeouts/keycode_events.snapshot b/app/tests/combo/multiple-timeouts/keycode_events.snapshot
new file mode 100644
index 0000000..c5bdd6e
--- /dev/null
+++ b/app/tests/combo/multiple-timeouts/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x04 mods 0x00
+pressed: usage_page 0x07 keycode 0x05 mods 0x00
+released: usage_page 0x07 keycode 0x04 mods 0x00
+released: usage_page 0x07 keycode 0x05 mods 0x00
diff --git a/app/tests/combo/multiple-timeouts/native_posix.keymap b/app/tests/combo/multiple-timeouts/native_posix.keymap
new file mode 100644
index 0000000..91bf523
--- /dev/null
+++ b/app/tests/combo/multiple-timeouts/native_posix.keymap
@@ -0,0 +1,40 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ };
+ combo_two {
+ timeout-ms = <120>;
+ key-positions = <0 1 2>;
+ bindings = <&kp C>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &none &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,100)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-0/events.patterns b/app/tests/combo/overlapping-combos-0/events.patterns
new file mode 100644
index 0000000..b90d786
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-0/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo//p \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-0/keycode_events.snapshot b/app/tests/combo/overlapping-combos-0/keycode_events.snapshot
new file mode 100644
index 0000000..ec63b77
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-0/keycode_events.snapshot
@@ -0,0 +1,20 @@
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
diff --git a/app/tests/combo/overlapping-combos-0/native_posix.keymap b/app/tests/combo/overlapping-combos-0/native_posix.keymap
new file mode 100644
index 0000000..e3cbf43
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-0/native_posix.keymap
@@ -0,0 +1,117 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/*
+ combo 0 timeout inf
+ combo 01 timeout inf
+ combo 0123 timeout inf
+ press 012 in any combination, release any of those keys
+ expected: combo 012 on key-release
+ */
+
+/* it is useful to set timeout to a large value when attaching a debugger. */
+#define TIMEOUT (60*60*1000)
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <TIMEOUT>;
+ key-positions = <0 1 2>;
+ bindings = <&kp X>;
+ };
+
+ combo_two {
+ timeout-ms = <TIMEOUT>;
+ key-positions = <0 2>;
+ bindings = <&kp Y>;
+ };
+
+ combo_three {
+ timeout-ms = <TIMEOUT>;
+ key-positions = <1>;
+ bindings = <&kp Z>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp C &none
+ >;
+ };
+ };
+};
+&kscan {
+ events = <
+ /* all permutations of combo one press, combo triggered by release */
+ /* while debugging these, you may want to set the release_timer to a high number */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ /* all permutations of combo two press and release, combo triggered by release */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-1/events.patterns b/app/tests/combo/overlapping-combos-1/events.patterns
new file mode 100644
index 0000000..b90d786
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-1/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo//p \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-1/keycode_events.snapshot b/app/tests/combo/overlapping-combos-1/keycode_events.snapshot
new file mode 100644
index 0000000..daf7247
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-1/keycode_events.snapshot
@@ -0,0 +1,8 @@
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
diff --git a/app/tests/combo/overlapping-combos-1/native_posix.keymap b/app/tests/combo/overlapping-combos-1/native_posix.keymap
new file mode 100644
index 0000000..c228c47
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-1/native_posix.keymap
@@ -0,0 +1,65 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/*
+ combo 01 timeout 50
+ combo 012 timeout 100
+ AB is pressed within 50ms, C is never pressed.
+ expected outcome: AB after 100ms
+*/
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_two {
+ timeout-ms = <50>;
+ key-positions = <0 1>;
+ bindings = <&kp Y>;
+ };
+
+ combo_three {
+ timeout-ms = <100>;
+ key-positions = <0 1 2>;
+ bindings = <&kp X>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp C &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ /* if you're debugging these, remember that the timer can be triggered between
+ events while stepping through code. */
+ /* all permutations of combo two press and release, combo triggered by timeout */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,100)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,100)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,100)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,100)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-2/events.patterns b/app/tests/combo/overlapping-combos-2/events.patterns
new file mode 100644
index 0000000..b90d786
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-2/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo//p \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-2/keycode_events.snapshot b/app/tests/combo/overlapping-combos-2/keycode_events.snapshot
new file mode 100644
index 0000000..dc4dbb4
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-2/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
diff --git a/app/tests/combo/overlapping-combos-2/native_posix.keymap b/app/tests/combo/overlapping-combos-2/native_posix.keymap
new file mode 100644
index 0000000..3d36421
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-2/native_posix.keymap
@@ -0,0 +1,52 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/*
+ combo 01 timeout 100
+ combo 0123 timeout 100
+ press 012, wait until timeout runs out
+ expected: combo 01 after 100ms, immediately followed by key 2.
+ */
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_two {
+ timeout-ms = <100>;
+ key-positions = <0 1>;
+ bindings = <&kp Y>;
+ };
+
+ combo_four {
+ timeout-ms = <100>;
+ key-positions = <0 1 2 3>;
+ bindings = <&kp W>;
+ };
+
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp C &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ /* if you're debugging these, remember that the timer can be triggered between
+ events while stepping through code. */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,2,100)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,2,100)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-3/events.patterns b/app/tests/combo/overlapping-combos-3/events.patterns
new file mode 100644
index 0000000..b90d786
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-3/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo//p \ No newline at end of file
diff --git a/app/tests/combo/overlapping-combos-3/keycode_events.snapshot b/app/tests/combo/overlapping-combos-3/keycode_events.snapshot
new file mode 100644
index 0000000..e0cb655
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-3/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x04 mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x04 mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
diff --git a/app/tests/combo/overlapping-combos-3/native_posix.keymap b/app/tests/combo/overlapping-combos-3/native_posix.keymap
new file mode 100644
index 0000000..0622dcd
--- /dev/null
+++ b/app/tests/combo/overlapping-combos-3/native_posix.keymap
@@ -0,0 +1,53 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/*
+ combo 12 timeout 100
+ combo 0123 timeout 100
+ press 012, release 2
+ expected: key pos 0 followed by combo 12
+ */
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_two {
+ timeout-ms = <100>;
+ key-positions = <1 2>;
+ bindings = <&kp Y>;
+ };
+
+
+ combo_four {
+ timeout-ms = <100>;
+ key-positions = <0 1 2 3>;
+ bindings = <&kp W>;
+ };
+
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp C &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ /* if you're debugging these, remember that the timer can be triggered between
+ events while stepping through code. */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,2,100)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,2,100)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/partially-overlapping-combos/events.patterns b/app/tests/combo/partially-overlapping-combos/events.patterns
new file mode 100644
index 0000000..b1342af
--- /dev/null
+++ b/app/tests/combo/partially-overlapping-combos/events.patterns
@@ -0,0 +1 @@
+s/.*hid_listener_keycode_//p
diff --git a/app/tests/combo/partially-overlapping-combos/keycode_events.snapshot b/app/tests/combo/partially-overlapping-combos/keycode_events.snapshot
new file mode 100644
index 0000000..adaa64b
--- /dev/null
+++ b/app/tests/combo/partially-overlapping-combos/keycode_events.snapshot
@@ -0,0 +1,16 @@
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1b mods 0x00
+released: usage_page 0x07 keycode 0x1b mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
+pressed: usage_page 0x07 keycode 0x1c mods 0x00
+released: usage_page 0x07 keycode 0x1c mods 0x00
diff --git a/app/tests/combo/partially-overlapping-combos/native_posix.keymap b/app/tests/combo/partially-overlapping-combos/native_posix.keymap
new file mode 100644
index 0000000..4e68105
--- /dev/null
+++ b/app/tests/combo/partially-overlapping-combos/native_posix.keymap
@@ -0,0 +1,84 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp X>;
+ };
+
+ combo_two {
+ timeout-ms = <30>;
+ key-positions = <0 2>;
+ bindings = <&kp Y>;
+ };
+
+ combo_three {
+ timeout-ms = <30>;
+ key-positions = <3>;
+ bindings = <&kp Z>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp C &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ /* all permutations of combo one press and release */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ /* all permutations of combo two press and release */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,2,10)
+ ZMK_MOCK_RELEASE(0,2,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ >;
+};
diff --git a/app/tests/combo/press-release/events.patterns b/app/tests/combo/press-release/events.patterns
new file mode 100644
index 0000000..b1342af
--- /dev/null
+++ b/app/tests/combo/press-release/events.patterns
@@ -0,0 +1 @@
+s/.*hid_listener_keycode_//p
diff --git a/app/tests/combo/press-release/keycode_events.snapshot b/app/tests/combo/press-release/keycode_events.snapshot
new file mode 100644
index 0000000..01718e7
--- /dev/null
+++ b/app/tests/combo/press-release/keycode_events.snapshot
@@ -0,0 +1,8 @@
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
diff --git a/app/tests/combo/press-release/native_posix.keymap b/app/tests/combo/press-release/native_posix.keymap
new file mode 100644
index 0000000..0f45792
--- /dev/null
+++ b/app/tests/combo/press-release/native_posix.keymap
@@ -0,0 +1,51 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &none &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ /* all different combinations of press and release order */
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/press-timeout/events.patterns b/app/tests/combo/press-timeout/events.patterns
new file mode 100644
index 0000000..b1342af
--- /dev/null
+++ b/app/tests/combo/press-timeout/events.patterns
@@ -0,0 +1 @@
+s/.*hid_listener_keycode_//p
diff --git a/app/tests/combo/press-timeout/keycode_events.snapshot b/app/tests/combo/press-timeout/keycode_events.snapshot
new file mode 100644
index 0000000..c5bdd6e
--- /dev/null
+++ b/app/tests/combo/press-timeout/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x04 mods 0x00
+pressed: usage_page 0x07 keycode 0x05 mods 0x00
+released: usage_page 0x07 keycode 0x04 mods 0x00
+released: usage_page 0x07 keycode 0x05 mods 0x00
diff --git a/app/tests/combo/press-timeout/native_posix.keymap b/app/tests/combo/press-timeout/native_posix.keymap
new file mode 100644
index 0000000..ff0b749
--- /dev/null
+++ b/app/tests/combo/press-timeout/native_posix.keymap
@@ -0,0 +1,35 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &none &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,100)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/press1-press2-release1-release2/events.patterns b/app/tests/combo/press1-press2-release1-release2/events.patterns
new file mode 100644
index 0000000..5f3e4cf
--- /dev/null
+++ b/app/tests/combo/press1-press2-release1-release2/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo/combo/p \ No newline at end of file
diff --git a/app/tests/combo/press1-press2-release1-release2/keycode_events.snapshot b/app/tests/combo/press1-press2-release1-release2/keycode_events.snapshot
new file mode 100644
index 0000000..cfa02de
--- /dev/null
+++ b/app/tests/combo/press1-press2-release1-release2/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x07 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x07 mods 0x00
diff --git a/app/tests/combo/press1-press2-release1-release2/native_posix.keymap b/app/tests/combo/press1-press2-release1-release2/native_posix.keymap
new file mode 100644
index 0000000..2518bbc
--- /dev/null
+++ b/app/tests/combo/press1-press2-release1-release2/native_posix.keymap
@@ -0,0 +1,45 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ };
+
+ combo_two {
+ timeout-ms = <30>;
+ key-positions = <2 3>;
+ bindings = <&kp D>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp Z &kp Y
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(1,0,10)
+ ZMK_MOCK_PRESS(1,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(1,0,10)
+ ZMK_MOCK_RELEASE(1,1,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/press1-press2-release2-release1/events.patterns b/app/tests/combo/press1-press2-release2-release1/events.patterns
new file mode 100644
index 0000000..b54b66b
--- /dev/null
+++ b/app/tests/combo/press1-press2-release2-release1/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo/combo/p
diff --git a/app/tests/combo/press1-press2-release2-release1/keycode_events.snapshot b/app/tests/combo/press1-press2-release2-release1/keycode_events.snapshot
new file mode 100644
index 0000000..b55f09b
--- /dev/null
+++ b/app/tests/combo/press1-press2-release2-release1/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x07 mods 0x00
+released: usage_page 0x07 keycode 0x07 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
diff --git a/app/tests/combo/press1-press2-release2-release1/native_posix.keymap b/app/tests/combo/press1-press2-release2-release1/native_posix.keymap
new file mode 100644
index 0000000..4895636
--- /dev/null
+++ b/app/tests/combo/press1-press2-release2-release1/native_posix.keymap
@@ -0,0 +1,46 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ };
+
+ combo_two {
+ timeout-ms = <30>;
+ key-positions = <2 3>;
+ bindings = <&kp D>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp Z &kp Y
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_PRESS(1,0,10)
+ ZMK_MOCK_PRESS(1,1,10)
+
+ ZMK_MOCK_RELEASE(1,0,10)
+ ZMK_MOCK_RELEASE(1,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/press1-release1-press2-release2/events.patterns b/app/tests/combo/press1-release1-press2-release2/events.patterns
new file mode 100644
index 0000000..5f3e4cf
--- /dev/null
+++ b/app/tests/combo/press1-release1-press2-release2/events.patterns
@@ -0,0 +1,2 @@
+s/.*hid_listener_keycode_//p
+s/.*combo/combo/p \ No newline at end of file
diff --git a/app/tests/combo/press1-release1-press2-release2/keycode_events.snapshot b/app/tests/combo/press1-release1-press2-release2/keycode_events.snapshot
new file mode 100644
index 0000000..c41dee8
--- /dev/null
+++ b/app/tests/combo/press1-release1-press2-release2/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x07 mods 0x00
+released: usage_page 0x07 keycode 0x07 mods 0x00
diff --git a/app/tests/combo/press1-release1-press2-release2/native_posix.keymap b/app/tests/combo/press1-release1-press2-release2/native_posix.keymap
new file mode 100644
index 0000000..0c4a698
--- /dev/null
+++ b/app/tests/combo/press1-release1-press2-release2/native_posix.keymap
@@ -0,0 +1,46 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ };
+
+ combo_two {
+ timeout-ms = <30>;
+ key-positions = <2 3>;
+ bindings = <&kp D>;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp Z &kp Y
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+
+ ZMK_MOCK_PRESS(1,0,10)
+ ZMK_MOCK_PRESS(1,1,10)
+ ZMK_MOCK_RELEASE(1,0,10)
+ ZMK_MOCK_RELEASE(1,1,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/slowrelease-disabled/events.patterns b/app/tests/combo/slowrelease-disabled/events.patterns
new file mode 100644
index 0000000..b1342af
--- /dev/null
+++ b/app/tests/combo/slowrelease-disabled/events.patterns
@@ -0,0 +1 @@
+s/.*hid_listener_keycode_//p
diff --git a/app/tests/combo/slowrelease-disabled/keycode_events.snapshot b/app/tests/combo/slowrelease-disabled/keycode_events.snapshot
new file mode 100644
index 0000000..c41dee8
--- /dev/null
+++ b/app/tests/combo/slowrelease-disabled/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x07 mods 0x00
+released: usage_page 0x07 keycode 0x07 mods 0x00
diff --git a/app/tests/combo/slowrelease-disabled/native_posix.keymap b/app/tests/combo/slowrelease-disabled/native_posix.keymap
new file mode 100644
index 0000000..3bacb88
--- /dev/null
+++ b/app/tests/combo/slowrelease-disabled/native_posix.keymap
@@ -0,0 +1,38 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ /* no slow-release! */
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label = "Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp D &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10) /* this should release the combo */
+ ZMK_MOCK_PRESS(1,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(1,0,10)
+ >;
+}; \ No newline at end of file
diff --git a/app/tests/combo/slowrelease-enabled/events.patterns b/app/tests/combo/slowrelease-enabled/events.patterns
new file mode 100644
index 0000000..b1342af
--- /dev/null
+++ b/app/tests/combo/slowrelease-enabled/events.patterns
@@ -0,0 +1 @@
+s/.*hid_listener_keycode_//p
diff --git a/app/tests/combo/slowrelease-enabled/keycode_events.snapshot b/app/tests/combo/slowrelease-enabled/keycode_events.snapshot
new file mode 100644
index 0000000..cfa02de
--- /dev/null
+++ b/app/tests/combo/slowrelease-enabled/keycode_events.snapshot
@@ -0,0 +1,4 @@
+pressed: usage_page 0x07 keycode 0x06 mods 0x00
+pressed: usage_page 0x07 keycode 0x07 mods 0x00
+released: usage_page 0x07 keycode 0x06 mods 0x00
+released: usage_page 0x07 keycode 0x07 mods 0x00
diff --git a/app/tests/combo/slowrelease-enabled/native_posix.keymap b/app/tests/combo/slowrelease-enabled/native_posix.keymap
new file mode 100644
index 0000000..8ac8316
--- /dev/null
+++ b/app/tests/combo/slowrelease-enabled/native_posix.keymap
@@ -0,0 +1,38 @@
+#include <dt-bindings/zmk/keys.h>
+#include <behaviors.dtsi>
+#include <dt-bindings/zmk/kscan-mock.h>
+
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_one {
+ timeout-ms = <30>;
+ key-positions = <0 1>;
+ bindings = <&kp C>;
+ slow-release;
+ };
+ };
+
+ keymap {
+ compatible = "zmk,keymap";
+ label ="Default keymap";
+
+ default_layer {
+ bindings = <
+ &kp A &kp B
+ &kp D &none
+ >;
+ };
+ };
+};
+
+&kscan {
+ events = <
+ ZMK_MOCK_PRESS(0,0,10)
+ ZMK_MOCK_PRESS(0,1,10)
+ ZMK_MOCK_RELEASE(0,0,10) /* this should not release the combo yet */
+ ZMK_MOCK_PRESS(1,0,10)
+ ZMK_MOCK_RELEASE(0,1,10)
+ ZMK_MOCK_RELEASE(1,0,10)
+ >;
+}; \ No newline at end of file
diff --git a/docs/docs/behaviors/combos.md b/docs/docs/behaviors/combos.md
new file mode 100644
index 0000000..e9b0176
--- /dev/null
+++ b/docs/docs/behaviors/combos.md
@@ -0,0 +1,52 @@
+---
+title: Combo Behavior
+sidebar_label: Combos
+---
+
+## Summary
+
+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
+
+Combos are specified like this:
+
+```
+/ {
+ combos {
+ compatible = "zmk,combos";
+ combo_esc {
+ timeout-ms = <50>;
+ key-positions = <0 1>;
+ bindings = <&kp ESC>;
+ };
+ };
+};
+```
+
+- The name of the combo doesn't really matter, but convention is to start the node name with `combo_`.
+- The `compatible` property should always be `"zmk,combos"` for combos.
+- `timeout-ms` is the number of milliseconds that all keys of the combo must be pressed.
+- `key-positions` is an array of key positions. See the info section below about how to figure out the positions on your board.
+- `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.
+
+:::info
+
+Key positions are numbered like the keys in your keymap, starting at 0. So, if the first key in your keymap is `Q`, this key is in position `0`. The next key (possibly `W`) will have position 1, etcetera.
+
+:::
+
+### 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.
+- You are not limited to `&kp` bindings. You can use all ZMK behaviors there, like `&mo`, `&bt`, `&mt`, `&lt` etc.
+
+### Advanced configuration
+
+There are three global combo parameters which are set through KConfig. You can set them in the `<boardname>.conf` file in the same directory as your keymap file.
+
+- `CONFIG_ZMK_COMBO_MAX_PRESSED_COMBOS` is the number of combos that can be active at the same time. Default 4.
+- `CONFIG_ZMK_COMBO_MAX_COMBOS_PER_KEY` is the maximum number of combos that can be active on a key position. Defaults to 5. (So you can have 5 separate combos that use position `3` for example)
+- `CONFIG_ZMK_COMBO_MAX_KEYS_PER_COMBO` is the maximum number of keys that need to be pressed to activate a combo. Default 4. If you want a combo that triggers when pressing 5 keys, you'd set this to 5 for example.
diff --git a/docs/docs/intro.md b/docs/docs/intro.md
index 57670ea..2215291 100644
--- a/docs/docs/intro.md
+++ b/docs/docs/intro.md
@@ -26,7 +26,7 @@ ZMK is currently missing some features found in other popular firmware. This tab
| [Display Support](features/displays)[^2] | 🚧 | 🚧 | ✅ |
| [RGB Underglow](features/underglow) | ✅ | ✅ | ✅ |
| One Shot Keys | ✅ | ✅ | ✅ |
-| [Combo Keys](https://github.com/zmkfirmware/zmk/pull/504) | 🚧 | | ✅ |
+| [Combo Keys](behaviors/combos) | ✅ | | ✅ |
| Macros | 🚧 | ✅ | ✅ |
| Mouse Keys | 💡 | ✅ | ✅ |
| Low Active Power Usage | ✅ | | |
diff --git a/docs/sidebars.js b/docs/sidebars.js
index 8fc1dc5..d095a47 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -20,6 +20,7 @@ module.exports = {
"behaviors/misc",
"behaviors/hold-tap",
"behaviors/mod-tap",
+ "behaviors/combos",
"behaviors/reset",
"behaviors/bluetooth",
"behaviors/outputs",