-
Notifications
You must be signed in to change notification settings - Fork 2
extra: pipe autoshift layer with HRM fold #671
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"}`. | ||
| 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 | ||
|
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), | ||
| }, | ||
|
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), | ||
| ], | ||
| }, | ||
| }, | ||
| } | ||
| 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); | ||
|
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"}, | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh. ... This is one awkward part of the design. And this implementation implicitly assumes the name 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()); | ||
| } | ||
| 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; | ||
|
|
||
| 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); | ||
|
rgoulter marked this conversation as resolved.
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.