mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 14:20:42 -07:00
fix(capture): layout-aware V keycode for synthetic paste on macOS
macOS apps match Cmd+V against the layout-translated character via NSMenu key equivalents, so posting kVK_ANSI_V (= 9, the QWERTY V position) on Dvorak produces Cmd+. and never triggers Paste. New keyboard_layout module resolves the active layout's V keycode via TISCopyCurrentKeyboardLayoutInputSource + UCKeyTranslate, caches it in an AtomicU16, and refreshes on kTISNotifySelectedKeyboardInputSourceChanged. All TIS calls run on the main thread (init from Tauri setup; observer callback delivered to the main runloop); synthetic_keys::send_paste reads the cached value once per paste. Falls back to kVK_ANSI_V when resolution fails or the active input source carries no Unicode key layout data. Windows is intentionally left on hardcoded VK_V — SendInput delivers WM_KEYDOWN with wParam = VK_V to the target regardless of the active layout, which is why `Send "^v"` works for AutoHotkey on Dvorak Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0b2a3cdc78
commit
53a7693101
@@ -0,0 +1,183 @@
|
||||
//! Layout-aware resolution of the keycode whose current-layout translation
|
||||
//! is `'v'`. Drives [`crate::synthetic_keys::send_paste`] so the synthetic
|
||||
//! Cmd+V it posts is interpreted as Paste by the focused app regardless of
|
||||
//! the user's active keyboard layout (Dvorak, Colemak, AZERTY, …).
|
||||
//!
|
||||
//! macOS apps process Cmd+V via NSMenu key equivalents, which match against
|
||||
//! `[NSEvent charactersIgnoringModifiers]` — i.e. the layout-translated
|
||||
//! character, not the raw keycode. Posting `kVK_ANSI_V` (= 9, the QWERTY V
|
||||
//! position) on Dvorak therefore produces Cmd+. and never triggers Paste.
|
||||
//!
|
||||
//! All TIS calls happen on the main thread: once at startup via [`init`]
|
||||
//! from Tauri's setup hook, and again from the
|
||||
//! `kTISNotifySelectedKeyboardInputSourceChanged` distributed notification
|
||||
//! (delivered to the main runloop). The hot path ([`paste_keycode_v`])
|
||||
//! only reads an [`AtomicU16`], so paste latency is unchanged.
|
||||
//!
|
||||
//! Windows is intentionally not covered here. `SendInput` with
|
||||
//! `wVk = VK_V` delivers `WM_KEYDOWN` to the target with `wParam = VK_V`
|
||||
//! regardless of the active layout — most Windows apps treat that as
|
||||
//! Ctrl+V. AutoHotkey relies on the same behaviour.
|
||||
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
|
||||
/// `kVK_ANSI_V` — the keycode for the physical V key on a US QWERTY
|
||||
/// layout. Used as the fallback whenever live resolution can't produce a
|
||||
/// better answer (no Unicode key layout data, lookup failure, non-macOS).
|
||||
const FALLBACK_V_KEYCODE: u16 = 9;
|
||||
|
||||
static V_KEYCODE: AtomicU16 = AtomicU16::new(FALLBACK_V_KEYCODE);
|
||||
|
||||
/// Returns the keycode whose current-layout translation is `'v'`. Falls
|
||||
/// back to `kVK_ANSI_V` when resolution hasn't run, the active input
|
||||
/// source carries no Unicode key layout data, or no keycode in the layout
|
||||
/// produces `v`.
|
||||
pub fn paste_keycode_v() -> u16 {
|
||||
V_KEYCODE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn init() {
|
||||
macos::init();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn init() {}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use super::{FALLBACK_V_KEYCODE, V_KEYCODE};
|
||||
use core_foundation_sys::base::CFRelease;
|
||||
use core_foundation_sys::data::{CFDataGetBytePtr, CFDataRef};
|
||||
use core_foundation_sys::dictionary::CFDictionaryRef;
|
||||
use core_foundation_sys::notification_center::{
|
||||
CFNotificationCenterAddObserver, CFNotificationCenterGetDistributedCenter,
|
||||
CFNotificationCenterRef, CFNotificationName,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately,
|
||||
};
|
||||
use core_foundation_sys::string::CFStringRef;
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
type TISInputSourceRef = *mut c_void;
|
||||
|
||||
/// `kUCKeyActionDown`.
|
||||
const K_UC_KEY_ACTION_DOWN: u16 = 0;
|
||||
/// `kUCKeyTranslateNoDeadKeysMask` — collapse dead-key state machine so
|
||||
/// a single call gives us the bare character. V is never a dead key on
|
||||
/// any layout we care about, but the flag costs nothing and removes
|
||||
/// any chance of ambiguous output.
|
||||
const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK: u32 = 1;
|
||||
/// Standard US-style virtual keycodes occupy 0..0x7F. We iterate the
|
||||
/// full range so non-US-extended layouts (ISO, JIS) can still be
|
||||
/// resolved if their `v` lives outside the ANSI range.
|
||||
const MAX_KEYCODE: u16 = 127;
|
||||
const TARGET_CHAR: u16 = b'v' as u16;
|
||||
|
||||
#[link(name = "Carbon", kind = "framework")]
|
||||
extern "C" {
|
||||
fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef;
|
||||
fn TISGetInputSourceProperty(
|
||||
source: TISInputSourceRef,
|
||||
key: CFStringRef,
|
||||
) -> *mut c_void;
|
||||
fn LMGetKbdType() -> u8;
|
||||
fn UCKeyTranslate(
|
||||
keyboard_layout: *const u8,
|
||||
virtual_key_code: u16,
|
||||
key_action: u16,
|
||||
modifier_key_state: u32,
|
||||
keyboard_type: u32,
|
||||
key_translate_options: u32,
|
||||
dead_key_state: *mut u32,
|
||||
max_string_length: usize,
|
||||
actual_string_length: *mut usize,
|
||||
unicode_string: *mut u16,
|
||||
) -> i32;
|
||||
|
||||
static kTISPropertyUnicodeKeyLayoutData: CFStringRef;
|
||||
static kTISNotifySelectedKeyboardInputSourceChanged: CFStringRef;
|
||||
}
|
||||
|
||||
pub fn init() {
|
||||
resolve_into_cache();
|
||||
register_layout_change_observer();
|
||||
}
|
||||
|
||||
fn resolve_into_cache() {
|
||||
let kc = resolve_v_keycode().unwrap_or(FALLBACK_V_KEYCODE);
|
||||
V_KEYCODE.store(kc, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn resolve_v_keycode() -> Option<u16> {
|
||||
unsafe {
|
||||
let source = TISCopyCurrentKeyboardLayoutInputSource();
|
||||
if source.is_null() {
|
||||
return None;
|
||||
}
|
||||
let _src_guard = scopeguard::guard(source, |s| CFRelease(s as *const c_void));
|
||||
|
||||
let layout_data_ptr =
|
||||
TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData);
|
||||
if layout_data_ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
let layout_bytes = CFDataGetBytePtr(layout_data_ptr as CFDataRef);
|
||||
if layout_bytes.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kbd_type = LMGetKbdType() as u32;
|
||||
|
||||
for keycode in 0..=MAX_KEYCODE {
|
||||
let mut dead_key_state: u32 = 0;
|
||||
let mut chars: [u16; 4] = [0; 4];
|
||||
let mut actual_len: usize = 0;
|
||||
let status = UCKeyTranslate(
|
||||
layout_bytes,
|
||||
keycode,
|
||||
K_UC_KEY_ACTION_DOWN,
|
||||
0, // no modifiers
|
||||
kbd_type,
|
||||
K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK,
|
||||
&mut dead_key_state,
|
||||
chars.len(),
|
||||
&mut actual_len,
|
||||
chars.as_mut_ptr(),
|
||||
);
|
||||
if status == 0 && actual_len == 1 && chars[0] == TARGET_CHAR {
|
||||
return Some(keycode);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn layout_changed(
|
||||
_center: CFNotificationCenterRef,
|
||||
_observer: *mut c_void,
|
||||
_name: CFNotificationName,
|
||||
_object: *const c_void,
|
||||
_user_info: CFDictionaryRef,
|
||||
) {
|
||||
resolve_into_cache();
|
||||
}
|
||||
|
||||
fn register_layout_change_observer() {
|
||||
unsafe {
|
||||
let center = CFNotificationCenterGetDistributedCenter();
|
||||
if center.is_null() {
|
||||
return;
|
||||
}
|
||||
CFNotificationCenterAddObserver(
|
||||
center,
|
||||
ptr::null(),
|
||||
layout_changed,
|
||||
kTISNotifySelectedKeyboardInputSourceChanged,
|
||||
ptr::null(),
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod hotkey_monitor;
|
||||
mod input_monitoring;
|
||||
#[cfg(desktop)]
|
||||
mod key_codes;
|
||||
mod keyboard_layout;
|
||||
mod speak_monitor;
|
||||
mod synthetic_keys;
|
||||
|
||||
@@ -1220,6 +1221,14 @@ pub fn run() {
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
|
||||
// Resolve the active keyboard layout's V keycode now, on
|
||||
// the main thread, and register an observer for layout
|
||||
// changes. The synthetic-paste hot path then only reads an
|
||||
// atomic. See keyboard_layout.rs for why this matters
|
||||
// (Cmd+V is matched by translated character, not keycode,
|
||||
// so QWERTY keycode 9 produces Cmd+. on Dvorak).
|
||||
keyboard_layout::init();
|
||||
|
||||
// HotkeyMonitor is spawned lazily via the `enable_hotkey`
|
||||
// command — see HotkeyState. The dictate pill webview is
|
||||
// built in the same lazy path so we don't pay setup cost
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
//! windows when we run non-elevated — nothing we can do short of also
|
||||
//! running elevated.
|
||||
//!
|
||||
//! The virtual keycode used for V is `kVK_ANSI_V` (9) on macOS and `VK_V`
|
||||
//! (0x56) on Windows. Both are layout-dependent — they mean "the physical
|
||||
//! key in the QWERTY V position" — so on Dvorak / Colemak this would fire
|
||||
//! the wrong shortcut. A later pass will resolve the current layout's V
|
||||
//! keycode per-platform (`TISCopyCurrentKeyboardInputSource` +
|
||||
//! `UCKeyTranslate` on macOS; `VkKeyScanExW` on Windows).
|
||||
//! On macOS the V keycode is resolved per-layout by
|
||||
//! [`crate::keyboard_layout`] — Cmd+V is matched against the layout-
|
||||
//! translated character via NSMenu key equivalents, so hardcoding
|
||||
//! `kVK_ANSI_V` (the QWERTY V position) would fire Cmd+. on Dvorak. The
|
||||
//! resolved keycode is read once per paste from an atomic; the cache is
|
||||
//! primed at startup and refreshed on layout change.
|
||||
//!
|
||||
//! Windows hardcodes `VK_V`. `SendInput` with `wVk = VK_V` makes the
|
||||
//! target receive `WM_KEYDOWN` with `wParam = VK_V` regardless of the
|
||||
//! active layout, and most Windows apps treat that as Ctrl+V (the same
|
||||
//! reason `Send "^v"` works in AutoHotkey on Dvorak Windows).
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::ffi::c_void;
|
||||
@@ -56,8 +61,6 @@ mod ffi {
|
||||
/// `kCGEventFlagMaskCommand` — the Cmd modifier bit inside `CGEventFlags`.
|
||||
pub const K_CG_EVENT_FLAG_MASK_COMMAND: CGEventFlags = 0x00100000;
|
||||
|
||||
/// `kVK_ANSI_V`.
|
||||
pub const KEYCODE_V: CGKeyCode = 9;
|
||||
/// `kVK_Command` (left Cmd).
|
||||
pub const KEYCODE_LEFT_CMD: CGKeyCode = 0x37;
|
||||
|
||||
@@ -88,6 +91,8 @@ mod ffi {
|
||||
pub fn send_paste() -> Result<(), String> {
|
||||
use ffi::*;
|
||||
|
||||
let v_keycode = crate::keyboard_layout::paste_keycode_v();
|
||||
|
||||
unsafe {
|
||||
let source = CGEventSourceCreate(K_CG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE);
|
||||
if source.is_null() {
|
||||
@@ -97,8 +102,8 @@ pub fn send_paste() -> Result<(), String> {
|
||||
|
||||
let events = [
|
||||
(KEYCODE_LEFT_CMD, true, 0),
|
||||
(KEYCODE_V, true, K_CG_EVENT_FLAG_MASK_COMMAND),
|
||||
(KEYCODE_V, false, K_CG_EVENT_FLAG_MASK_COMMAND),
|
||||
(v_keycode, true, K_CG_EVENT_FLAG_MASK_COMMAND),
|
||||
(v_keycode, false, K_CG_EVENT_FLAG_MASK_COMMAND),
|
||||
(KEYCODE_LEFT_CMD, false, 0),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user