From 371a9a2823046da101169db8294e5e5155b19eb7 Mon Sep 17 00:00:00 2001 From: Richard Goulter Date: Wed, 26 Aug 2026 20:50:24 +0700 Subject: [PATCH 1/6] core: add System::pending_output hook Add a defaulted trait method System::pending_output(&PKS) -> Option so Keymap's HID fold can include speculative output without special-casing tap-hold in keymap.rs. The fold now includes pending_output in pressed_keys and aggregate_pressed_modifiers while a pending session is live, using expression style without early return and iterator collection. This is the generic seam for tap-hold Hold-while-undecided; other families keep the default None (no output while pending). Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark Co-Authored-By: muse-spark-1.2 --- smart-keymap-core/src/key.rs | 7 +++++ smart-keymap-core/src/keymap.rs | 55 +++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/smart-keymap-core/src/key.rs b/smart-keymap-core/src/key.rs index e37ab4ed..828890e0 100644 --- a/smart-keymap-core/src/key.rs +++ b/smart-keymap-core/src/key.rs @@ -287,6 +287,13 @@ pub trait System: Debug { fn key_output(&self, _ref: &Self::Ref, _key_state: &Self::KeyState) -> Option { None } + + /// Speculative HID while a pending session is live. + /// + /// Default: `NoOutput` (no output while pending). + fn pending_output(&self, _pending_key_state: &Self::PendingKeyState) -> Option { + None + } } /// Used to provide state that may affect behaviour when pressing the key. diff --git a/smart-keymap-core/src/keymap.rs b/smart-keymap-core/src/keymap.rs index b6069589..87da2a29 100644 --- a/smart-keymap-core/src/keymap.rs +++ b/smart-keymap-core/src/keymap.rs @@ -869,8 +869,12 @@ impl< } /// Aggregate keyboard modifiers from already-pressed inputs (no suppress). + /// + /// Includes speculative [`key::System::pending_output`] while a pending + /// session is live so the next key sees the mod (Ctrl+mouse / roll). fn aggregate_pressed_modifiers(&self) -> key::KeyboardModifiers { - self.pressed_inputs + let base = self + .pressed_inputs .iter() .filter_map(|pi| match pi { input::PressedInput::Key(input::PressedKey { @@ -880,7 +884,13 @@ impl< }) .fold(key::KeyboardModifiers::NONE, |acc, ko| { acc.union(&ko.key_modifiers()) - }) + }); + let pending_mod = self + .pending_state + .as_ref() + .and_then(|pending| self.key_system.pending_output(&pending.pending_key_state)) + .map_or(key::KeyboardModifiers::NONE, |ko| ko.key_modifiers()); + base.union(&pending_mod) } fn push_keymap_context(&mut self) { @@ -926,23 +936,30 @@ impl< /// Returns the the pressed key outputs. pub fn pressed_keys(&self) -> heapless::Vec { let suppress = self.context.suppressed_modifiers(); - let pressed_key_codes = self.pressed_inputs.iter().filter_map(|pi| { - let ko = match pi { - input::PressedInput::Key(input::PressedKey { - key_ref, key_state, .. - }) => self.key_system.key_output(key_ref, key_state)?, - &input::PressedInput::Virtual(key_output) => key_output, - }; - let ko = ko.without_modifiers(suppress); - // Drop pure-mod outputs that are fully suppressed. - if ko == key::KeyOutput::NO_OUTPUT { - None - } else { - Some(ko) - } - }); - - pressed_key_codes.collect() + let mut outputs: heapless::Vec = self + .pressed_inputs + .iter() + .filter_map(|pi| { + let ko = match pi { + input::PressedInput::Key(input::PressedKey { + key_ref, key_state, .. + }) => self.key_system.key_output(key_ref, key_state)?, + &input::PressedInput::Virtual(key_output) => key_output, + }; + let ko = ko.without_modifiers(suppress); + (ko != key::KeyOutput::NO_OUTPUT).then_some(ko) + }) + .collect(); + if let Some(ko) = self + .pending_state + .as_ref() + .and_then(|pending| self.key_system.pending_output(&pending.pending_key_state)) + .map(|ko| ko.without_modifiers(suppress)) + .filter(|ko| *ko != key::KeyOutput::NO_OUTPUT) + { + let _ = outputs.push(ko); + } + outputs } fn tick_by(&mut self, delta_ms: u32) { From bd6b2a2bf7fa9fcc7d23a2c1d7e01ab992100b59 Mon Sep 17 00:00:00 2001 From: Richard Goulter Date: Wed, 26 Aug 2026 20:51:51 +0700 Subject: [PATCH 2/6] ncl: tap-hold pending_output authoring and codegen Add pending_output (NoOutput|Hold) to tap-hold Profile/Config, lower flat config.tap_hold.pending_output to default_profile, and generate composite System::pending_output that delegates to PendingKeyState::speculative_output (keyboard only, GUI filtered in core). Update families to use PendingKeyState. Validator accepts both "NoOutput" and "None" (alias) for backwards compat. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark Co-Authored-By: muse-spark-1.2 --- ncl/key_system/families.ncl | 2 +- ncl/key_system/keymap-codegen.ncl | 28 +++++++++++++++++++ ncl/keymap-ncl-to-json.ncl | 6 ++++ ncl/smart_keys/tap_hold/keymap-codegen.ncl | 22 +++++++++++++++ .../tap_hold/keymap-ncl-to-json.ncl | 11 ++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/ncl/key_system/families.ncl b/ncl/key_system/families.ncl index 7717e9db..c9f50211 100644 --- a/ncl/key_system/families.ncl +++ b/ncl/key_system/families.ncl @@ -734,7 +734,7 @@ }, pending = 'PendingKeyState { - ty = "%{module}::PendingKeyState", + ty = "%{module}::PendingKeyState", }, keymap_context = 'UpdatesKeymapContext, system = diff --git a/ncl/key_system/keymap-codegen.ncl b/ncl/key_system/keymap-codegen.ncl index 876dfb68..6fe0064f 100644 --- a/ncl/key_system/keymap-codegen.ncl +++ b/ncl/key_system/keymap-codegen.ncl @@ -575,6 +575,25 @@ Ref::%{f.variant}(key_ref) => { ) ++ "\n(_, _) => None,", + pending_output_arms = + if pending_systems == [] then + "_ => None," + else + ( + pending_systems + |> std.array.map (fun f => + if f.variant == "TapHold" then + m%"PendingKeyState::%{f.variant}(pks) => pks.speculative_output(|spec| match spec { + Ref::Keyboard(kb_ref) => self.keyboard.key_output(kb_ref, &smart_keymap::key::keyboard::KeyState), + _ => None, + }),"% + else + m%"PendingKeyState::%{f.variant}(_) => None,"% + ) + |> join + ) + ++ "\n_ => None,", + key_state_from_family = systems |> std.array.map (fun f => @@ -861,6 +880,15 @@ pub mod key_system { %{key_output_arms} } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { +%{pending_output_arms} + } + } } } "%, diff --git a/ncl/keymap-ncl-to-json.ncl b/ncl/keymap-ncl-to-json.ncl index d725f5c5..400343cd 100644 --- a/ncl/keymap-ncl-to-json.ncl +++ b/ncl/keymap-ncl-to-json.ncl @@ -699,6 +699,12 @@ else {} ) + & ( + if std.record.has_field "pending_output" th_config then + { pending_output = th_config.pending_output } + else + {} + ) in let profiles_array = th_profile_names diff --git a/ncl/smart_keys/tap_hold/keymap-codegen.ncl b/ncl/smart_keys/tap_hold/keymap-codegen.ncl index 2414f471..6a8bb818 100644 --- a/ncl/smart_keys/tap_hold/keymap-codegen.ncl +++ b/ncl/smart_keys/tap_hold/keymap-codegen.ncl @@ -102,6 +102,15 @@ ] ), + TapHoldPendingOutputJson = + std.contract.from_validator ( + validators.is_elem_of [ + "NoOutput", + "Hold", + "None", + ] + ), + module = "smart_keymap::key::tap_hold", hold_trigger_positions_expr = fun positions => @@ -156,6 +165,18 @@ } else {} + ) + & ( + if std.record.has_field "pending_output" c then + { + pending_output = + if c.pending_output == "None" then + "%{module}::PendingOutput::NoOutput" + else + "%{module}::PendingOutput::%{c.pending_output}", + } + else + {} ), profile_rust_expr = fun c => @@ -297,6 +318,7 @@ required_idle_time | optional | Number, hold_trigger_key_positions | optional | Array Number, quick_tap_ms | optional | Number, + pending_output | optional | TapHoldPendingOutputJson, }, # Lowered JSON form: nested default_profile + profiles array. diff --git a/ncl/smart_keys/tap_hold/keymap-ncl-to-json.ncl b/ncl/smart_keys/tap_hold/keymap-ncl-to-json.ncl index fc365ef0..e06fadaa 100644 --- a/ncl/smart_keys/tap_hold/keymap-ncl-to-json.ncl +++ b/ncl/smart_keys/tap_hold/keymap-ncl-to-json.ncl @@ -63,6 +63,15 @@ ] ), + TapHoldPendingOutput = + std.contract.from_validator ( + validators.is_elem_of [ + "NoOutput", + "Hold", + "None", + ] + ), + # One behavior profile. Profile 0 is Config's default (flat on config.tap_hold); # extras live under config.tap_hold.profiles. Profile = { @@ -80,6 +89,7 @@ hold_trigger_key_positions | optional | Array Number, # Re-press of same key within this many ms forces tap (ZMK quick-tap-ms). quick_tap_ms | optional | Number, + pending_output | optional | TapHoldPendingOutput, }, # Authoring keeps default-profile knobs flat on config.tap_hold; @@ -99,6 +109,7 @@ hold_trigger_key_positions | optional | Array Number, # Re-press of same key within this many ms forces tap (ZMK quick-tap-ms). quick_tap_ms | optional | Number, + pending_output | optional | TapHoldPendingOutput, # Authoring: name → profile record. Lowered to a JSON array (indices 1..). profiles | optional | { _ | Profile }, }, From 962600221b21c08e7a3582fd1379d33178764c3e Mon Sep 17 00:00:00 2001 From: Richard Goulter Date: Wed, 26 Aug 2026 20:52:09 +0700 Subject: [PATCH 3/6] core: tap-hold pending_output profile and speculative state Add PendingOutput (NoOutput|Hold) to Profile with serde alias for "None", extend PendingKeyState with speculative: Option, wire new_pending_key to store the hold ref when pending_output is Hold, and add PendingKeyState::speculative_output helper that resolves the speculative ref and filters GUI modifiers in core (used by generated composite pending_output). Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark Co-Authored-By: muse-spark-1.2 --- smart-keymap-core/src/key/tap_hold.rs | 140 ++++++++++++++++++++------ 1 file changed, 109 insertions(+), 31 deletions(-) diff --git a/smart-keymap-core/src/key/tap_hold.rs b/smart-keymap-core/src/key/tap_hold.rs index 1d5b111e..5c37b866 100644 --- a/smart-keymap-core/src/key/tap_hold.rs +++ b/smart-keymap-core/src/key/tap_hold.rs @@ -76,6 +76,15 @@ pub struct Profile { /// scoped to re-presses of the same keymap index. #[serde(default)] pub quick_tap_ms: Option, + + /// Speculative output while this tap-hold is pending. + /// + /// - `NoOutput` (default): silent until tap vs hold settles. + /// - `Hold`: emit the hold binding's HID while still pending + /// (ZMK `hold-while-undecided` / QMK Speculative Hold / + /// FAK `eager_decision = 'hold`). + #[serde(default = "default_pending_output")] + pub pending_output: PendingOutput, } impl Profile { @@ -152,6 +161,16 @@ impl Default for Key { } } +/// Speculative output while tap-hold is pending. +#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +pub enum PendingOutput { + /// No speculative output (default). + #[serde(alias = "None")] + NoOutput, + /// Emit hold binding while still pending. + Hold, +} + /// How the tap hold key should respond to interruptions (input events from other keys). #[derive(Deserialize, Debug, Clone, Copy, PartialEq)] pub enum InterruptResponse { @@ -191,6 +210,9 @@ pub const DEFAULT_TIMEOUT: u16 = 200; /// The default interrupt response. pub const DEFAULT_INTERRUPT_RESPONSE: InterruptResponse = InterruptResponse::Ignore; +/// The default pending output. +pub const DEFAULT_PENDING_OUTPUT: PendingOutput = PendingOutput::NoOutput; + fn default_timeout() -> Option { Some(DEFAULT_TIMEOUT) } @@ -199,6 +221,10 @@ fn default_interrupt_response() -> InterruptResponse { DEFAULT_INTERRUPT_RESPONSE } +fn default_pending_output() -> PendingOutput { + DEFAULT_PENDING_OUTPUT +} + fn default_profile_value() -> Profile { DEFAULT_PROFILE } @@ -210,6 +236,7 @@ pub const DEFAULT_PROFILE: Profile = Profile { required_idle_time: None, hold_trigger_positions: None, quick_tap_ms: None, + pending_output: DEFAULT_PENDING_OUTPUT, }; /// Default tap hold config. @@ -337,16 +364,47 @@ pub enum Event { /// The state of a pressed tap-hold key. #[derive(Debug, Clone, PartialEq)] -pub struct PendingKeyState { +pub struct PendingKeyState { // For tracking 'tap' interruptions other_pressed_keymap_index: Option, + /// Speculative hold ref while undecided (`pending_output = Hold`). + pub speculative: Option, } -impl PendingKeyState { +impl PendingKeyState { /// Constructs the initial pressed key state - fn new() -> PendingKeyState { + #[allow(dead_code)] + fn new() -> PendingKeyState { PendingKeyState { other_pressed_keymap_index: None, + speculative: None, + } + } + + /// Constructs with a speculative hold ref. + fn new_with_speculative(speculative: Option) -> Self { + Self { + other_pressed_keymap_index: None, + speculative, + } + } + + /// Returns speculative hold output, filtered to avoid GUI host effects. + /// + /// Resolves the speculative `R` via `resolve` and suppresses GUI modifiers. + pub fn speculative_output( + &self, + resolve: impl Fn(&R) -> Option, + ) -> Option { + let spec = self.speculative.as_ref()?; + let ko = resolve(spec)?; + let mods = ko.key_modifiers(); + if mods.has_modifiers(&key::KeyboardModifiers::LEFT_GUI) + || mods.has_modifiers(&key::KeyboardModifiers::RIGHT_GUI) + { + None + } else { + Some(ko) } } @@ -474,8 +532,16 @@ impl>> System { &self, profile: &Profile, keymap_index: u16, - ) -> (PendingKeyState, Option>) { - let pending = PendingKeyState::new(); + hold: R, + ) -> (PendingKeyState, Option>) + where + R: Copy, + { + let speculative = match profile.pending_output { + PendingOutput::Hold => Some(hold), + PendingOutput::NoOutput => None, + }; + let pending = PendingKeyState::new_with_speculative(speculative); let scheduled = profile.timeout.map(|timeout| { key::ScheduledEvent::after( timeout, @@ -489,7 +555,7 @@ impl>> System { &self, key_index: u8, ) -> ( - key::PressedKeyResult, + key::PressedKeyResult, KeyState>, key::KeyEvents, ) where @@ -511,7 +577,7 @@ impl>> key::System; type KeyState = KeyState; fn new_pressed_key( @@ -535,7 +601,8 @@ impl>> key::System { if context.idle_time_ms >= required_idle_time as u32 { // Keymap has been idle long enough; use pending tap-hold key state. - let (th_pks, maybe_sch_ev) = self.new_pending_key(&profile, keymap_index); + let (th_pks, maybe_sch_ev) = + self.new_pending_key(&profile, keymap_index, key_def.hold); let pk = key::PressedKeyResult::Pending(th_pks); let pke = match maybe_sch_ev { Some(sch_ev) => { @@ -552,7 +619,8 @@ impl>> key::System { // Idle time not considered. Use pending tap-hold key state. - let (th_pks, maybe_sch_ev) = self.new_pending_key(&profile, keymap_index); + let (th_pks, maybe_sch_ev) = + self.new_pending_key(&profile, keymap_index, key_def.hold); let pk = key::PressedKeyResult::Pending(th_pks); let pke = match maybe_sch_ev { Some(sch_ev) => key::KeyEvents::scheduled_event(sch_ev.into_scheduled_event()), @@ -642,6 +710,7 @@ mod tests { required_idle_time, hold_trigger_positions: None, quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[]), } @@ -685,7 +754,7 @@ mod tests { InterruptResponse::Ignore, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, release(KEYMAP_INDEX)); @@ -702,7 +771,7 @@ mod tests { InterruptResponse::Ignore, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = @@ -720,7 +789,7 @@ mod tests { InterruptResponse::Ignore, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(OTHER_INDEX)); @@ -737,7 +806,7 @@ mod tests { InterruptResponse::Ignore, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, release(OTHER_INDEX)); @@ -756,7 +825,7 @@ mod tests { InterruptResponse::HoldOnKeyPress, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(OTHER_INDEX)); @@ -773,7 +842,7 @@ mod tests { InterruptResponse::HoldOnKeyPress, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, release(KEYMAP_INDEX)); @@ -790,7 +859,7 @@ mod tests { InterruptResponse::HoldOnKeyPress, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = @@ -808,7 +877,7 @@ mod tests { InterruptResponse::HoldOnKeyPress, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, release(OTHER_INDEX)); @@ -827,7 +896,7 @@ mod tests { InterruptResponse::HoldOnKeyTap, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, release(KEYMAP_INDEX)); @@ -844,7 +913,7 @@ mod tests { InterruptResponse::HoldOnKeyTap, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = @@ -862,7 +931,7 @@ mod tests { InterruptResponse::HoldOnKeyTap, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(OTHER_INDEX)); @@ -879,7 +948,7 @@ mod tests { InterruptResponse::HoldOnKeyTap, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); let _ = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(OTHER_INDEX)); // Act @@ -897,7 +966,7 @@ mod tests { InterruptResponse::HoldOnKeyTap, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, release(OTHER_INDEX)); @@ -914,7 +983,7 @@ mod tests { InterruptResponse::HoldOnKeyTap, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); let _ = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(OTHER_INDEX)); // Act @@ -1068,7 +1137,7 @@ mod tests { // Assemble let system = system(); let ctx = default_context(); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let (resolved, _) = system.update_pending_state( @@ -1088,7 +1157,7 @@ mod tests { // Assemble let system = system(); let ctx = default_context(); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let (resolved, _) = system.update_pending_state( @@ -1112,7 +1181,7 @@ mod tests { InterruptResponse::HoldOnKeyPress, None, )); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let (resolved, _) = @@ -1127,7 +1196,7 @@ mod tests { // Assemble let system = system(); let ctx = default_context(); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let (resolved, _) = @@ -1142,7 +1211,7 @@ mod tests { // Assemble let system = system(); let ctx = default_context(); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act let (_, events) = system.update_pending_state( @@ -1214,6 +1283,7 @@ mod tests { required_idle_time: Some(10), hold_trigger_positions: None, quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[]), }; @@ -1237,6 +1307,7 @@ mod tests { required_idle_time: None, hold_trigger_positions: None, quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }; let config = Config { default_profile: Profile { @@ -1245,6 +1316,7 @@ mod tests { required_idle_time: None, hold_trigger_positions: None, quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[extra]), }; @@ -1263,6 +1335,7 @@ mod tests { required_idle_time: None, hold_trigger_positions: None, quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[]), }; @@ -1337,10 +1410,11 @@ mod tests { required_idle_time: None, hold_trigger_positions: Some(Slice::from_slice(&[2])), quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[]), }); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act: interrupt from a non-trigger position (OTHER_INDEX = 1). let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(OTHER_INDEX)); @@ -1359,10 +1433,11 @@ mod tests { required_idle_time: None, hold_trigger_positions: Some(Slice::from_slice(&[2])), quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[]), }); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); // Act: interrupt from trigger position 2. let resolution = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(2)); @@ -1381,10 +1456,11 @@ mod tests { required_idle_time: None, hold_trigger_positions: Some(Slice::from_slice(&[2])), quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[]), }); - let mut pks = PendingKeyState::new(); + let mut pks = PendingKeyState::::new(); let _ = pks.handle_event(&ctx.profile(0), KEYMAP_INDEX, press(OTHER_INDEX)); // Act: complete the non-trigger key's tap (release). @@ -1405,6 +1481,7 @@ mod tests { required_idle_time: None, hold_trigger_positions: Some(triggers), quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }, profiles: Slice::from_slice(&[]), }; @@ -1426,6 +1503,7 @@ mod tests { required_idle_time: None, hold_trigger_positions: Some(triggers), quick_tap_ms: None, + pending_output: PendingOutput::NoOutput, }; let config = Config { default_profile: Profile::new(), From c2fbcfb73938a39d7fee2dfa64de52a0c1239d2a Mon Sep 17 00:00:00 2001 From: Richard Goulter Date: Wed, 26 Aug 2026 20:52:31 +0700 Subject: [PATCH 4/6] test: refresh expected.rs snapshots for PendingKeyState Generated via ncl/scripts/save-test-snapshots.sh after making PendingKeyState generic, adding System::pending_output, and moving GUI filter to PendingKeyState::speculative_output. Snapshots now use pks.speculative_output and NoOutput. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark Co-Authored-By: muse-spark-1.2 --- .../keymap-1key-2layer-th-lmod/expected.rs | 23 ++++++++++++++--- tests/ncl/keymap-1key-abbrev-ent/expected.rs | 9 +++++++ tests/ncl/keymap-1key-automation/expected.rs | 9 +++++++ .../keymap-1key-callback-custom/expected.rs | 9 +++++++ tests/ncl/keymap-1key-custom/expected.rs | 9 +++++++ tests/ncl/keymap-1key-simple/expected.rs | 9 +++++++ tests/ncl/keymap-1key-tap_dance/expected.rs | 10 ++++++++ tests/ncl/keymap-1key-tap_hold/expected.rs | 23 ++++++++++++++--- .../keymap-2key-2layer-composite/expected.rs | 23 ++++++++++++++--- .../expected.rs | 9 +++++++ .../ncl/keymap-2key-2layer-named/expected.rs | 9 +++++++ .../ncl/keymap-2key-2layer-simple/expected.rs | 9 +++++++ .../expected.rs | 24 +++++++++++++++--- tests/ncl/keymap-2key-chorded/expected.rs | 10 ++++++++ tests/ncl/keymap-34key-seniply/expected.rs | 9 +++++++ tests/ncl/keymap-48key-basic/expected.rs | 9 +++++++ tests/ncl/keymap-48key-rgoulter/expected.rs | 25 ++++++++++++++++--- .../expected.rs | 23 ++++++++++++++--- .../keymap-60key-dvorak-simple/expected.rs | 9 +++++++ 19 files changed, 236 insertions(+), 24 deletions(-) diff --git a/tests/ncl/keymap-1key-2layer-th-lmod/expected.rs b/tests/ncl/keymap-1key-2layer-th-lmod/expected.rs index 765078a3..2729c373 100644 --- a/tests/ncl/keymap-1key-2layer-th-lmod/expected.rs +++ b/tests/ncl/keymap-1key-2layer-th-lmod/expected.rs @@ -211,7 +211,7 @@ pub mod init { /// [smart_keymap::key::layered] variant. Layered(smart_keymap::key::layered::PendingKeyState), /// [smart_keymap::key::tap_hold] variant. - TapHold(smart_keymap::key::tap_hold::PendingKeyState), + TapHold(smart_keymap::key::tap_hold::PendingKeyState), } impl From for PendingKeyState { @@ -224,14 +224,14 @@ pub mod init { PendingKeyState::Layered(pks) } } - impl From for PendingKeyState { - fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { + impl From> for PendingKeyState { + fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { PendingKeyState::TapHold(pks) } } #[allow(unreachable_patterns)] impl<'pks> TryFrom<&'pks mut PendingKeyState> - for &'pks mut smart_keymap::key::tap_hold::PendingKeyState + for &'pks mut smart_keymap::key::tap_hold::PendingKeyState { type Error = (); fn try_from(pks: &'pks mut PendingKeyState) -> Result { @@ -449,6 +449,21 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::TapHold(pks) => pks.speculative_output(|spec| match spec { + Ref::Keyboard(kb_ref) => self + .keyboard + .key_output(kb_ref, &smart_keymap::key::keyboard::KeyState), + _ => None, + }), + _ => None, + } + } } } diff --git a/tests/ncl/keymap-1key-abbrev-ent/expected.rs b/tests/ncl/keymap-1key-abbrev-ent/expected.rs index 78679571..148b17df 100644 --- a/tests/ncl/keymap-1key-abbrev-ent/expected.rs +++ b/tests/ncl/keymap-1key-abbrev-ent/expected.rs @@ -276,6 +276,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-1key-automation/expected.rs b/tests/ncl/keymap-1key-automation/expected.rs index 8187a7d9..7f7ada6c 100644 --- a/tests/ncl/keymap-1key-automation/expected.rs +++ b/tests/ncl/keymap-1key-automation/expected.rs @@ -290,6 +290,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-1key-callback-custom/expected.rs b/tests/ncl/keymap-1key-callback-custom/expected.rs index df2e8a97..92e9d88c 100644 --- a/tests/ncl/keymap-1key-callback-custom/expected.rs +++ b/tests/ncl/keymap-1key-callback-custom/expected.rs @@ -260,6 +260,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-1key-custom/expected.rs b/tests/ncl/keymap-1key-custom/expected.rs index de40e199..f738d94d 100644 --- a/tests/ncl/keymap-1key-custom/expected.rs +++ b/tests/ncl/keymap-1key-custom/expected.rs @@ -253,6 +253,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-1key-simple/expected.rs b/tests/ncl/keymap-1key-simple/expected.rs index 7ce71ede..c398e7b0 100644 --- a/tests/ncl/keymap-1key-simple/expected.rs +++ b/tests/ncl/keymap-1key-simple/expected.rs @@ -276,6 +276,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-1key-tap_dance/expected.rs b/tests/ncl/keymap-1key-tap_dance/expected.rs index a084b61a..086fe22e 100644 --- a/tests/ncl/keymap-1key-tap_dance/expected.rs +++ b/tests/ncl/keymap-1key-tap_dance/expected.rs @@ -367,6 +367,16 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::TapDance(_) => None, + _ => None, + } + } } } diff --git a/tests/ncl/keymap-1key-tap_hold/expected.rs b/tests/ncl/keymap-1key-tap_hold/expected.rs index f2d550de..848e585b 100644 --- a/tests/ncl/keymap-1key-tap_hold/expected.rs +++ b/tests/ncl/keymap-1key-tap_hold/expected.rs @@ -177,7 +177,7 @@ pub mod init { /// [smart_keymap::key::keyboard] variant. Keyboard(smart_keymap::key::keyboard::PendingKeyState), /// [smart_keymap::key::tap_hold] variant. - TapHold(smart_keymap::key::tap_hold::PendingKeyState), + TapHold(smart_keymap::key::tap_hold::PendingKeyState), } impl From for PendingKeyState { @@ -185,14 +185,14 @@ pub mod init { PendingKeyState::Keyboard(pks) } } - impl From for PendingKeyState { - fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { + impl From> for PendingKeyState { + fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { PendingKeyState::TapHold(pks) } } #[allow(unreachable_patterns)] impl<'pks> TryFrom<&'pks mut PendingKeyState> - for &'pks mut smart_keymap::key::tap_hold::PendingKeyState + for &'pks mut smart_keymap::key::tap_hold::PendingKeyState { type Error = (); fn try_from(pks: &'pks mut PendingKeyState) -> Result { @@ -359,6 +359,21 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::TapHold(pks) => pks.speculative_output(|spec| match spec { + Ref::Keyboard(kb_ref) => self + .keyboard + .key_output(kb_ref, &smart_keymap::key::keyboard::KeyState), + _ => None, + }), + _ => None, + } + } } } diff --git a/tests/ncl/keymap-2key-2layer-composite/expected.rs b/tests/ncl/keymap-2key-2layer-composite/expected.rs index 78ca20ed..eb9d59c2 100644 --- a/tests/ncl/keymap-2key-2layer-composite/expected.rs +++ b/tests/ncl/keymap-2key-2layer-composite/expected.rs @@ -211,7 +211,7 @@ pub mod init { /// [smart_keymap::key::layered] variant. Layered(smart_keymap::key::layered::PendingKeyState), /// [smart_keymap::key::tap_hold] variant. - TapHold(smart_keymap::key::tap_hold::PendingKeyState), + TapHold(smart_keymap::key::tap_hold::PendingKeyState), } impl From for PendingKeyState { @@ -224,14 +224,14 @@ pub mod init { PendingKeyState::Layered(pks) } } - impl From for PendingKeyState { - fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { + impl From> for PendingKeyState { + fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { PendingKeyState::TapHold(pks) } } #[allow(unreachable_patterns)] impl<'pks> TryFrom<&'pks mut PendingKeyState> - for &'pks mut smart_keymap::key::tap_hold::PendingKeyState + for &'pks mut smart_keymap::key::tap_hold::PendingKeyState { type Error = (); fn try_from(pks: &'pks mut PendingKeyState) -> Result { @@ -449,6 +449,21 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::TapHold(pks) => pks.speculative_output(|spec| match spec { + Ref::Keyboard(kb_ref) => self + .keyboard + .key_output(kb_ref, &smart_keymap::key::keyboard::KeyState), + _ => None, + }), + _ => None, + } + } } } diff --git a/tests/ncl/keymap-2key-2layer-named-layer_string/expected.rs b/tests/ncl/keymap-2key-2layer-named-layer_string/expected.rs index 5d824207..f18ea62b 100644 --- a/tests/ncl/keymap-2key-2layer-named-layer_string/expected.rs +++ b/tests/ncl/keymap-2key-2layer-named-layer_string/expected.rs @@ -364,6 +364,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-2key-2layer-named/expected.rs b/tests/ncl/keymap-2key-2layer-named/expected.rs index 5d824207..f18ea62b 100644 --- a/tests/ncl/keymap-2key-2layer-named/expected.rs +++ b/tests/ncl/keymap-2key-2layer-named/expected.rs @@ -364,6 +364,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-2key-2layer-simple/expected.rs b/tests/ncl/keymap-2key-2layer-simple/expected.rs index f02758c1..877c3c95 100644 --- a/tests/ncl/keymap-2key-2layer-simple/expected.rs +++ b/tests/ncl/keymap-2key-2layer-simple/expected.rs @@ -364,6 +364,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-2key-chorded-named-th-lmod/expected.rs b/tests/ncl/keymap-2key-chorded-named-th-lmod/expected.rs index ad4896f2..0c11b38a 100644 --- a/tests/ncl/keymap-2key-chorded-named-th-lmod/expected.rs +++ b/tests/ncl/keymap-2key-chorded-named-th-lmod/expected.rs @@ -259,7 +259,7 @@ pub mod init { /// [smart_keymap::key::layered] variant. Layered(smart_keymap::key::layered::PendingKeyState), /// [smart_keymap::key::tap_hold] variant. - TapHold(smart_keymap::key::tap_hold::PendingKeyState), + TapHold(smart_keymap::key::tap_hold::PendingKeyState), } impl @@ -291,8 +291,8 @@ pub mod init { PendingKeyState::Layered(pks) } } - impl From for PendingKeyState { - fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { + impl From> for PendingKeyState { + fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { PendingKeyState::TapHold(pks) } } @@ -314,7 +314,7 @@ pub mod init { } #[allow(unreachable_patterns)] impl<'pks> TryFrom<&'pks mut PendingKeyState> - for &'pks mut smart_keymap::key::tap_hold::PendingKeyState + for &'pks mut smart_keymap::key::tap_hold::PendingKeyState { type Error = (); fn try_from(pks: &'pks mut PendingKeyState) -> Result { @@ -600,6 +600,22 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::Chorded(_) => None, + PendingKeyState::TapHold(pks) => pks.speculative_output(|spec| match spec { + Ref::Keyboard(kb_ref) => self + .keyboard + .key_output(kb_ref, &smart_keymap::key::keyboard::KeyState), + _ => None, + }), + _ => None, + } + } } } diff --git a/tests/ncl/keymap-2key-chorded/expected.rs b/tests/ncl/keymap-2key-chorded/expected.rs index db3c4ab4..cbc80b45 100644 --- a/tests/ncl/keymap-2key-chorded/expected.rs +++ b/tests/ncl/keymap-2key-chorded/expected.rs @@ -427,6 +427,16 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::Chorded(_) => None, + _ => None, + } + } } } diff --git a/tests/ncl/keymap-34key-seniply/expected.rs b/tests/ncl/keymap-34key-seniply/expected.rs index 0b110037..51ad2931 100644 --- a/tests/ncl/keymap-34key-seniply/expected.rs +++ b/tests/ncl/keymap-34key-seniply/expected.rs @@ -441,6 +441,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-48key-basic/expected.rs b/tests/ncl/keymap-48key-basic/expected.rs index c7c51f3e..fc18f64e 100644 --- a/tests/ncl/keymap-48key-basic/expected.rs +++ b/tests/ncl/keymap-48key-basic/expected.rs @@ -419,6 +419,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } diff --git a/tests/ncl/keymap-48key-rgoulter/expected.rs b/tests/ncl/keymap-48key-rgoulter/expected.rs index d7755662..ba8120e5 100644 --- a/tests/ncl/keymap-48key-rgoulter/expected.rs +++ b/tests/ncl/keymap-48key-rgoulter/expected.rs @@ -393,7 +393,7 @@ pub mod init { /// [smart_keymap::key::tap_dance] variant. TapDance(smart_keymap::key::tap_dance::PendingKeyState), /// [smart_keymap::key::tap_hold] variant. - TapHold(smart_keymap::key::tap_hold::PendingKeyState), + TapHold(smart_keymap::key::tap_hold::PendingKeyState), } impl From for PendingKeyState { @@ -450,8 +450,8 @@ pub mod init { PendingKeyState::TapDance(pks) } } - impl From for PendingKeyState { - fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { + impl From> for PendingKeyState { + fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { PendingKeyState::TapHold(pks) } } @@ -485,7 +485,7 @@ pub mod init { } #[allow(unreachable_patterns)] impl<'pks> TryFrom<&'pks mut PendingKeyState> - for &'pks mut smart_keymap::key::tap_hold::PendingKeyState + for &'pks mut smart_keymap::key::tap_hold::PendingKeyState { type Error = (); fn try_from(pks: &'pks mut PendingKeyState) -> Result { @@ -934,6 +934,23 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::Chorded(_) => None, + PendingKeyState::TapDance(_) => None, + PendingKeyState::TapHold(pks) => pks.speculative_output(|spec| match spec { + Ref::Keyboard(kb_ref) => self + .keyboard + .key_output(kb_ref, &smart_keymap::key::keyboard::KeyState), + _ => None, + }), + _ => None, + } + } } } diff --git a/tests/ncl/keymap-60key-dvorak-simple-with-tap_hold/expected.rs b/tests/ncl/keymap-60key-dvorak-simple-with-tap_hold/expected.rs index ef16b229..e563d83d 100644 --- a/tests/ncl/keymap-60key-dvorak-simple-with-tap_hold/expected.rs +++ b/tests/ncl/keymap-60key-dvorak-simple-with-tap_hold/expected.rs @@ -177,7 +177,7 @@ pub mod init { /// [smart_keymap::key::keyboard] variant. Keyboard(smart_keymap::key::keyboard::PendingKeyState), /// [smart_keymap::key::tap_hold] variant. - TapHold(smart_keymap::key::tap_hold::PendingKeyState), + TapHold(smart_keymap::key::tap_hold::PendingKeyState), } impl From for PendingKeyState { @@ -185,14 +185,14 @@ pub mod init { PendingKeyState::Keyboard(pks) } } - impl From for PendingKeyState { - fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { + impl From> for PendingKeyState { + fn from(pks: smart_keymap::key::tap_hold::PendingKeyState) -> Self { PendingKeyState::TapHold(pks) } } #[allow(unreachable_patterns)] impl<'pks> TryFrom<&'pks mut PendingKeyState> - for &'pks mut smart_keymap::key::tap_hold::PendingKeyState + for &'pks mut smart_keymap::key::tap_hold::PendingKeyState { type Error = (); fn try_from(pks: &'pks mut PendingKeyState) -> Result { @@ -359,6 +359,21 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + PendingKeyState::TapHold(pks) => pks.speculative_output(|spec| match spec { + Ref::Keyboard(kb_ref) => self + .keyboard + .key_output(kb_ref, &smart_keymap::key::keyboard::KeyState), + _ => None, + }), + _ => None, + } + } } } diff --git a/tests/ncl/keymap-60key-dvorak-simple/expected.rs b/tests/ncl/keymap-60key-dvorak-simple/expected.rs index fe9da5f6..dad056e9 100644 --- a/tests/ncl/keymap-60key-dvorak-simple/expected.rs +++ b/tests/ncl/keymap-60key-dvorak-simple/expected.rs @@ -276,6 +276,15 @@ pub mod init { (_, _) => None, } } + + fn pending_output( + &self, + pending_key_state: &Self::PendingKeyState, + ) -> Option { + match pending_key_state { + _ => None, + } + } } } From a0bd1fe0ab2d38caf3b5c264a441ec24ca50ed8f Mon Sep 17 00:00:00 2001 From: Richard Goulter Date: Wed, 26 Aug 2026 20:52:46 +0700 Subject: [PATCH 5/6] test: add pending_output integration tests Add tests/rust/pending_output.rs with 6 AAA tests: default silent, Hold+first-tick, Hold+timeout, Hold+quick-release cancel, Hold+HoldOnKeyPress interrupt, and GUI refusal. AAA prose is inside each test body (Assemble/Act/Assert with -- description), blank line before Act/Assert, named MOD constants, and readable flash check via helper. Split timeout saga into two single-assert tests. Register the module in tests/rust/keymap.rs. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark Co-Authored-By: muse-spark-1.2 --- tests/rust/keymap.rs | 1 + tests/rust/pending_output.rs | 205 +++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tests/rust/pending_output.rs diff --git a/tests/rust/keymap.rs b/tests/rust/keymap.rs index 73f8b5d9..588252b9 100644 --- a/tests/rust/keymap.rs +++ b/tests/rust/keymap.rs @@ -10,6 +10,7 @@ mod key_lock; mod layered; mod mod_conditioned; mod mouse; +mod pending_output; mod sequence; mod sticky; mod tap_dance; diff --git a/tests/rust/pending_output.rs b/tests/rust/pending_output.rs new file mode 100644 index 00000000..ff4b759b --- /dev/null +++ b/tests/rust/pending_output.rs @@ -0,0 +1,205 @@ +use smart_keymap::input; +use smart_keymap::key::KeyboardModifiers; +use smart_keymap::keymap::ObservedKeymap; +use smart_keymap_macros::keymap; + +use crate::hid_keycodes::*; + +/// LEFT_CTRL modifier byte as used in HID report `report[0]`. +const MOD_LEFT_CTRL: u8 = KeyboardModifiers::LEFT_CTRL_U8; +/// LEFT_GUI modifier byte. +const MOD_LEFT_GUI: u8 = KeyboardModifiers::LEFT_GUI_U8; + +/// Returns true if any HID report contains `modifier` byte. +fn has_report_with_mod(reports: &[[u8; 8]], modifier: u8) -> bool { + reports.iter().any(|r| r[0] == modifier) +} + +/// Returns true if any report has `modifier` and `key_code` together +/// (e.g. mod + A flash). +fn has_report_with_mod_and_key(reports: &[[u8; 8]], modifier: u8, key_code: u8) -> bool { + reports.iter().any(|r| r[0] == modifier && r[2] == key_code) +} + +#[test] +fn default_none_still_silent_until_settle() { + // Assemble + let mut keymap = ObservedKeymap::new(keymap!( + r#" + let K = import "keys.ncl" in + { keys = [ K.A & K.hold K.LeftCtrl ] } + "# + )); + + // Act -- press TH + keymap.handle_input(input::Event::Press { keymap_index: 0 }); + + // Assert -- silent while pending + let reports = keymap.distinct_reports().reports().to_vec(); + assert_eq!( + vec![[0, 0, 0, 0, 0, 0, 0, 0]], + reports, + "should be silent while pending" + ); + + // Act -- tick to timeout + + for _ in 0..210 { + keymap.tick(); + } + + // Assert -- after timeout should be hold + + let reports2 = keymap.distinct_reports().reports().to_vec(); + assert!( + has_report_with_mod(&reports2, MOD_LEFT_CTRL), + "after timeout should be hold" + ); +} + +#[test] +fn hold_pending_shows_mod_after_first_tick() { + // Assemble -- tap-hold with Hold pending_output + let mut keymap = ObservedKeymap::new(keymap!( + r#" + let K = import "keys.ncl" in + { + config.tap_hold.pending_output = "Hold", + keys = [ K.A & K.hold K.LeftCtrl ], + } + "# + )); + + // Act -- press, tick once + keymap.handle_input(input::Event::Press { keymap_index: 0 }); + keymap.tick(); + + // Assert -- mod from first tick + let reports = keymap.distinct_reports().reports().to_vec(); + assert!( + has_report_with_mod(&reports, MOD_LEFT_CTRL), + "speculative hold should appear after first tick, got {:?}", + reports + ); +} + +#[test] +fn hold_pending_timeout_stays_hold() { + // Assemble -- tap-hold with Hold pending_output + let mut keymap = ObservedKeymap::new(keymap!( + r#" + let K = import "keys.ncl" in + { + config.tap_hold.pending_output = "Hold", + keys = [ K.A & K.hold K.LeftCtrl ], + } + "# + )); + + // Act -- press, tick, then timeout + keymap.handle_input(input::Event::Press { keymap_index: 0 }); + keymap.tick(); + for _ in 0..210 { + keymap.tick(); + } + + // Assert -- still hold after timeout + let reports = keymap.distinct_reports().reports().to_vec(); + assert!(has_report_with_mod(&reports, MOD_LEFT_CTRL)); +} + +#[test] +fn hold_pending_quick_release_cancels_then_tap() { + // Assemble + let mut keymap = ObservedKeymap::new(keymap!( + r#" + let K = import "keys.ncl" in + { + config.tap_hold.pending_output = "Hold", + keys = [ K.A & K.hold K.LeftCtrl ], + } + "# + )); + + // Act -- press and tick to show speculative hold + keymap.handle_input(input::Event::Press { keymap_index: 0 }); + keymap.tick(); + let before = keymap.distinct_reports().reports().to_vec(); + assert!( + has_report_with_mod(&before, MOD_LEFT_CTRL), + "speculative hold should be present" + ); + + // Act -- quick release before timeout + keymap.handle_input(input::Event::Release { keymap_index: 0 }); + keymap.tick_until_no_scheduled_events(); + let reports = keymap.distinct_reports().reports().to_vec(); + + // Assert -- should have tap A without mod, and no Ctrl+A flash + assert!( + reports.iter().any(|r| r[2] == KC_A && r[0] == 0), + "tap A without mod after cancel" + ); + let has_flash = has_report_with_mod_and_key(&reports, MOD_LEFT_CTRL, KC_A); + assert!( + !has_flash, + "should not have Ctrl+A flash, reports {:?}", + reports + ); +} + +#[test] +fn hold_pending_interrupt_mod_already_down() { + // Assemble + let mut keymap = ObservedKeymap::new(keymap!( + r#" + let K = import "keys.ncl" in + { + config.tap_hold.pending_output = "Hold", + config.tap_hold.interrupt_response = "HoldOnKeyPress", + keys = [ K.A & K.hold K.LeftCtrl, K.B ], + } + "# + )); + + // Act -- press TH, tick, then interrupt with B + keymap.handle_input(input::Event::Press { keymap_index: 0 }); + keymap.tick(); + keymap.handle_input(input::Event::Press { keymap_index: 1 }); + keymap.tick_until_no_scheduled_events(); + let reports = keymap.distinct_reports().reports().to_vec(); + + // Assert -- mod already down when B taps (distinct: MOD then MOD+B) + assert!(has_report_with_mod(&reports, MOD_LEFT_CTRL)); + assert!( + has_report_with_mod_and_key(&reports, MOD_LEFT_CTRL, KC_B), + "B should be with mod, reports {:?}", + reports + ); +} + +#[test] +fn gui_hold_refused_speculation() { + // Assemble + let mut keymap = ObservedKeymap::new(keymap!( + r#" + let K = import "keys.ncl" in + { + config.tap_hold.pending_output = "Hold", + keys = [ K.A & K.hold K.LeftGUI ], + } + "# + )); + + // Act -- press GUI hold + keymap.handle_input(input::Event::Press { keymap_index: 0 }); + keymap.tick(); + let reports = keymap.distinct_reports().reports().to_vec(); + + // Assert -- should remain silent (GUI not speculated) + assert!( + !has_report_with_mod(&reports, MOD_LEFT_GUI), + "GUI should not speculate, reports {:?}", + reports + ); +} From 5bede79f5f515fbc59422af3e02e8b54cf970a1c Mon Sep 17 00:00:00 2001 From: Richard Goulter Date: Wed, 26 Aug 2026 20:53:08 +0700 Subject: [PATCH 6/6] test: add pending_output cucumber feature Add features/keymap/key/tap_hold-config-pending_output.feature with 3 scenarios (Hold+timeout, Hold+quick-release retracts to tap, Hold+HoldOnKeyPress). Default Hold is in Background; default silent is covered in rust integration test. Prose uses NoOutput, no emdash, semantic linebreaks, and matches FAK eager_decision, ZMK hold-while-undecided, and QMK Speculative Hold. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark Co-Authored-By: muse-spark-1.2 --- .../tap_hold-config-pending_output.feature | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 features/keymap/key/tap_hold-config-pending_output.feature diff --git a/features/keymap/key/tap_hold-config-pending_output.feature b/features/keymap/key/tap_hold-config-pending_output.feature new file mode 100644 index 00000000..3ab7b0bb --- /dev/null +++ b/features/keymap/key/tap_hold-config-pending_output.feature @@ -0,0 +1,90 @@ +Feature: TapHold Key (configure pending_output) + + The `pending_output` config for tap-hold keys controls speculative HID + while the tap-vs-hold decision is still pending. + + Default `NoOutput` produces no output while pending. + No HID is emitted until timeout, interrupt, or release settles tap vs hold. + `Hold` emits the hold binding's HID while still pending, then keeps it + or retracts it when tap vs hold settles. Decision logic is unchanged. + Only the timing of hold appearance changes. + + This matches FAK `eager_decision = 'hold'`, ZMK + `hold-while-undecided`, and QMK Speculative Hold. + + For examples of this key in other smart keyboard firmware, see e.g.: + + - [FAK's eager_decision](https://github.com/semickolon/fak) + + - [ZMK's hold-while-undecided](https://zmk.dev/docs/keymaps/behaviors/hold-tap#hold-while-undecided) + + - [QMK's Speculative Hold](https://docs.qmk.fm/tap_hold#speculative-hold) + + Background: + + Given a keymap.ncl: + """ + let K = import "keys.ncl" in + { + config.tap_hold.pending_output = "Hold", + keys = [ + K.A & K.hold K.LeftCtrl, + K.B + ] + } + """ + + Example: Hold with timeout shows mod from first tick and stays hold + + When the keymap registers the following input + """ + [ + press (K.A & K.hold K.LeftCtrl), + wait 1, + ] + """ + Then the HID keyboard report should equal + """ + { modifiers = { left_ctrl = true } } + """ + + Example: Hold with quick release retracts mod and ends as tap + + When the keymap registers the following input + """ + [ + press (K.A & K.hold K.LeftCtrl), + wait 1, + release (K.A & K.hold K.LeftCtrl), + ] + """ + Then the HID keyboard report should equal + """ + { key_codes = [K.A] } + """ + + Example: Hold with HoldOnKeyPress interrupt shows mod already down when other key taps + + Given a keymap.ncl: + """ + let K = import "keys.ncl" in + { + config.tap_hold.interrupt_response = "HoldOnKeyPress", + keys = [ + K.A & K.hold K.LeftCtrl, + K.B + ] + } + """ + When the keymap registers the following input + """ + [ + press (K.A & K.hold K.LeftCtrl), + wait 1, + tap K.B, + ] + """ + Then the HID keyboard report should equal + """ + { modifiers = { left_ctrl = true }, key_codes = [K.B] } + """