Skip to content

tap-hold pending_output (speculative Hold) - #708

Open
rgoulter wants to merge 6 commits into
masterfrom
pending-output
Open

tap-hold pending_output (speculative Hold)#708
rgoulter wants to merge 6 commits into
masterfrom
pending-output

Conversation

@rgoulter

Copy link
Copy Markdown
Owner

Implements speculative eager Hold for tap-hold (FAK eager_decision='hold' / ZMK hold-while-undecided / QMK Speculative Hold) behind a generic System::pending_output seam.

Stack

  1. core: add System::pending_output hook — adds defaulted System::pending_output(&PendingKeyState) -> Option<KeyOutput> and includes it in Keymap's pressed_keys / aggregate_pressed_modifiers fold while a pending session is live. Keeps keymap.rs generic (no tap-hold type).
  2. ncl: tap-hold pending_output authoring and codegen — adds pending_output (None|Hold) to Profile/Config, lowers flat config.tap_hold.pending_output to default_profile, generates composite System::pending_output that decodes the speculative hold ref (keyboard only; GUI LEFT_GUI/RIGHT_GUI refused via has_modifiers). Updates families.ncl to PendingKeyState<Ref>.
  3. core: tap-hold pending_output profile and speculative statePendingOutput enum, PendingKeyState<R> { speculative: Option<R> }, new_pending_key stores hold ref when pending_output=Hold.
  4. test: refresh expected.rs snapshotsncl/scripts/save-test-snapshots.sh.
  5. test: add pending_output integration tests — 5 AAA tests in 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.
  6. test: add pending_output cucumber featuretap_hold-config-pending_output.feature (4 scenarios).

Verification

  • cargo test --test rust-integration pending_output 5 passed
  • just test-fast 218 passed
  • just check-quick green (fmt, clippy, doc, nickel format)

Linger / same-mod ZMK tap deferred to follow-on.

rgoulter and others added 6 commits August 24, 2026 09:15
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).

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.

"today's silence" is an awkward phrase here.

Comment on lines +7 to +12
`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.

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.

Emdash remains a sign of cringe. (LLMs still lack taste about what a good comment is).

Should help try rearrange to more 'semantic' linebreaks.

Comment on lines +71 to +92
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] }
"""

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.

That's not what this test demonstrates. (The hid report is about the last state).

Comment on lines +22 to +47
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 = {} }
"""

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.

Background should have the Some config option. Can check that there's no output in a rust integration test, and otherwise assume it.

Comment on lines +587 to +608
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
}

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.

Really would prefer this be in smart-keymap-core rather than Rust code.

return base.union(&ko.key_modifiers());
}
}
base

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.

Prefer to try and keep this in the expression, rather than use return.

Comment on lines +167 to +168
/// No speculative output (default).
None,

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.

NoOutput would be better, because None otherwise strongly pairs with Some.

{ keys = [ K.A & K.hold K.LeftCtrl ] }
"#
));
// Act -- press TH

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.

For readability / structure, there should be a blank line before the // Act and // Assert comments.

Comment on lines +58 to +64
// 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));

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.

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),

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.

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);

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.

This is unreadable.

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() {

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.

Prefer expression-based construction, rather than constructing with a for loop.

Comment on lines +292 to +294
///
/// Default: `None` (today's silence).
/// Only tap-hold implements it in v1.

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.

  1. "today's silence" is an awkward phrase.
  2. It's awkward to mention which implementations there are. "v1" is also awkward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant