Skip to content
Merged
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
91 changes: 91 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,91 @@
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.pending_output = "Hold",
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] }
"""
22 changes: 22 additions & 0 deletions ncl/key_system/keymap-codegen.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,19 @@ Ref::%{f.variant}(key_ref) => {
)
++ "\n(_, _) => None,",

pending_output_arms =
if pending_systems == [] then
"_ => None,"
else
(
pending_systems
|> std.array.map (fun f =>
"PendingKeyState::%{f.variant}(pks) => self.%{f.field}.pending_output(pks),"
)
|> join
)
++ "\n_ => None,",

key_state_from_family =
systems
|> std.array.map (fun f =>
Expand Down Expand Up @@ -861,6 +874,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
182 changes: 181 additions & 1 deletion ncl/smart_keys/tap_hold/keymap-codegen.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,71 @@
}"%,
},
},

check_profile_with_pending_output =
let rust_expr =
smart_keymap.tap_hold.profile_rust_expr {
pending_output = "Hold",
}
in
{
check_rust_expr = {
actual = rust_expr,
expected = m%"smart_keymap::key::tap_hold::Profile {
pending_output: smart_keymap::key::tap_hold::PendingOutput::Hold,
..smart_keymap::key::tap_hold::Profile::new()
}"%,
},
},

check_speculative_hold_output = {
check_left_ctrl_modifiers =
let cv =
smart_keymap.keyboard.key.codegen_values { modifiers = 1 }
in
{
actual = smart_keymap.tap_hold.speculative_hold_output cv,
expected =
'Some {
json = { key_modifiers = 1 },
rust_expr = "smart_keymap::key::KeyOutput::from_key_modifiers(smart_keymap::key::KeyboardModifiers::from_byte(1))",
},
},

check_left_ctrl_keycode =
let cv =
smart_keymap.keyboard.key.codegen_values { key_code = 224 }
in
{
actual = smart_keymap.tap_hold.speculative_hold_output cv,
expected =
'Some {
json = { key_modifiers = 1 },
rust_expr = "smart_keymap::key::KeyOutput::from_key_modifiers(smart_keymap::key::KeyboardModifiers::from_byte(1))",
},
},

check_left_gui_modifiers_refused =
let cv =
smart_keymap.keyboard.key.codegen_values { modifiers = 8 }
in
smart_keymap.tap_hold.speculative_hold_output cv == 'None,

check_left_gui_keycode_refused =
let cv =
smart_keymap.keyboard.key.codegen_values { key_code = 227 }
in
smart_keymap.tap_hold.speculative_hold_output cv == 'None,

check_non_keyboard_refused =
let cv =
smart_keymap.tap_hold.key.codegen_values {
tap = { key_code = 4 },
hold = { key_code = 224 },
}
in
smart_keymap.tap_hold.speculative_hold_output cv == 'None,
},
},
},

Expand All @@ -102,8 +167,97 @@
]
),

TapHoldPendingOutputJson =
std.contract.from_validator (
validators.is_elem_of [
"NoOutput",
"Hold",
]
),

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

# HID of a keyboard hold leaf, or 'None if it must not be speculated
# (non-keyboard, or LeftGUI / RightGUI).
speculative_hold_output = fun hold_cv =>
if std.record.has_field "module" hold_cv
&& hold_cv.module == "smart_keymap::key::keyboard" then
let json = hold_cv.json in
let key_code =
if std.record.has_field "key_code" json then
json.key_code
else
0
in
let modifiers =
if std.record.has_field "modifiers" json then
json.modifiers
else
0
in
let is_odd = fun n => n - 2 * std.number.floor (n / 2) == 1 in
let bit_set = fun value bit => is_odd (std.number.floor (value / bit)) in
let bit_or8 = fun a b =>
std.array.fold_left
(fun acc i =>
let bit = std.number.pow 2 i in
if bit_set a bit || bit_set b bit then
acc + bit
else
acc
)
0
[0, 1, 2, 3, 4, 5, 6, 7]
in
let kc_mod =
key_code
|> match {
224 => 1,
225 => 2,
226 => 4,
227 => 8,
228 => 16,
229 => 32,
230 => 64,
231 => 128,
Comment on lines +215 to +222

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.

Let's use hex values, or otherwise comment this so it's clearer / easier to cross reference.

_ => 0,
}
in
let remaining_kc = if kc_mod != 0 then 0 else key_code in
let merged = bit_or8 modifiers kc_mod in
let has_gui = bit_set merged 8 || bit_set merged 128 in
if has_gui || (remaining_kc == 0 && merged == 0) then
'None
else
let rust_expr =
if remaining_kc == 0 then
"smart_keymap::key::KeyOutput::from_key_modifiers(smart_keymap::key::KeyboardModifiers::from_byte(%{std.to_string merged}))"
else if merged == 0 then
"smart_keymap::key::KeyOutput::from_key_code(%{std.to_string remaining_kc})"
else
"smart_keymap::key::KeyOutput::from_key_code_with_modifiers(%{std.to_string remaining_kc}, smart_keymap::key::KeyboardModifiers::from_byte(%{std.to_string merged}))"
in
let json =
(
if remaining_kc == 0 then
{}
else
{ key_code = { Keyboard = remaining_kc } }
)
& (
if merged == 0 then
{}
else
{ key_modifiers = merged }
)
in
'Some {
include json,
include rust_expr,
}
else
'None,

hold_trigger_positions_expr = fun positions =>
let idxs =
positions
Expand Down Expand Up @@ -156,6 +310,14 @@
}
else
{}
)
& (
if std.record.has_field "pending_output" c then
{
pending_output = "%{module}::PendingOutput::%{c.pending_output}",
}
else
{}
),

profile_rust_expr = fun c =>
Expand Down Expand Up @@ -257,18 +419,35 @@
else
{ profile = profile_id }
in
let hold_output = smart_keymap.tap_hold.speculative_hold_output hold_cv in
let hold_output_json =
hold_output
|> match {
'None => {},
'Some { json, .. } => { hold_output = json },
}
in
let hold_output_rust =
hold_output
|> match {
'None => "None",
'Some { rust_expr, .. } => "Some(%{rust_expr})",
}
in
let new_key = {
json =
{
tap = tap_ref.json,
hold = hold_ref.json,
}
& profile_json,
& profile_json
& hold_output_json,
rust_expr = m%"
%{module}::Key {
tap: %{tap_ref.rust_expr},
hold: %{hold_ref.rust_expr},
profile: %{std.to_string profile_id},
hold_output: %{hold_output_rust},
}
"%,
}
Expand Down Expand Up @@ -297,6 +476,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
Loading
Loading