tap-hold pending_output (speculative Hold) - #708
Conversation
Add a defaulted trait method System::pending_output(&PKS) -> Option<KeyOutput> 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. This is the generic seam for tap-hold Hold-while-undecided; other families keep the default None. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark <muse-code@meta.com> Co-Authored-By: muse-spark-1.2 <muse-spark@meta.com>
Add pending_output (None|Hold) to tap-hold Profile/Config, lower flat config.tap_hold.pending_output to default_profile, and generate composite System::pending_output that decodes the speculative hold ref (keyboard only, GUI refused). Update families to use PendingKeyState<Ref>. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark <muse-code@meta.com> Co-Authored-By: muse-spark-1.2 <muse-spark@meta.com>
Add PendingOutput (None|Hold) to Profile with serde default None, extend PendingKeyState<R> with speculative: Option<R>, and wire new_pending_key to store the hold ref when pending_output is Hold. GUI filtering is deferred to the composite pending_output decoder. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark <muse-code@meta.com> Co-Authored-By: muse-spark-1.2 <muse-spark@meta.com>
Generated via ncl/scripts/save-test-snapshots.sh after making PendingKeyState generic and adding System::pending_output. Model: muse-spark-1.2 Agent: Muse Code Co-Authored-By: Muse Code powered by Meta Muse Spark <muse-code@meta.com> Co-Authored-By: muse-spark-1.2 <muse-spark@meta.com>
Add tests/rust/pending_output.rs with 5 AAA tests: default silent, Hold+timeout, Hold+quick-release cancel, Hold+HoldOnKeyPress interrupt, and GUI refusal. AAA prose is inside each test body (Assemble/Act/Assert with -- description). 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 <muse-code@meta.com> Co-Authored-By: muse-spark-1.2 <muse-spark@meta.com>
Add features/keymap/key/tap_hold-config-pending_output.feature with 4 scenarios (default silent, Hold+timeout, Hold+quick-release, Hold+HoldOnKeyPress) cross-linked to 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 <muse-code@meta.com> Co-Authored-By: muse-spark-1.2 <muse-spark@meta.com>
| The `pending_output` config for tap-hold keys controls speculative HID | ||
| while the tap-vs-hold decision is still pending. | ||
|
|
||
| Default `None` is today's silence (no HID until timeout / interrupt / release). |
There was a problem hiding this comment.
"today's silence" is an awkward phrase here.
| `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 when hold appears. | ||
|
|
||
| This is the same behaviour as FAK `eager_decision = 'hold`, ZMK | ||
| `hold-while-undecided`, and QMK Speculative Hold. |
There was a problem hiding this comment.
Emdash remains a sign of cringe. (LLMs still lack taste about what a good comment is).
Should help try rearrange to more 'semantic' linebreaks.
| Example: Hold with quick release shows mod then cancels to tap without Ctrl+A flash | ||
|
|
||
| Given a keymap.ncl: | ||
| """ | ||
| let K = import "keys.ncl" in | ||
| { | ||
| config.tap_hold.pending_output = "Hold", | ||
| keys = [ K.A & K.hold K.LeftCtrl ] | ||
| } | ||
| """ | ||
| 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] } | ||
| """ |
There was a problem hiding this comment.
That's not what this test demonstrates. (The hid report is about the last state).
| Background: | ||
|
|
||
| Given a keymap.ncl: | ||
| """ | ||
| let K = import "keys.ncl" in | ||
| { | ||
| keys = [ | ||
| K.A & K.hold K.LeftCtrl, | ||
| K.B | ||
| ] | ||
| } | ||
| """ | ||
|
|
||
| Example: default None stays silent while pending (regression) | ||
|
|
||
| When the keymap registers the following input | ||
| """ | ||
| [ | ||
| press (K.A & K.hold K.LeftCtrl), | ||
| wait 1, | ||
| ] | ||
| """ | ||
| Then the HID keyboard report should equal | ||
| """ | ||
| { modifiers = {} } | ||
| """ |
There was a problem hiding this comment.
Background should have the Some config option. Can check that there's no output in a rust integration test, and otherwise assume it.
| if let Some(spec) = pks.speculative { | ||
| match spec { | ||
| Ref::Keyboard(kb_ref) => { | ||
| let ko = self.keyboard.key_output(&kb_ref, &smart_keymap::key::keyboard::KeyState); | ||
| if let Some(ko) = ko { | ||
| let mods = ko.key_modifiers(); | ||
| if mods.has_modifiers(&smart_keymap::key::KeyboardModifiers::LEFT_GUI) | ||
| || mods.has_modifiers(&smart_keymap::key::KeyboardModifiers::RIGHT_GUI) | ||
| { | ||
| None | ||
| } else { | ||
| Some(ko) | ||
| } | ||
| } else { | ||
| None | ||
| } | ||
| }, | ||
| _ => None, | ||
| } | ||
| } else { | ||
| None | ||
| } |
There was a problem hiding this comment.
Really would prefer this be in smart-keymap-core rather than Rust code.
| return base.union(&ko.key_modifiers()); | ||
| } | ||
| } | ||
| base |
There was a problem hiding this comment.
Prefer to try and keep this in the expression, rather than use return.
| /// No speculative output (default). | ||
| None, |
There was a problem hiding this comment.
NoOutput would be better, because None otherwise strongly pairs with Some.
| { keys = [ K.A & K.hold K.LeftCtrl ] } | ||
| "# | ||
| )); | ||
| // Act -- press TH |
There was a problem hiding this comment.
For readability / structure, there should be a blank line before the // Act and // Assert comments.
| // Act -- timeout | ||
| for _ in 0..210 { | ||
| keymap.tick(); | ||
| } | ||
| let reports2 = keymap.distinct_reports().reports().to_vec(); | ||
| // Assert -- still hold | ||
| assert!(reports2.iter().any(|r| r[0] == 0x01)); |
There was a problem hiding this comment.
Multiple // Act, // Assert is smelly. May be acceptable to have a saga of tests, but I'd want it clearer.
| } | ||
| let reports2 = keymap.distinct_reports().reports().to_vec(); | ||
| assert!( | ||
| reports2.iter().any(|r| r[0] == 0x01), |
There was a problem hiding this comment.
Use of 0x01 is unclear.
Perhaps out of scope for this PR, but this .any(|r| ...) is a common pattern across tests which could be factored out.
| ); | ||
| let has_flash = reports | ||
| .windows(2) | ||
| .any(|w| w[0][0] == 0x01 && w[1][0] == 0x01 && w[1][2] == KC_A); |
| let suppress = self.context.suppressed_modifiers(); | ||
| let pressed_key_codes = self.pressed_inputs.iter().filter_map(|pi| { | ||
| let mut outputs: heapless::Vec<key::KeyOutput, { MAX_PRESSED_KEYS }> = heapless::Vec::new(); | ||
| for pi in self.pressed_inputs.iter() { |
There was a problem hiding this comment.
Prefer expression-based construction, rather than constructing with a for loop.
| /// | ||
| /// Default: `None` (today's silence). | ||
| /// Only tap-hold implements it in v1. |
There was a problem hiding this comment.
- "today's silence" is an awkward phrase.
- It's awkward to mention which implementations there are. "v1" is also awkward.
Implements speculative eager Hold for tap-hold (FAK
eager_decision='hold'/ ZMKhold-while-undecided/ QMK Speculative Hold) behind a genericSystem::pending_outputseam.Stack
System::pending_output(&PendingKeyState) -> Option<KeyOutput>and includes it inKeymap'spressed_keys/aggregate_pressed_modifiersfold while a pending session is live. Keepskeymap.rsgeneric (no tap-hold type).pending_output(None|Hold) toProfile/Config, lowers flatconfig.tap_hold.pending_outputtodefault_profile, generates compositeSystem::pending_outputthat decodes the speculative hold ref (keyboard only; GUILEFT_GUI/RIGHT_GUIrefused viahas_modifiers). Updatesfamilies.ncltoPendingKeyState<Ref>.PendingOutputenum,PendingKeyState<R> { speculative: Option<R> },new_pending_keystores hold ref whenpending_output=Hold.ncl/scripts/save-test-snapshots.sh.tests/rust/pending_output.rs(default silent, Hold+timeout, Hold+quick-release cancel, Hold+HoldOnKeyPress interrupt, GUI refusal); AAA prose is inline// Assemble --/// Act --/// Assert --inside bodies.tap_hold-config-pending_output.feature(4 scenarios).Verification
cargo test --test rust-integration pending_output5 passedjust test-fast218 passedjust check-quickgreen (fmt, clippy, doc, nickel format)Linger / same-mod ZMK tap deferred to follow-on.