Skip to content

Commit 2953ff4

Browse files
committed
feat(macos): implement mouse button input and side button output support
Enable mouse buttons (mlft, mrgt, mmid, mbck, mfwd) in defsrc on macOS by adding a CGEventTap that intercepts and remaps mouse button events. The tap only installs if the config maps mouse buttons Fix mbck and mfwd output actions which previously panicked on macOS Requires Accessibility or Input Monitoring permission on macOS Update docs/config.adoc, platform-known-issues.adoc, and release-template.md to reflect macOS mouse support
1 parent 1681edb commit 2953ff4

File tree

7 files changed

+222
-25
lines changed

7 files changed

+222
-25
lines changed

Cargo.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ core-graphics = "0.24.0"
6969
open = { version = "5", optional = true }
7070
libc = "0.2"
7171
os_pipe = "1.2.1"
72+
core-foundation = "0.10.1"
7273

7374
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
7475
evdev = "0.13.0"

docs/config.adoc

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,7 +1408,7 @@ The mouse button actions are:
14081408
* `mbck`: backward mouse button
14091409

14101410
The mouse button will be held while the key mapped to it is held.
1411-
Only on Linux and Windows,
1411+
On Linux, Windows, and macOS,
14121412
the above actions are also usable in `defsrc`
14131413
to enable remapping specified mouse actions in your layers,
14141414
like you would with keyboard keys.
@@ -1475,14 +1475,17 @@ to remap scroll events as if they were keys,
14751475
corresponding to up, down, left, right respectively:
14761476
`mwu`, `mwd`, `mwl`, `mwr`.
14771477

1478-
The remapping of mouse events is only effective on Linux and Windows.
1478+
The remapping of mouse button events is effective on Linux, Windows, and macOS.
1479+
Mouse wheel remapping is currently only effective on Linux and Windows.
14791480

14801481
NOTE:
1481-
On Windows, the Kanata process must be restarted
1482+
On Windows and macOS, the Kanata process must be restarted
14821483
for it to begin or to stop handling mouse events;
14831484
changing defsrc then live-reloading will not
14841485
begin handling mouse events
14851486
if defsrc previously did not have any mouse events in defsrc.
1487+
On macOS, mouse button input requires Accessibility or Input Monitoring
1488+
permission in System Settings > Privacy & Security.
14861489

14871490
**Description**
14881491

docs/platform-known-issues.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,5 @@ and explicitly map keys in `defsrc` instead
8888

8989
== MacOS
9090

91-
* Only left, right, and middle mouse buttons are implemented for clicking
92-
* Mouse input processing is not implemented, e.g. putting `mlft` into `defsrc` does nothing
91+
* Mouse input processing requires Accessibility or Input Monitoring permission in System Settings > Privacy & Security
92+
* Mouse wheel input is not yet supported in `defsrc`

docs/release-template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Explanation of items in the binary variant:
110110

111111
The supported Karabiner driver version in this release is `v6.2.0`.
112112

113-
**WARNING**: macOS does not support mouse as input. The `mbck` and `mfwd` mouse button actions are also not operational.
113+
**NOTE**: macOS mouse button input requires Accessibility or Input Monitoring permission in System Settings > Privacy & Security.
114114

115115
### Binary variants
116116

src/kanata/macos.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ impl Kanata {
4747

4848
info!("keyboard grabbed, entering event processing loop");
4949

50+
// Start the mouse event tap on a background thread if any mouse buttons
51+
// are mapped in the config. Similar to the Windows mouse hook.
52+
// The braces scope-drop the MAPPED_KEYS lock before entering the event loop;
53+
// the JoinHandle is dropped because the run loop runs for the process lifetime.
54+
{
55+
let mapped = MAPPED_KEYS.lock();
56+
let _ = crate::oskbd::start_mouse_listener(tx.clone(), &mapped);
57+
}
58+
5059
loop {
5160
// --- Event processing loop ---
5261
let needs_recovery = loop {

src/oskbd/macos.rs

Lines changed: 198 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ use super::*;
1111
use crate::kanata::CalculatedMouseMove;
1212
use crate::oskbd::KeyEvent;
1313
use anyhow::anyhow;
14+
use core_foundation::runloop::{CFRunLoop, kCFRunLoopCommonModes};
1415
use core_graphics::base::CGFloat;
1516
use core_graphics::display::{CGDisplay, CGPoint};
16-
use core_graphics::event::{CGEvent, CGEventTapLocation, CGEventType, CGMouseButton, EventField};
17+
use core_graphics::event::{
18+
CGEvent, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType,
19+
CGMouseButton, EventField,
20+
};
1721
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
22+
use kanata_parser::cfg::MappedKeys;
1823
use kanata_parser::custom_action::*;
1924
use kanata_parser::keys::*;
2025
use karabiner_driverkit::*;
@@ -25,6 +30,7 @@ use std::convert::TryFrom;
2530
use std::fmt;
2631
use std::io;
2732
use std::io::Error;
33+
use std::sync::mpsc::SyncSender as Sender;
2834
use std::time::{Duration, Instant};
2935

3036
#[derive(Debug, Clone, Copy)]
@@ -416,47 +422,79 @@ impl KbdOut {
416422
event.post(CGEventTapLocation::HID);
417423
Ok(())
418424
}
425+
/// Synthesize a mouse button press or release via CGEvent.
426+
///
427+
/// Side buttons (Backward/Forward) use OtherMouseDown/Up with
428+
/// CGMouseButton::Center as a placeholder, then override the
429+
/// MOUSE_EVENT_BUTTON_NUMBER field to the real index (3=Back, 4=Forward).
430+
/// The Rust CGMouseButton enum only has 3 variants but the underlying
431+
/// Apple API supports up to 32 buttons via this field.
432+
///
433+
/// Ref: [init(mouseEventSource:mouseType:mouseCursorPosition:mouseButton:)][1], [setIntegerValueField][2]
434+
///
435+
/// [1]: https://developer.apple.com/documentation/coregraphics/cgevent/init(mouseeventsource:mousetype:mousecursorposition:mousebutton:)
436+
/// [2]: https://developer.apple.com/documentation/coregraphics/cgevent/setintegervaluefield(_:value:)
419437
fn button_action(&mut self, _btn: Btn, is_click: bool) -> Result<(), io::Error> {
420-
let (event_type, button) = match _btn {
438+
// (event_type, placeholder_button, real_button_number_override)
439+
let (event_type, button, button_number) = match _btn {
421440
Btn::Left => (
422441
if is_click {
423442
CGEventType::LeftMouseDown
424443
} else {
425444
CGEventType::LeftMouseUp
426445
},
427-
Some(CGMouseButton::Left),
446+
CGMouseButton::Left,
447+
None,
428448
),
429449
Btn::Right => (
430450
if is_click {
431451
CGEventType::RightMouseDown
432452
} else {
433453
CGEventType::RightMouseUp
434454
},
435-
Some(CGMouseButton::Right),
455+
CGMouseButton::Right,
456+
None,
436457
),
437458
Btn::Mid => (
438459
if is_click {
439460
CGEventType::OtherMouseDown
440461
} else {
441462
CGEventType::OtherMouseUp
442463
},
443-
Some(CGMouseButton::Center),
464+
CGMouseButton::Center,
465+
None,
466+
),
467+
// Side buttons use OtherMouseDown/Up (same event type as middle click)
468+
// with the button number overridden after event creation.
469+
Btn::Backward => (
470+
if is_click {
471+
CGEventType::OtherMouseDown
472+
} else {
473+
CGEventType::OtherMouseUp
474+
},
475+
CGMouseButton::Center,
476+
Some(3), // USB HID button 4 -> CGEvent button 3 (0-indexed)
477+
),
478+
Btn::Forward => (
479+
if is_click {
480+
CGEventType::OtherMouseDown
481+
} else {
482+
CGEventType::OtherMouseUp
483+
},
484+
CGMouseButton::Center,
485+
Some(4), // USB HID button 5 -> CGEvent button 4 (0-indexed)
444486
),
445-
// It's unclear to me which event type to use here, hence unsupported for now
446-
Btn::Forward => (CGEventType::Null, None),
447-
Btn::Backward => (CGEventType::Null, None),
448487
};
449-
// CGEventType doesn't implement Eq, therefore the casting to u8
450-
if event_type as u8 == CGEventType::Null as u8 {
451-
panic!("mouse buttons other than left, right, and middle aren't currently supported")
452-
}
453488

454489
let event_source = Self::make_event_source()?;
455490
let event = Self::make_event()?;
456491
let mouse_position = event.location();
457-
let event =
458-
CGEvent::new_mouse_event(event_source, event_type, mouse_position, button.unwrap())
459-
.map_err(|_| std::io::Error::other("Failed to create mouse event"))?;
492+
let event = CGEvent::new_mouse_event(event_source, event_type, mouse_position, button)
493+
.map_err(|_| std::io::Error::other("Failed to create mouse event"))?;
494+
495+
if let Some(num) = button_number {
496+
event.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, num);
497+
}
460498

461499
// Mouse control only seems to work with CGEventTapLocation::HID.
462500
event.post(CGEventTapLocation::HID);
@@ -568,3 +606,148 @@ impl KbdOut {
568606
}
569607
}
570608
}
609+
610+
/// Convert a `(CGEventType, button_number)` pair from a CGEventTap into a
611+
/// kanata `KeyEvent`. The button number field is only meaningful for
612+
/// `OtherMouseDown`/`OtherMouseUp` (2=Middle, 3=Back, 4=Forward); Left/Right
613+
/// are determined entirely by the event type.
614+
impl TryFrom<(CGEventType, i64)> for KeyEvent {
615+
type Error = ();
616+
fn try_from((event_type, button_number): (CGEventType, i64)) -> Result<Self, ()> {
617+
use OsCode::*;
618+
let (code, value) = match event_type {
619+
CGEventType::LeftMouseDown => (BTN_LEFT, KeyValue::Press),
620+
CGEventType::LeftMouseUp => (BTN_LEFT, KeyValue::Release),
621+
CGEventType::RightMouseDown => (BTN_RIGHT, KeyValue::Press),
622+
CGEventType::RightMouseUp => (BTN_RIGHT, KeyValue::Release),
623+
CGEventType::OtherMouseDown | CGEventType::OtherMouseUp => {
624+
let code = match button_number {
625+
2 => BTN_MIDDLE,
626+
3 => BTN_SIDE,
627+
4 => BTN_EXTRA,
628+
_ => return Err(()),
629+
};
630+
let value = if matches!(event_type, CGEventType::OtherMouseDown) {
631+
KeyValue::Press
632+
} else {
633+
KeyValue::Release
634+
};
635+
(code, value)
636+
}
637+
_ => return Err(()),
638+
};
639+
Ok(KeyEvent { code, value })
640+
}
641+
}
642+
643+
/// Start a CGEventTap on a background thread to intercept mouse button events.
644+
/// macOS equivalent of the Windows mouse hook in `windows/llhook.rs`.
645+
///
646+
/// Mapped buttons are suppressed and forwarded to the processing channel;
647+
/// unmapped buttons pass through. Only installed if the config has mouse
648+
/// buttons in defsrc.
649+
///
650+
/// Requires Accessibility or Input Monitoring permission.
651+
pub fn start_mouse_listener(
652+
tx: Sender<KeyEvent>,
653+
mapped_keys: &MappedKeys,
654+
) -> Option<std::thread::JoinHandle<()>> {
655+
use OsCode::*;
656+
let mouse_oscodes = [BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA];
657+
// Copy only the mouse-relevant mapped keys for the callback closure.
658+
let mapped: MappedKeys = mapped_keys
659+
.iter()
660+
.copied()
661+
.filter(|k| mouse_oscodes.contains(k))
662+
.collect();
663+
if mapped.is_empty() {
664+
log::info!("No mouse buttons in defsrc. Not installing mouse event tap.");
665+
return None;
666+
}
667+
668+
let handle = std::thread::Builder::new()
669+
.name("mouse-event-tap".into())
670+
.spawn(move || {
671+
let events_of_interest = vec![
672+
CGEventType::LeftMouseDown,
673+
CGEventType::LeftMouseUp,
674+
CGEventType::RightMouseDown,
675+
CGEventType::RightMouseUp,
676+
CGEventType::OtherMouseDown,
677+
CGEventType::OtherMouseUp,
678+
];
679+
680+
let tap = match CGEventTap::new(
681+
CGEventTapLocation::HID,
682+
CGEventTapPlacement::HeadInsertEventTap,
683+
CGEventTapOptions::Default,
684+
events_of_interest,
685+
// Callback receives &CGEvent; return Some(clone) to pass through,
686+
// None to suppress the event.
687+
move |_proxy, event_type, event| {
688+
let button_number =
689+
event.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
690+
let mut key_event = match KeyEvent::try_from((event_type, button_number)) {
691+
Ok(ev) => ev,
692+
Err(()) => return Some(event.clone()),
693+
};
694+
695+
if !mapped.contains(&key_event.code) {
696+
return Some(event.clone());
697+
}
698+
699+
// Track pressed state to convert duplicate presses into repeats,
700+
// matching the keyboard event loop behavior.
701+
match key_event.value {
702+
KeyValue::Release => {
703+
crate::kanata::PRESSED_KEYS.lock().remove(&key_event.code);
704+
}
705+
KeyValue::Press => {
706+
let mut pressed_keys = crate::kanata::PRESSED_KEYS.lock();
707+
if pressed_keys.contains(&key_event.code) {
708+
key_event.value = KeyValue::Repeat;
709+
} else {
710+
pressed_keys.insert(key_event.code);
711+
}
712+
}
713+
_ => {}
714+
}
715+
716+
log::debug!("mouse tap: {key_event:?}");
717+
718+
if let Err(e) = tx.try_send(key_event) {
719+
log::warn!("mouse tap: failed to send event: {e}");
720+
return Some(event.clone());
721+
}
722+
723+
// Suppress the original event so it doesn't reach the system.
724+
None
725+
},
726+
) {
727+
Ok(tap) => tap,
728+
Err(()) => {
729+
log::error!(
730+
"Failed to create mouse event tap. \
731+
Ensure kanata has Accessibility or Input Monitoring permission \
732+
in System Settings > Privacy & Security."
733+
);
734+
return;
735+
}
736+
};
737+
738+
let loop_source = tap
739+
.mach_port
740+
.create_runloop_source(0)
741+
.expect("failed to create CFRunLoop source for mouse event tap");
742+
// Safety: kCFRunLoopCommonModes is an extern static from CoreFoundation.
743+
// Accessing it requires unsafe but is always valid in a running process.
744+
let mode = unsafe { kCFRunLoopCommonModes };
745+
CFRunLoop::get_current().add_source(&loop_source, mode);
746+
tap.enable();
747+
log::info!("Mouse event tap installed and active.");
748+
CFRunLoop::run_current();
749+
})
750+
.expect("failed to spawn mouse event tap thread");
751+
752+
Some(handle)
753+
}

0 commit comments

Comments
 (0)