Skip to content
Open
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
90 changes: 90 additions & 0 deletions features/keymap/key/tap_hold-config-pending_output.feature
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +8 to +13

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.


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] }
"""
2 changes: 1 addition & 1 deletion ncl/key_system/families.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,7 @@
},
pending =
'PendingKeyState {
ty = "%{module}::PendingKeyState",
ty = "%{module}::PendingKeyState<Ref>",
},
keymap_context = 'UpdatesKeymapContext,
system =
Expand Down
28 changes: 28 additions & 0 deletions ncl/key_system/keymap-codegen.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),"%
Comment on lines +586 to +589

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.

Hmm. Not sure about this.

else
m%"PendingKeyState::%{f.variant}(_) => None,"%
)
|> join
)
++ "\n_ => None,",

key_state_from_family =
systems
|> std.array.map (fun f =>
Expand Down Expand Up @@ -861,6 +880,15 @@ pub mod key_system {
%{key_output_arms}
}
}

fn pending_output(
&self,
pending_key_state: &Self::PendingKeyState,
) -> Option<key::KeyOutput> {
match pending_key_state {
%{pending_output_arms}
}
}
}
}
"%,
Expand Down
6 changes: 6 additions & 0 deletions ncl/keymap-ncl-to-json.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions ncl/smart_keys/tap_hold/keymap-codegen.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@
]
),

TapHoldPendingOutputJson =
std.contract.from_validator (
validators.is_elem_of [
"NoOutput",
"Hold",
"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.

?

]
),

module = "smart_keymap::key::tap_hold",

hold_trigger_positions_expr = fun positions =>
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions ncl/smart_keys/tap_hold/keymap-ncl-to-json.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand All @@ -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 },
},
Expand Down
7 changes: 7 additions & 0 deletions smart-keymap-core/src/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,13 @@ pub trait System<R>: Debug {
fn key_output(&self, _ref: &Self::Ref, _key_state: &Self::KeyState) -> Option<KeyOutput> {
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<KeyOutput> {
None
}
}

/// Used to provide state that may affect behaviour when pressing the key.
Expand Down
Loading
Loading