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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ kanata-tcp-protocol = { path = "tcp_protocol", version = "0.1110.0" }
arboard = "3.4"

[target.'cfg(target_os = "macos")'.dependencies]
karabiner-driverkit = "0.2.1"
karabiner-driverkit = "0.3.0"
objc = "0.2.7"
core-graphics = "0.24.0"
open = { version = "5", optional = true }
Expand Down
7 changes: 7 additions & 0 deletions cfg_samples/kanata.kbd
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,13 @@ If you need help, please feel welcome to ask in the GitHub discussions.
(defalias
th1 (tap-hold $tt $ht caps lctl)
th2 (tap-hold $tt $ht spc lsft)

;; tap-hold-opposite-hand-release is a release-time variant of
;; tap-hold-opposite-hand. It waits for the interrupting key to be pressed
;; AND released before deciding, which avoids misfires on fast same-hand
;; rolls. Requires defhands.
;; actl (tap-hold-opposite-hand-release 200 a lctl
;; (same-hand tap) (unknown-hand hold) (timeout hold))
)

;; defalias is used to declare a shortcut for a more complicated action to keep
Expand Down
8 changes: 7 additions & 1 deletion docs/config.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2018,6 +2018,7 @@ results in `$tap-action` activating.
(tap-hold-except-keys $tap-repress-timeout $hold-timeout $tap-action $hold-action $tap-keys)
(tap-hold-tap-keys $tap-repress-timeout $hold-timeout $tap-action $hold-action $tap-keys)
(tap-hold-opposite-hand $timeout $tap-action $hold-action [options...])
(tap-hold-opposite-hand-release $timeout $tap-action $hold-action [options...])
(tap-hold-order $tap-repress-timeout $buffer-ms $tap-action $hold-action [options...])
----

Expand Down Expand Up @@ -2056,7 +2057,7 @@ is pressed then released before hold activates.

| `tap-hold-except-keys`
| The `$tap-keys` parameter is a list of key names.
Activates $tap-action if a key within $tap-keys is pressed
Activates `$tap-action` if a key within `$tap-keys` is pressed
or if the action key is released before hold timeout.
No key is ever output until the action key is released
or another key is pressed,
Expand All @@ -2075,6 +2076,11 @@ This is useful for home row mods where fast typing should not trigger modifiers.
Requires a `defhands` directive. Supports list-form options for fine-grained control.
See <<defhands and tap-hold-opposite-hand>> below.

| `tap-hold-opposite-hand-release`
| Like `tap-hold-opposite-hand` but waits for the interrupting key to be pressed AND released
before committing. This avoids misfires on fast same-hand rolls where keystrokes briefly overlap.
Requires a `defhands` directive. Supports the same options as `tap-hold-opposite-hand`.

| `tap-hold-order`
| Resolves purely by key release order, with no timeout.
If the tap-hold key is released before the other key, activates `$tap-action`;
Expand Down
68 changes: 68 additions & 0 deletions parser/src/cfg/custom_tap_hold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,71 @@ pub(crate) fn custom_tap_hold_opposite_hand(
},
)
}

/// Like `custom_tap_hold_opposite_hand` but waits for the interrupting key's
/// press+release before committing. This avoids misfires on fast same-hand
/// rolls where keystrokes briefly overlap.
pub(crate) fn custom_tap_hold_opposite_hand_release(
hand_map: &'static HandMap,
same_hand: DecisionBehavior,
neutral_behavior: DecisionBehavior,
unknown_hand: DecisionBehavior,
neutral_keys: &'static [OsCode],
a: &Allocations,
) -> &'static CustomTapHoldFn {
a.sref(
move |mut queued: QueuedIter, coord: KCoord| -> (Option<WaitingAction>, bool) {
let (_row, col) = coord;
let waiting_hand = hand_map.get(col);

while let Some(q) = queued.next() {
if !q.event().is_press() {
continue;
}
let (i, j) = q.event().coord();
if i != REAL_KEY_ROW {
continue;
}

// Wait for the interrupting key's release before deciding.
let release = Event::Release(i, j);
if !queued.clone().copied().any(|q| q.event() == release) {
continue;
}

// Check neutral-keys first (takes precedence over defhands)
if let Some(osc) = OsCode::from_u16(j) {
if neutral_keys.contains(&osc) {
match neutral_behavior {
DecisionBehavior::Tap => return (Some(WaitingAction::Tap), false),
DecisionBehavior::Hold => return (Some(WaitingAction::Hold), false),
DecisionBehavior::Ignore => continue,
}
}
}

let pressed_hand = hand_map.get(j);

match (waiting_hand, pressed_hand) {
(Hand::Left, Hand::Right) | (Hand::Right, Hand::Left) => {
return (Some(WaitingAction::Hold), false);
}
(Hand::Left, Hand::Left) | (Hand::Right, Hand::Right) => match same_hand {
DecisionBehavior::Tap => return (Some(WaitingAction::Tap), false),
DecisionBehavior::Hold => return (Some(WaitingAction::Hold), false),
DecisionBehavior::Ignore => continue,
},
_ => {
// At least one key is Neutral (not in defhands)
match unknown_hand {
DecisionBehavior::Tap => return (Some(WaitingAction::Tap), false),
DecisionBehavior::Hold => return (Some(WaitingAction::Hold), false),
DecisionBehavior::Ignore => continue,
}
}
}
}
(None, false)
},
)
}
148 changes: 148 additions & 0 deletions parser/src/cfg/defhands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,154 @@ pub(super) fn parse_tap_hold_opposite_hand(
}))))
}

pub(super) fn parse_tap_hold_opposite_hand_release(
ac_params: &[SExpr],
s: &ParserState,
) -> Result<&'static KanataAction> {
use custom_tap_hold::{DecisionBehavior, custom_tap_hold_opposite_hand_release};

const ARITY_MSG: &str = "tap-hold-opposite-hand-release expects at least 3 items: \
<timeout> <tap> <hold> [options...]";
if ac_params.is_empty() {
bail!(ARITY_MSG);
}
if ac_params.len() < 3 {
bail_expr!(&ac_params[0], "{}", ARITY_MSG);
}
let hand_map = s.hand_map.ok_or_else(|| {
anyhow_expr!(
&ac_params[0],
"tap-hold-opposite-hand-release requires defhands to be defined"
)
})?;

let hold_timeout = parse_non_zero_u16(&ac_params[0], s, "timeout")?;
let tap_action = parse_action(&ac_params[1], s)?;
let hold_action = parse_action(&ac_params[2], s)?;
if matches!(tap_action, Action::HoldTap { .. }) {
bail_expr!(
&ac_params[1],
"tap-hold does not work in the tap-action of tap-hold"
);
}

let mut timeout_behavior = DecisionBehavior::Tap;
let mut same_hand = DecisionBehavior::Tap;
let mut neutral_behavior = DecisionBehavior::Ignore;
let mut unknown_hand = DecisionBehavior::Ignore;
let mut neutral_keys: Vec<OsCode> = Vec::new();
let mut require_prior_idle: Option<u16> = None;
let mut seen_options: HashSet<&str> = HashSet::default();

for option_expr in &ac_params[3..] {
let Some(option) = option_expr.list(s.vars()) else {
bail_expr!(
option_expr,
"expected option list, e.g. `(timeout hold)` or `(neutral-keys spc tab)`"
);
};
if option.is_empty() {
bail_expr!(option_expr, "option list cannot be empty");
}
let kw = option[0]
.atom(s.vars())
.ok_or_else(|| anyhow_expr!(&option[0], "option name must be a string"))?;
if !seen_options.insert(kw) {
bail_expr!(
&option[0],
"duplicate option '{}' in tap-hold-opposite-hand-release",
kw
);
}
match kw {
"timeout" => {
if option.len() != 2 {
bail_expr!(
option_expr,
"option must contain exactly 2 items: `(name value)`"
);
}
timeout_behavior = parse_decision_behavior_tap_hold(&option[1], s)?;
}
"same-hand" => {
if option.len() != 2 {
bail_expr!(
option_expr,
"option must contain exactly 2 items: `(name value)`"
);
}
same_hand = parse_decision_behavior(&option[1], s)?;
}
"neutral" => {
if option.len() != 2 {
bail_expr!(
option_expr,
"option must contain exactly 2 items: `(name value)`"
);
}
neutral_behavior = parse_decision_behavior(&option[1], s)?;
}
"unknown-hand" => {
if option.len() != 2 {
bail_expr!(
option_expr,
"option must contain exactly 2 items: `(name value)`"
);
}
unknown_hand = parse_decision_behavior(&option[1], s)?;
}
"neutral-keys" => {
if option.len() < 2 {
bail_expr!(
option_expr,
"neutral-keys expects one or more key atoms, e.g. `(neutral-keys spc tab)`"
);
}
neutral_keys = parse_key_atoms(&option[1..], s, "neutral-keys")?;
}
"require-prior-idle" => {
require_prior_idle = Some(tap_hold::parse_require_prior_idle_option(
option,
option_expr,
s,
)?);
}
_ => bail_expr!(
&option[0],
"unknown option '{}' for tap-hold-opposite-hand-release. \
Valid options: timeout, same-hand, neutral, unknown-hand, neutral-keys, require-prior-idle",
kw
),
}
}

let timeout_action = match timeout_behavior {
DecisionBehavior::Tap => tap_action,
DecisionBehavior::Hold => hold_action,
DecisionBehavior::Ignore => unreachable!(),
};

let neutral_keys_static = s.a.sref_vec(neutral_keys);

Ok(s.a.sref(Action::HoldTap(s.a.sref(HoldTapAction {
config: HoldTapConfig::Custom(custom_tap_hold_opposite_hand_release(
hand_map,
same_hand,
neutral_behavior,
unknown_hand,
neutral_keys_static,
&s.a,
)),
tap_hold_interval: 0,
timeout: hold_timeout,
tap: *tap_action,
hold: *hold_action,
timeout_action: *timeout_action,
on_press_reset_timeout_to: None,
require_prior_idle,
}))))
}

fn parse_key_atoms(exprs: &[SExpr], s: &ParserState, label: &str) -> Result<Vec<OsCode>> {
exprs
.iter()
Expand Down
2 changes: 2 additions & 0 deletions parser/src/cfg/list_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ pub const CLIPBOARD_SAVE_CMD_SET: &str = "clipboard-save-cmd-set";
pub const CLIPBOARD_SAVE_SWAP: &str = "clipboard-save-swap";
pub const TAP_HOLD_ORDER: &str = "tap-hold-order";
pub const TAP_HOLD_OPPOSITE_HAND: &str = "tap-hold-opposite-hand";
pub const TAP_HOLD_OPPOSITE_HAND_RELEASE: &str = "tap-hold-opposite-hand-release";

pub fn is_list_action(ac: &str) -> bool {
const LIST_ACTIONS: &[&str] = &[
Expand Down Expand Up @@ -275,6 +276,7 @@ pub fn is_list_action(ac: &str) -> bool {
CLIPBOARD_SAVE_SWAP,
TAP_HOLD_ORDER,
TAP_HOLD_OPPOSITE_HAND,
TAP_HOLD_OPPOSITE_HAND_RELEASE,
];
LIST_ACTIONS.contains(&ac)
}
5 changes: 4 additions & 1 deletion parser/src/cfg/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ use custom_tap_hold::*;
mod defcfg;
pub use defcfg::*;
mod defhands;
use defhands::{parse_defhands, parse_tap_hold_opposite_hand};
use defhands::{
parse_defhands, parse_tap_hold_opposite_hand, parse_tap_hold_opposite_hand_release,
};
mod deflocalkeys;
use deflocalkeys::*;
mod defsrc;
Expand Down Expand Up @@ -1529,6 +1531,7 @@ fn parse_action_list(ac: &[SExpr], s: &ParserState) -> Result<&'static KanataAct
parse_tap_hold_keys(&ac[1..], s, TAP_HOLD_TAP_KEYS, custom_tap_hold_tap_keys)
}
TAP_HOLD_OPPOSITE_HAND => parse_tap_hold_opposite_hand(&ac[1..], s),
TAP_HOLD_OPPOSITE_HAND_RELEASE => parse_tap_hold_opposite_hand_release(&ac[1..], s),
MULTI => parse_multi(&ac[1..], s),
MACRO => parse_macro(&ac[1..], s, RepeatMacro::No),
MACRO_REPEAT | MACRO_REPEAT_A => parse_macro(&ac[1..], s, RepeatMacro::Yes),
Expand Down
16 changes: 13 additions & 3 deletions src/main_lib/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,19 @@ pub(crate) fn list_devices_macos() {
return;
}

println!(
"\nTo address devices with empty names (product key), hash values can be used in the configuration!"
);
let has_empty_names = kb_list.iter().any(|k| k.product_key.trim().is_empty());
if has_empty_names {
println!(
"\nTo address devices with empty names (product key), hash values can be used in the configuration!"
);
}

if kb_list.iter().any(|k| k.product_key.contains("Karabiner")) {
println!(
"\nNote: Karabiner virtual devices detected. Device hashes may not be stable \
because Karabiner virtualizes HID devices. Prefer name-based matching."
);
}

println!("\nConfiguration example:");
println!(" (defcfg");
Expand Down
8 changes: 7 additions & 1 deletion src/oskbd/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl From<InputEvent> for DKEvent {
value: event.value,
page: event.page,
code: event.code,
device_hash: 0,
}
}
}
Expand Down Expand Up @@ -79,6 +80,7 @@ impl KbdIn {

// Based on the definition of include and exclude names, they should never be used together.
// Kanata config parser should probably enforce this.
let has_device_filter = include_names.is_some() || exclude_names.is_some();
let device_names = if let Some(included_names) = include_names {
validate_and_register_devices(included_names)
} else if let Some(excluded_names) = exclude_names {
Expand All @@ -104,7 +106,10 @@ impl KbdIn {
vec![]
};

if !device_names.is_empty() || register_device("") {
// When an include/exclude list is configured but no devices matched,
// do NOT fall back to registering all devices. Only use the catch-all
// register_device("") when no device filter was specified at all.
if !device_names.is_empty() || (!has_device_filter && register_device("")) {
if grab() {
Ok(Self { grabbed: true })
} else {
Expand All @@ -123,6 +128,7 @@ impl KbdIn {
value: 0,
page: 0,
code: 0,
device_hash: 0,
};

let got_event = wait_key(&mut event);
Expand Down
Loading
Loading