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
8 changes: 6 additions & 2 deletions docs/config.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4529,7 +4529,7 @@ the swapped "i" and "o" keys.
----

[[mouse-movement-key]]
=== Linux or Windows-interception only: mouse-movement-key
=== Linux, macOS, or Windows-interception only: mouse-movement-key

Accepts a single key name.
When configured, whenever a mouse cursor movement is received,
Expand All @@ -4552,11 +4552,15 @@ The `mvmt` key name is specially intended for this purpose. It has no
output key mapping and cannot be supplied as an action; however, any
key may be used.

Supports live reload on Linux, but with Windows-interception, this
Supports live reload on Linux and macOS. With Windows-interception, this
option must be present on startup to enable mouse movement event
collection, so restart is required to enable it. Changing the key name
is always supported, however.

On macOS, this feature uses the same CGEventTap as the existing mouse
button input support, and so requires the same Accessibility or Input
Monitoring permission in System Settings > Privacy & Security.

.Example:
[source]
----
Expand Down
3 changes: 3 additions & 0 deletions parser/src/cfg/defcfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ pub struct CfgOptions {
all(target_os = "windows", feature = "interception_driver"),
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "unknown"
))]
pub mouse_movement_key: Option<OsCode>,
Expand Down Expand Up @@ -207,6 +208,7 @@ impl Default for CfgOptions {
all(target_os = "windows", feature = "interception_driver"),
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "unknown"
))]
mouse_movement_key: None,
Expand Down Expand Up @@ -906,6 +908,7 @@ pub fn parse_defcfg(expr: &[SExpr]) -> Result<CfgOptions> {
all(target_os = "windows", feature = "interception_driver"),
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "unknown"
))]
{
Expand Down
28 changes: 28 additions & 0 deletions parser/src/cfg/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2098,3 +2098,31 @@ fn parse_clipboard_actions() {
";
parse_cfg(source).map(|_| ()).expect("success");
}

/// `mouse-movement-key` parses on every platform that supports the feature
/// (Linux, Android, macOS, Windows-interception, unknown). Regression guard
/// for the macOS cfg gate previously omitting `target_os = "macos"`, which
/// caused the option to fail with "Unknown defcfg option" on macOS.
#[cfg(any(
all(target_os = "windows", feature = "interception_driver"),
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "unknown"
))]
#[test]
fn parse_mouse_movement_key() {
let source = "
(defcfg
process-unmapped-keys yes
mouse-movement-key mvmt
)
(defsrc a mvmt)
(deflayer base a _)
";
let icfg = parse_cfg(source).expect("parses");
assert_eq!(
icfg.options.mouse_movement_key,
Some(crate::keys::OsCode::KEY_766),
);
}
7 changes: 6 additions & 1 deletion src/kanata/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,14 @@ impl Kanata {
// `do_live_reload`). The tap callback re-reads `MAPPED_KEYS` per event,
// so reloads that change *which* mouse keys are mapped also take
// effect without restart.
//
// Clone the mouse_movement_key Arc *before* locking MAPPED_KEYS to keep
// the project-wide lock order `kanata -> MAPPED_KEYS`. Reversing it here
// would create a new ordering edge with the rest of this file.
{
let mmk = kanata.lock().mouse_movement_key.clone();
let mapped = MAPPED_KEYS.lock();
let _ = crate::oskbd::start_mouse_listener(tx.clone(), &mapped);
let _ = crate::oskbd::start_mouse_listener(tx.clone(), &mapped, mmk);
}

loop {
Expand Down
14 changes: 11 additions & 3 deletions src/kanata/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,10 @@ pub struct Kanata {
all(target_os = "windows", feature = "interception_driver"),
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "unknown"
))]
mouse_movement_key: Arc<Mutex<Option<OsCode>>>,
pub(crate) mouse_movement_key: Arc<Mutex<Option<OsCode>>>,
/// Time when kanata started (for uptime tracking)
#[cfg(feature = "tcp_server")]
start_time: web_time::Instant,
Expand Down Expand Up @@ -544,6 +545,7 @@ impl Kanata {
#[cfg(any(
all(target_os = "windows", feature = "interception_driver"),
any(target_os = "linux", target_os = "android"),
target_os = "macos",
target_os = "unknown"
))]
mouse_movement_key: Arc::new(Mutex::new(cfg.options.mouse_movement_key)),
Expand Down Expand Up @@ -695,6 +697,7 @@ impl Kanata {
all(target_os = "windows", feature = "interception_driver"),
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "unknown"
))]
mouse_movement_key: Arc::new(Mutex::new(cfg.options.mouse_movement_key)),
Expand Down Expand Up @@ -778,8 +781,9 @@ impl Kanata {
*MAPPED_KEYS.lock() = cfg.mapped_keys;
#[cfg(any(target_os = "linux", target_os = "android"))]
Kanata::set_repeat_rate(cfg.options.linux_opts.linux_x11_repeat_delay_rate)?;
#[cfg(target_os = "macos")]
crate::oskbd::ensure_mouse_listener_installed_after_reload();
// The macOS mouse-tap reload hook is invoked further down, *after* the
// `mouse_movement_key` mutate, so its install gate sees fresh state
// for both `MAPPED_KEYS` and `mouse_movement_key`.
log::info!("Live reload successful");
#[cfg(feature = "tcp_server")]
if let Some(tx) = _tx {
Expand Down Expand Up @@ -808,6 +812,7 @@ impl Kanata {
all(target_os = "windows", feature = "interception_driver"),
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "unknown"
))]
{
Expand All @@ -823,6 +828,9 @@ impl Kanata {
}

*self.mouse_movement_key.lock() = cfg.options.mouse_movement_key;

#[cfg(target_os = "macos")]
crate::oskbd::ensure_mouse_listener_installed_after_reload();
}

PRESSED_KEYS.lock().clear();
Expand Down
142 changes: 114 additions & 28 deletions src/oskbd/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,28 @@ const MOUSE_OSCODES: [OsCode; 9] = [
/// later live reload without needing the original `event_loop` context.
static MOUSE_TAP_TX: OnceLock<Sender<KeyEvent>> = OnceLock::new();

/// Tracks whether the CGEventTap thread is currently running. Claimed via
/// `compare_exchange` to make installation idempotent across concurrent
/// reloads. Reset to `false` if `CGEventTap::new` fails so a future reload
/// (e.g. after the user grants Accessibility permission) can retry.
/// Tracks whether `start_mouse_listener` has *claimed* the install slot —
/// i.e. promised to spawn a thread that will create and enable a CGEventTap.
/// Claimed via `compare_exchange` *before* `thread::spawn` so a concurrent
/// live reload cannot race in and install a second tap during the brief
/// window before the spawned thread reaches `tap.enable()`. Reset to `false`
/// if `CGEventTap::new` fails, so a future reload (e.g. after the user grants
/// Accessibility permission) can retry.
///
/// Note that "claimed" is slightly stronger than "currently capturing
/// events": there is a sub-millisecond gap between the claim and
/// `tap.enable()` during which no events flow yet. Reload callers
/// short-circuit in that gap, which is correct because the spawned thread
/// will deliver the working tap regardless.
static MOUSE_TAP_INSTALLED: AtomicBool = AtomicBool::new(false);

/// Stashed by the first `start_mouse_listener` call so the CGEventTap callback
/// can read the live `mouse-movement-key` setting on every cursor movement
/// event. The Arc points to the same `parking_lot::Mutex` that the live-reload
/// path updates, so changes take effect with no extra plumbing.
static MOUSE_MOVEMENT_KEY: OnceLock<std::sync::Arc<parking_lot::Mutex<Option<OsCode>>>> =
OnceLock::new();

#[derive(Debug, Clone, Copy)]
pub struct InputEvent {
pub value: u64,
Expand Down Expand Up @@ -721,32 +737,61 @@ fn scroll_event_to_key_event(event: &CGEvent) -> Option<KeyEvent> {
})
}

/// Start a CGEventTap on a background thread to intercept mouse button events.
/// macOS equivalent of the Windows mouse hook in `windows/llhook.rs`.
/// Start a CGEventTap on a background thread to intercept mouse button events
/// and (optionally) cursor movement events. macOS equivalent of the Windows
/// mouse hook in `windows/llhook.rs` plus the cursor-movement branch of the
/// Linux event loop.
///
/// Mapped buttons are suppressed and forwarded to the processing channel;
/// unmapped buttons pass through. Only installed if the config has mouse
/// buttons in defsrc.
/// unmapped buttons pass through. If `mouse_movement_key` is `Some`, every
/// cursor movement (including drags) sends a synthetic `Tap` of the configured
/// `OsCode` on the channel without suppressing the underlying movement event.
///
/// Only installed if the config has mouse buttons in defsrc OR
/// `mouse-movement-key` is configured.
///
/// Requires Accessibility or Input Monitoring permission.
pub fn start_mouse_listener(
tx: Sender<KeyEvent>,
mapped_keys: &MappedKeys,
mouse_movement_key: std::sync::Arc<parking_lot::Mutex<Option<OsCode>>>,
) -> Option<std::thread::JoinHandle<()>> {
// Stash the tx unconditionally so a later live reload that introduces
// mouse keys can install the tap via
// `ensure_mouse_listener_installed_after_reload` without needing the
// original `event_loop` context.
let _ = MOUSE_TAP_TX.set(tx.clone());

if !MOUSE_OSCODES.iter().any(|c| mapped_keys.contains(c)) {
log::info!("No mouse buttons or wheel in defsrc. Not installing mouse event tap.");
// Stash both unconditionally so the reload helper always has them, even
// if this initial call bails on the install gate. `OnceLock::set` is a
// no-op on subsequent calls — we rely on the single-process,
// single-Kanata assumption: the inner `parking_lot::Mutex` is shared with
// `do_live_reload`, so reloads mutate the *value*, never replace the
// Arc. The `debug_assert!` surfaces accidental violations in test builds.
let tx_was_unset = MOUSE_TAP_TX.set(tx.clone()).is_ok();
let _ = MOUSE_MOVEMENT_KEY.set(mouse_movement_key.clone());
debug_assert!(
tx_was_unset
|| std::sync::Arc::ptr_eq(
MOUSE_MOVEMENT_KEY
.get()
.expect("set above or already present"),
&mouse_movement_key,
),
"start_mouse_listener called twice with a different mouse_movement_key Arc — \
the previously stashed Arc would be silently kept"
);

let has_mouse_keys = MOUSE_OSCODES.iter().any(|c| mapped_keys.contains(c));
let has_movement_key = mouse_movement_key.lock().is_some();
if !has_mouse_keys && !has_movement_key {
log::info!(
"No mouse buttons/wheel in defsrc and no mouse-movement-key configured. \
Not installing mouse event tap."
);
return None;
}

// Claim the install slot. If another thread already installed the tap,
// bail out — the existing tap reads `MAPPED_KEYS` live so it already
// covers any newly mapped mouse keys.
// Claim the install slot atomically *before* spawning. Closes the race
// where a live reload could observe `MOUSE_TAP_INSTALLED == false` between
// the spawn here and the spawned thread's `tap.enable()`, and try to
// install a second tap. If the claim fails, an installation is already in
// progress (or completed) — the running tap reads both globals live, so
// this caller has nothing to do.
if MOUSE_TAP_INSTALLED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
Expand All @@ -765,6 +810,10 @@ pub fn start_mouse_listener(
CGEventType::OtherMouseDown,
CGEventType::OtherMouseUp,
CGEventType::ScrollWheel,
CGEventType::MouseMoved,
CGEventType::LeftMouseDragged,
CGEventType::RightMouseDragged,
CGEventType::OtherMouseDragged,
];

let tap = match CGEventTap::new(
Expand All @@ -775,6 +824,38 @@ pub fn start_mouse_listener(
// Callback receives &CGEvent; return Some(clone) to pass through,
// None to suppress the event.
move |_proxy, event_type, event| {
// Cursor movement (incl. drags while a button is held).
// Always pass through — never suppress, or the cursor freezes.
if matches!(
event_type,
CGEventType::MouseMoved
| CGEventType::LeftMouseDragged
| CGEventType::RightMouseDragged
| CGEventType::OtherMouseDragged
) {
// The Arc is stashed before this tap is created, so
// `get()` is `Some` in practice. Fall back to a plain
// pass-through if not, rather than panicking on the
// hot path.
let mmk_slot = match MOUSE_MOVEMENT_KEY.get() {
Some(slot) => slot,
None => return Some(event.clone()),
};
if let Some(code) = *mmk_slot.lock() {
let fake = KeyEvent {
code,
value: KeyValue::Tap,
};
if let Err(e) = tx.try_send(fake) {
// Drops are expected under high movement rates;
// the user only needs one tap to refresh their
// hold timer, so this is not user-visible.
log::trace!("mouse tap (movement): drop synthetic tap: {e}");
}
}
return Some(event.clone());
}

if matches!(event_type, CGEventType::ScrollWheel) {
let Some(key_event) = scroll_event_to_key_event(event) else {
return Some(event.clone());
Expand Down Expand Up @@ -852,6 +933,8 @@ pub fn start_mouse_listener(
let mode = unsafe { kCFRunLoopCommonModes };
CFRunLoop::get_current().add_source(&loop_source, mode);
tap.enable();
// MOUSE_TAP_INSTALLED was already set by the caller via
// compare_exchange before this thread was spawned.
log::info!("Mouse event tap installed and active.");
CFRunLoop::run_current();
})
Expand All @@ -860,22 +943,25 @@ pub fn start_mouse_listener(
Some(handle)
}

/// Install the mouse event tap if a live reload introduced mouse keys to
/// `MAPPED_KEYS` and the tap isn't already running. Idempotent: if the tap is
/// already running, the existing callback reads `MAPPED_KEYS` live so newly
/// mapped mouse keys take effect without reinstall.
///
/// Has no effect if the initial `start_mouse_listener` call hasn't run yet
/// (which would mean `event_loop` hasn't started — shouldn't happen, but
/// defended against).
/// Re-attempt installing the mouse event tap after a live reload. The running
/// tap callback already reads `MAPPED_KEYS` and `MOUSE_MOVEMENT_KEY` live, so
/// if the tap is already up there is nothing to do — but if a reload introduces
/// the first mouse key in defsrc or the first `mouse-movement-key` value, the
/// startup-time install gate may have skipped installation, and we need to
/// install now.
pub fn ensure_mouse_listener_installed_after_reload() {
if MOUSE_TAP_INSTALLED.load(Ordering::Acquire) {
// Existing tap reads both MAPPED_KEYS and MOUSE_MOVEMENT_KEY live.
return;
}
let Some(tx) = MOUSE_TAP_TX.get().cloned() else {
log::debug!("mouse tap reload hook: no tx stashed yet, skipping");
return;
};
let Some(mmk) = MOUSE_MOVEMENT_KEY.get().cloned() else {
log::debug!("mouse tap reload hook: no mouse_movement_key stashed yet, skipping");
return;
};
let mapped = crate::kanata::MAPPED_KEYS.lock();
let _ = start_mouse_listener(tx, &mapped);
let _ = start_mouse_listener(tx, &mapped, mmk);
}
Loading