Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions ncl/extra/autoshift_layer.ncl
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
let K = import "keys.ncl" in
let keymap_ncl = (import "keymap-ncl-to-json.ncl").keymap_ncl in
let tap_hold_ncl = keymap_ncl.tap_hold in
{
# Transform a single base key into autoshift, folding existing HRM hold into inner.
#
# - `null` stays null (transparent).
# - Existing tap-hold (`{hold, ..tap}`) reuses its `hold` as inner hold.
# - Plain keys become `k & hold (k & LeftShift)` (no HRM creation).
# - Other composites (tap_dance, sticky, etc.) are left as-is — don't double-wrap.
transform_key
| doc m%"
Turn a base key into autoshift, folding HRM if present.

Inner profile must exist: `config.tap_hold.profiles.inner_hold = {timeout=null, interrupt_response="HoldOnKeyPress"}`.
Comment thread
rgoulter marked this conversation as resolved.
Outer uses the default profile (`timeout=200 Ignore` → `a`/`A`).
"%
= fun k =>
k
|> match {
null => null,
{hold = hrm_inner, ..tap_rest} =>
let tap = tap_hold_ncl.strip_profile_meta tap_rest in
Comment thread
rgoulter marked this conversation as resolved.
tap & K.hold (tap & K.LeftShift & K.hold hrm_inner & K.tap_hold_profile "inner_hold"),
_ => k & K.hold (k & K.LeftShift)
},

# Pipe-friendly autoshift for a single layer row.
# `layer |> autoshift` — folds HRM where present, otherwise plain autoshift.
autoshift
| doc m%"
Pipe-friendly: `keys |> autoshift` or `layer |> autoshift`.

Folds any existing `hold` (`K.A & hold K.Ctrl` → `K.A & hold (K.A&LeftShift & hold K.Ctrl & inner)`).
Plain `K.A` → `K.A & hold (K.A&LeftShift)`.
"%
= fun keys => keys |> std.array.map transform_key,

checks | default = {},
checks.autoshift_layer =
let K = import "keys.ncl" in
let inner = K.tap_hold_profile "inner_hold" in
{
check_transform_null_is_null = {
actual = transform_key null,
expected = null,
},
check_transform_plain_is_autoshift = {
actual = transform_key K.A,
expected = K.A & K.hold (K.A & K.LeftShift),
},
Comment thread
Copilot marked this conversation as resolved.
check_transform_existing_hrm_folds = {
actual = transform_key (K.A & K.hold K.LeftAlt),
expected = K.A & K.hold (K.A & K.LeftShift & K.hold K.LeftAlt & inner),
},
check_layer_pipe_is_autoshift = {
actual = [K.A, K.B] |> autoshift,
expected = [K.A & K.hold (K.A & K.LeftShift), K.B & K.hold (K.B & K.LeftShift)],
},
check_layer_existing_hrm_folds = {
actual = [K.A & K.hold K.LeftCtrl, K.B] |> autoshift,
expected = [
K.A & K.hold (K.A & K.LeftShift & K.hold K.LeftCtrl & inner),
K.B & K.hold (K.B & K.LeftShift),
],
},
},
}
156 changes: 156 additions & 0 deletions tests/rust/autoshift_layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
use smart_keymap::input;
use smart_keymap::keymap::ObservedKeymap;

use crate::hid_keycodes::*;
use smart_keymap_macros::keymap;

// Extra autoshift: `layer |> AL.autoshift` folds existing `hold` into inner hold.
// `inner_hold` profile: no timeout, HoldOnKeyPress → `A` on tap, mod on interrupt.

#[test]
fn autoshift_hrm_tap_is_plain() {
// Assemble -- HRM key folded into autoshift, tap should be plain
let mut keymap = ObservedKeymap::new(keymap!(
r#"
let AL = import "extra/autoshift_layer.ncl" in
let K = import "keys.ncl" in
{
config.tap_hold.profiles.inner_hold = {timeout = null, interrupt_response = "HoldOnKeyPress"},
keys = [K.A & K.hold K.LeftAlt] |> AL.autoshift,
}
"#
));

// Act -- tap HRM position
keymap.handle_input(input::Event::Press { keymap_index: 0 });
keymap.handle_input(input::Event::Release { keymap_index: 0 });
keymap.tick_until_no_scheduled_events();

// Assert -- plain 'a'
let expected: &[[u8; 8]] = &[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, KC_A, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
];
assert_eq!(expected, keymap.distinct_reports().reports());
}

#[test]
fn autoshift_hrm_hold_is_shifted() {
// Assemble -- HRM hold is reused as inner hold, outer hold is shifted
let mut keymap = ObservedKeymap::new(keymap!(
r#"
let AL = import "extra/autoshift_layer.ncl" in
let K = import "keys.ncl" in
{
config.tap_hold.profiles.inner_hold = {timeout = null, interrupt_response = "HoldOnKeyPress"},
keys = [K.A & K.hold K.LeftAlt] |> AL.autoshift,
}
"#
));

// Act -- hold past outer timeout, release without interrupt
keymap.handle_input(input::Event::Press { keymap_index: 0 });
for _ in 0..250 {
keymap.tick();
}
keymap.handle_input(input::Event::Release { keymap_index: 0 });
keymap.tick_until_no_scheduled_events();

// Assert -- Shift + A
let expected: &[[u8; 8]] = &[
[0, 0, 0, 0, 0, 0, 0, 0],
[MOD_LSHFT, 0, KC_A, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
];
assert_eq!(expected, keymap.distinct_reports().reports());
}

#[test]
fn autoshift_hrm_interrupt_is_mod() {
// Assemble -- HRM key, interrupt should be the HRM mod
let mut keymap = ObservedKeymap::new(keymap!(
r#"
let AL = import "extra/autoshift_layer.ncl" in
let K = import "keys.ncl" in
{
config.tap_hold.profiles.inner_hold = {timeout = null, interrupt_response = "HoldOnKeyPress"},
keys = [K.A & K.hold K.LeftAlt, K.B] |> AL.autoshift,
}
"#
));

// Act -- press HRM, interrupt with B
keymap.handle_input(input::Event::Press { keymap_index: 0 });
keymap.handle_input(input::Event::Press { keymap_index: 1 });
for _ in 0..250 {
keymap.tick();
}
keymap.tick_until_no_scheduled_events();

// Assert -- Alt held (from HRM), regardless of Shift on B
let reports = keymap.distinct_reports().reports().to_vec();
let has_alt = reports.iter().any(|r| r[0] & MOD_LALT != 0);
let has_ctrl = reports.iter().any(|r| r[0] & MOD_LCTL != 0);
assert!(has_alt, "expected reused Alt, got {:02X?}", reports);
assert!(!has_ctrl, "should not have Ctrl, got {:02X?}", reports);
Comment thread
rgoulter marked this conversation as resolved.
}

#[test]
fn autoshift_plain_tap_is_plain() {
// Assemble -- plain key autoshifted, tap should be plain
let mut keymap = ObservedKeymap::new(keymap!(
r#"
let AL = import "extra/autoshift_layer.ncl" in
let K = import "keys.ncl" in
{
config.tap_hold.profiles.inner_hold = {timeout = null, interrupt_response = "HoldOnKeyPress"},
keys = [K.A] |> AL.autoshift,
}
"#
));

// Act -- tap plain
keymap.handle_input(input::Event::Press { keymap_index: 0 });
keymap.handle_input(input::Event::Release { keymap_index: 0 });
keymap.tick_until_no_scheduled_events();

// Assert -- plain 'a'
let expected: &[[u8; 8]] = &[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, KC_A, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
];
assert_eq!(expected, keymap.distinct_reports().reports());
}

#[test]
fn autoshift_plain_hold_is_shifted() {
// Assemble -- plain autoshift, hold should be shifted
let mut keymap = ObservedKeymap::new(keymap!(
r#"
let AL = import "extra/autoshift_layer.ncl" in
let K = import "keys.ncl" in
{
config.tap_hold.profiles.inner_hold = {timeout = null, interrupt_response = "HoldOnKeyPress"},

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh. ...
Oops.

This is one awkward part of the design.
It assumes inner-hold has "HoldOnKeyPress" interrupt response and no key press.

And this implementation implicitly assumes the name inner_hold huh?

That's enough for me to think this PR is still 'draft', or may be 'extra' rather than 'merge now'.

keys = [K.A] |> AL.autoshift,
}
"#
));

// Act -- hold past outer 200ms
keymap.handle_input(input::Event::Press { keymap_index: 0 });
for _ in 0..250 {
keymap.tick();
}
keymap.handle_input(input::Event::Release { keymap_index: 0 });
keymap.tick_until_no_scheduled_events();

// Assert -- Shift + A
let expected: &[[u8; 8]] = &[
[0, 0, 0, 0, 0, 0, 0, 0],
[MOD_LSHFT, 0, KC_A, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
];
assert_eq!(expected, keymap.distinct_reports().reports());
}
1 change: 1 addition & 0 deletions tests/rust/keymap.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod automation;
mod autoshift_layer;
mod caps_word;
mod chorded;
mod consumer;
Expand Down
1 change: 1 addition & 0 deletions tests/rust/tap_hold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod hold_on_interrupt_tap;
mod hold_trigger_positions;
mod interrupt_ignore;
mod layered;
mod nested_hold;
mod no_timeout;
mod profiles;
mod quick_tap;
Expand Down
155 changes: 155 additions & 0 deletions tests/rust/tap_hold/nested_hold.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
use smart_keymap::input;
use smart_keymap::keymap::ObservedKeymap;

use crate::hid_keycodes::*;
use smart_keymap_macros::keymap;

// Outer tap = 'a' (KC_A) with timeout-only resolution.
// Inner is a second tap-hold: tap = 'A' (Shift+KC_A), hold = LeftAlt.
// Inner uses a profile with no timeout and HoldOnKeyPress.
//
// Nickel for outer hold (inner):
// K.A & K.LeftShift & K.hold K.LeftAlt & K.tap_hold_profile "inner_hold"
//
// Config:
// config.tap_hold.timeout = 200, interrupt_response = "Ignore" (outer)
// config.tap_hold.profiles.inner_hold = { timeout = null, interrupt_response = "HoldOnKeyPress" }

macro_rules! nested_keymap {
() => {
ObservedKeymap::new(keymap!(
r#"
let K = import "keys.ncl" in
{
config.tap_hold = {
timeout = 200,
interrupt_response = "Ignore",
profiles = {
inner_hold = {
timeout = null,
interrupt_response = "HoldOnKeyPress",
},
},
},
keys = [
K.A & K.hold (K.A & K.LeftShift & K.hold K.LeftAlt & K.tap_hold_profile "inner_hold"),
K.B,
],
}
"#
))
};
}

#[test]
fn nested_tap_hold_outer_tap_is_plain_a() {
// Assemble -- outer 'a', nested hold is another tap-hold (inner profile)
let mut keymap = nested_keymap!();

// Act -- quick tap of outer (press+release before timeout) → plain 'a'
keymap.handle_input(input::Event::Press { keymap_index: 0 });
keymap.handle_input(input::Event::Release { keymap_index: 0 });
keymap.tick_until_no_scheduled_events();

// Assert -- reports a plain 'a' tap, not shifted, not Alt
let expected_reports: &[[u8; 8]] = &[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, KC_A, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
];
let actual_reports = keymap.distinct_reports();
assert_eq!(expected_reports, actual_reports.reports());
}

#[test]
fn nested_tap_hold_outer_hold_via_timeout_gives_inner_tap_shifted_a() {
// Assemble
let mut keymap = nested_keymap!();

// Act -- hold outer past its 200ms timeout, then release.
// Outer timeout resolves outer → inner pending (profile: no timeout, HoldOnKeyPress).
// Inner stays pending (no timeout). Releasing without interrupting key resolves inner as tap → Shift+A.
keymap.handle_input(input::Event::Press { keymap_index: 0 });
for _ in 0..250 {
keymap.tick();
}
// After outer timeout, Alt should NOT yet be held; inner is pending.
// Release outer.
keymap.handle_input(input::Event::Release { keymap_index: 0 });
keymap.tick_until_no_scheduled_events();

// Assert -- shifted 'A' (LeftShift + KC_A)
let expected_reports: &[[u8; 8]] = &[
[0, 0, 0, 0, 0, 0, 0, 0],
[MOD_LSHFT, 0, KC_A, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
];
let actual_reports = keymap.distinct_reports();
assert_eq!(expected_reports, actual_reports.reports());
}

#[test]
fn nested_tap_hold_outer_hold_via_interrupt_then_inner_hold_gives_alt() {
// Assemble -- HoldOnKeyPress on inner, Ignore on outer.
// Press outer, then press B before outer timeout.
// Outer (Ignore) stays pending until timeout, then replays B press into inner.
// Inner (HoldOnKeyPress, no timeout) sees B press → resolves as hold → Alt.
let mut keymap = nested_keymap!();

// Act -- press outer, quickly press B (interrupt), then wait for outer timeout
keymap.handle_input(input::Event::Press { keymap_index: 0 });
keymap.handle_input(input::Event::Press { keymap_index: 1 });
// Outer is Ignore, so both outer and inner remain pending until timeout.
// Tick past outer timeout (200ms) — this should resolve outer→inner, then inner→hold (Alt) via replayed B press.
for _ in 0..250 {
keymap.tick();
}
keymap.tick_until_no_scheduled_events();

// Assert -- Alt is held while B is pressed
let reports = keymap.distinct_reports().reports().to_vec();
let has_alt = reports.iter().any(|r| r[0] & MOD_LALT != 0);
let has_alt_b = reports
.iter()
.any(|r| r[0] & MOD_LALT != 0 && r.contains(&KC_B));
assert!(
has_alt,
"expected Alt held after nested interrupt, reports: {:02X?}",
reports
);
assert!(
has_alt_b,
"expected Alt+B chord after nested interrupt, reports: {:02X?}",
reports
);
}

#[test]
fn nested_tap_hold_outer_hold_timeout_then_inner_interrupt_gives_alt() {
// Assemble
let mut keymap = nested_keymap!();

// Act -- hold outer past timeout (250 ticks), then press B.
// Outer → inner pending (no timeout). Inner pending + B press → Alt.
keymap.handle_input(input::Event::Press { keymap_index: 0 });
for _ in 0..250 {
keymap.tick();
}
// Outer has timed out, inner is now pending (still no Alt yet). Confirm not yet Alt+B.
// Now interrupt inner with B.
keymap.handle_input(input::Event::Press { keymap_index: 1 });
keymap.tick_until_no_scheduled_events();

// Assert -- Alt held with B
let reports = keymap.distinct_reports().reports().to_vec();
let has_alt = reports.iter().any(|r| r[0] == MOD_LALT);
let has_alt_b = reports
.iter()
.any(|r| r[0] == MOD_LALT && r.contains(&KC_B));
assert!(
has_alt,
"expected Alt after inner interrupt, reports: {:02X?}",
reports
);
assert!(has_alt_b, "expected Alt+B, reports: {:02X?}", reports);
Comment thread
rgoulter marked this conversation as resolved.
}
Loading