From 228b9102be8743dd46c3f80b322459d81f78c7b1 Mon Sep 17 00:00:00 2001 From: Evgeny Oleynik Date: Tue, 2 Dec 2025 13:33:30 +0700 Subject: [PATCH 1/7] Fix paste not working on non-QWERTY keyboard layouts (Dvorak, Colemak, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text-writer used hardcoded keycode 9 (physical 'V' key on QWERTY) to simulate Cmd+V paste. On Dvorak and other layouts, this physical key produces a different character, so paste never triggered. This fix uses macOS TIS (Text Input Source) APIs to dynamically look up which physical keycode produces 'v' in the current keyboard layout, making the paste command work regardless of the active layout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- native/text-writer/src/keyboard_layout.rs | 112 ++++++++++++++++++++++ native/text-writer/src/macos_writer.rs | 10 +- native/text-writer/src/main.rs | 2 + 3 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 native/text-writer/src/keyboard_layout.rs diff --git a/native/text-writer/src/keyboard_layout.rs b/native/text-writer/src/keyboard_layout.rs new file mode 100644 index 00000000..bdd45d60 --- /dev/null +++ b/native/text-writer/src/keyboard_layout.rs @@ -0,0 +1,112 @@ +//! Keyboard layout utilities for macOS +//! +//! Uses TIS (Text Input Source) APIs to dynamically determine keycodes +//! for the current keyboard layout, enabling layout-independent shortcuts. + +use core_foundation::base::{CFRelease, TCFType}; +use core_foundation::data::CFData; +use std::collections::HashMap; + +// FFI declarations for Carbon/CoreServices APIs +#[link(name = "Carbon", kind = "framework")] +extern "C" { + fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut std::ffi::c_void; + fn TISGetInputSourceProperty( + input_source: *const std::ffi::c_void, + property_key: *const std::ffi::c_void, + ) -> *const std::ffi::c_void; + fn LMGetKbdType() -> u32; + static kTISPropertyUnicodeKeyLayoutData: *const std::ffi::c_void; +} + +#[link(name = "CoreServices", kind = "framework")] +extern "C" { + fn UCKeyTranslate( + key_layout_ptr: *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; +} + +const KUC_KEY_ACTION_DISPLAY: u16 = 3; +const KUC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT: u32 = 0; + +/// Default QWERTY keycode for 'V' key +const QWERTY_V_KEYCODE: u16 = 9; + +/// Build a lookup table mapping lowercase characters to their keycodes +/// for the current keyboard layout. +fn build_char_to_keycode_map() -> HashMap { + let mut map = HashMap::new(); + + unsafe { + let input_source = TISCopyCurrentKeyboardLayoutInputSource(); + if input_source.is_null() { + return map; + } + + let layout_data_ref = + TISGetInputSourceProperty(input_source, kTISPropertyUnicodeKeyLayoutData); + + if layout_data_ref.is_null() { + CFRelease(input_source); + return map; + } + + // Wrap the CFData without retaining (it's owned by input_source) + let layout_data: CFData = TCFType::wrap_under_get_rule(layout_data_ref as *const _); + let layout_ptr = layout_data.bytes().as_ptr(); + let kbd_type = LMGetKbdType(); + + // Iterate through keycodes 0-127 to build reverse lookup + for keycode in 0u16..128 { + let mut dead_key_state: u32 = 0; + let mut char_buf: [u16; 4] = [0; 4]; + let mut actual_len: usize = 0; + + let result = UCKeyTranslate( + layout_ptr, + keycode, + KUC_KEY_ACTION_DISPLAY, + 0, // no modifiers + kbd_type, + KUC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT, + &mut dead_key_state, + char_buf.len(), + &mut actual_len, + char_buf.as_mut_ptr(), + ); + + if result == 0 && actual_len == 1 { + if let Some(ch) = char::from_u32(u32::from(char_buf[0])) { + // Store lowercase version, prefer lower keycodes + map.entry(ch.to_ascii_lowercase()).or_insert(keycode); + } + } + } + + CFRelease(input_source); + } + + map +} + +/// Get the keycode for a character in the current keyboard layout. +/// Returns None if the character cannot be found. +pub fn keycode_for_char(ch: char) -> Option { + let map = build_char_to_keycode_map(); + map.get(&ch.to_ascii_lowercase()).copied() +} + +/// Get the keycode for 'v' in the current keyboard layout. +/// Falls back to QWERTY keycode (9) if lookup fails. +pub fn get_paste_keycode() -> u16 { + keycode_for_char('v').unwrap_or(QWERTY_V_KEYCODE) +} diff --git a/native/text-writer/src/macos_writer.rs b/native/text-writer/src/macos_writer.rs index 6969f128..4b285093 100644 --- a/native/text-writer/src/macos_writer.rs +++ b/native/text-writer/src/macos_writer.rs @@ -7,6 +7,8 @@ use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; use std::thread; use std::time::Duration; +use crate::keyboard_layout; + /// Type text on macOS using clipboard paste approach /// This avoids character-by-character typing which can cause issues in some /// apps @@ -51,11 +53,13 @@ pub fn type_text_macos(text: &str, _char_delay: u64) -> Result<(), String> { let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState) .map_err(|_| "Failed to create event source")?; + // Get layout-aware keycode for 'v' (works with Dvorak, Colemak, etc.) + let v_keycode = keyboard_layout::get_paste_keycode(); + // Simulate Cmd+V (paste) - // Key code 9 is 'V' key - let key_v_down = CGEvent::new_keyboard_event(source.clone(), 9, true) + let key_v_down = CGEvent::new_keyboard_event(source.clone(), v_keycode, true) .map_err(|_| "Failed to create key down event")?; - let key_v_up = CGEvent::new_keyboard_event(source.clone(), 9, false) + let key_v_up = CGEvent::new_keyboard_event(source.clone(), v_keycode, false) .map_err(|_| "Failed to create key up event")?; // Set the Command modifier flag diff --git a/native/text-writer/src/main.rs b/native/text-writer/src/main.rs index fadc13d5..eb60d6d8 100644 --- a/native/text-writer/src/main.rs +++ b/native/text-writer/src/main.rs @@ -6,6 +6,8 @@ use std::time::Duration; #[cfg(target_os = "linux")] use enigo::{Enigo, Key, Keyboard, Settings}; +#[cfg(target_os = "macos")] +mod keyboard_layout; #[cfg(target_os = "macos")] mod macos_writer; #[cfg(target_os = "macos")] From 3df537f2e2947f3e89982707e330245b84026658 Mon Sep 17 00:00:00 2001 From: Evgeny Oleynik Date: Tue, 2 Dec 2025 13:47:16 +0700 Subject: [PATCH 2/7] Cache keycode map with OnceLock for better performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard layout map is now built once on first access and cached using std::sync::OnceLock, avoiding redundant FFI calls on each paste. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- native/text-writer/src/keyboard_layout.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/native/text-writer/src/keyboard_layout.rs b/native/text-writer/src/keyboard_layout.rs index bdd45d60..057653c0 100644 --- a/native/text-writer/src/keyboard_layout.rs +++ b/native/text-writer/src/keyboard_layout.rs @@ -6,6 +6,10 @@ use core_foundation::base::{CFRelease, TCFType}; use core_foundation::data::CFData; use std::collections::HashMap; +use std::sync::OnceLock; + +/// Cached keycode map - built once on first access +static KEYCODE_MAP: OnceLock> = OnceLock::new(); // FFI declarations for Carbon/CoreServices APIs #[link(name = "Carbon", kind = "framework")] @@ -101,7 +105,7 @@ fn build_char_to_keycode_map() -> HashMap { /// Get the keycode for a character in the current keyboard layout. /// Returns None if the character cannot be found. pub fn keycode_for_char(ch: char) -> Option { - let map = build_char_to_keycode_map(); + let map = KEYCODE_MAP.get_or_init(build_char_to_keycode_map); map.get(&ch.to_ascii_lowercase()).copied() } From fdfe1af1ca771cdf0f57fd700b269d3b709f8877 Mon Sep 17 00:00:00 2001 From: Evgeny Oleynik Date: Tue, 2 Dec 2025 14:02:55 +0700 Subject: [PATCH 3/7] Use Apple-conformant FFI types for keyboard layout APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add UCKeyboardLayout opaque struct for proper type safety - Add CFTypeRef, OSStatus, UniCharCount type aliases - Update FFI signatures to match Apple documentation - Reference Text Input Sources and Unicode Utilities docs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- native/text-writer/src/keyboard_layout.rs | 44 +++++++++++++++-------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/native/text-writer/src/keyboard_layout.rs b/native/text-writer/src/keyboard_layout.rs index 057653c0..9f3f581e 100644 --- a/native/text-writer/src/keyboard_layout.rs +++ b/native/text-writer/src/keyboard_layout.rs @@ -6,37 +6,52 @@ use core_foundation::base::{CFRelease, TCFType}; use core_foundation::data::CFData; use std::collections::HashMap; +use std::ffi::c_void; use std::sync::OnceLock; /// Cached keycode map - built once on first access static KEYCODE_MAP: OnceLock> = OnceLock::new(); +// Apple type aliases for FFI correctness per Apple documentation +/// Opaque type for keyboard layout data structure (UCKeyboardLayout) +#[repr(C)] +struct UCKeyboardLayout { + _opaque: [u8; 0], +} + +/// Apple's CFTypeRef - opaque reference to any Core Foundation object +type CFTypeRef = *const c_void; + +/// Apple's OSStatus return type for Carbon APIs +type OSStatus = i32; + +/// Apple's UniCharCount for Unicode string lengths +type UniCharCount = usize; + // FFI declarations for Carbon/CoreServices APIs +// See: Apple Text Input Sources Reference, Unicode Utilities Reference #[link(name = "Carbon", kind = "framework")] extern "C" { - fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut std::ffi::c_void; - fn TISGetInputSourceProperty( - input_source: *const std::ffi::c_void, - property_key: *const std::ffi::c_void, - ) -> *const std::ffi::c_void; + fn TISCopyCurrentKeyboardLayoutInputSource() -> CFTypeRef; + fn TISGetInputSourceProperty(input_source: CFTypeRef, property_key: CFTypeRef) -> CFTypeRef; fn LMGetKbdType() -> u32; - static kTISPropertyUnicodeKeyLayoutData: *const std::ffi::c_void; + static kTISPropertyUnicodeKeyLayoutData: CFTypeRef; } #[link(name = "CoreServices", kind = "framework")] extern "C" { fn UCKeyTranslate( - key_layout_ptr: *const u8, + key_layout_ptr: *const UCKeyboardLayout, 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, + max_string_length: UniCharCount, + actual_string_length: *mut UniCharCount, unicode_string: *mut u16, - ) -> i32; + ) -> OSStatus; } const KUC_KEY_ACTION_DISPLAY: u16 = 3; @@ -60,13 +75,14 @@ fn build_char_to_keycode_map() -> HashMap { TISGetInputSourceProperty(input_source, kTISPropertyUnicodeKeyLayoutData); if layout_data_ref.is_null() { - CFRelease(input_source); + CFRelease(input_source.cast_mut()); return map; } // Wrap the CFData without retaining (it's owned by input_source) - let layout_data: CFData = TCFType::wrap_under_get_rule(layout_data_ref as *const _); - let layout_ptr = layout_data.bytes().as_ptr(); + let layout_data: CFData = TCFType::wrap_under_get_rule(layout_data_ref.cast()); + // Cast byte pointer to UCKeyboardLayout pointer per Apple documentation + let layout_ptr = layout_data.bytes().as_ptr().cast::(); let kbd_type = LMGetKbdType(); // Iterate through keycodes 0-127 to build reverse lookup @@ -96,7 +112,7 @@ fn build_char_to_keycode_map() -> HashMap { } } - CFRelease(input_source); + CFRelease(input_source.cast_mut()); } map From d38892b14772fe96be5d78730258fe47d6559cb5 Mon Sep 17 00:00:00 2001 From: Evgeny Oleynik Date: Tue, 2 Dec 2025 14:04:08 +0700 Subject: [PATCH 4/7] Rename misleading constant to KUC_KEY_TRANSLATE_NO_OPTIONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constant value 0 means "no options", not a specific bit flag. Renamed and added doc comment for clarity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- native/text-writer/src/keyboard_layout.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/native/text-writer/src/keyboard_layout.rs b/native/text-writer/src/keyboard_layout.rs index 9f3f581e..e052085b 100644 --- a/native/text-writer/src/keyboard_layout.rs +++ b/native/text-writer/src/keyboard_layout.rs @@ -55,7 +55,8 @@ extern "C" { } const KUC_KEY_ACTION_DISPLAY: u16 = 3; -const KUC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT: u32 = 0; +/// No translation options - pass 0 for default behavior +const KUC_KEY_TRANSLATE_NO_OPTIONS: u32 = 0; /// Default QWERTY keycode for 'V' key const QWERTY_V_KEYCODE: u16 = 9; @@ -97,7 +98,7 @@ fn build_char_to_keycode_map() -> HashMap { KUC_KEY_ACTION_DISPLAY, 0, // no modifiers kbd_type, - KUC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT, + KUC_KEY_TRANSLATE_NO_OPTIONS, &mut dead_key_state, char_buf.len(), &mut actual_len, From 6d1428c77d56ee4a22c06b9a06ef5ac4d8dd0acf Mon Sep 17 00:00:00 2001 From: Evgeny Oleynik Date: Tue, 2 Dec 2025 14:15:41 +0700 Subject: [PATCH 5/7] Remove unnecessary cast_mut() calls for CFRelease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFRelease accepts CFTypeRef (*const c_void) directly, so the cast_mut() was unnecessary. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- native/text-writer/src/keyboard_layout.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native/text-writer/src/keyboard_layout.rs b/native/text-writer/src/keyboard_layout.rs index e052085b..0c99e0a8 100644 --- a/native/text-writer/src/keyboard_layout.rs +++ b/native/text-writer/src/keyboard_layout.rs @@ -76,7 +76,7 @@ fn build_char_to_keycode_map() -> HashMap { TISGetInputSourceProperty(input_source, kTISPropertyUnicodeKeyLayoutData); if layout_data_ref.is_null() { - CFRelease(input_source.cast_mut()); + CFRelease(input_source); return map; } @@ -113,7 +113,7 @@ fn build_char_to_keycode_map() -> HashMap { } } - CFRelease(input_source.cast_mut()); + CFRelease(input_source); } map From 32d77dee0805eb186ee2e3ca1112bf0adc6fa398 Mon Sep 17 00:00:00 2001 From: Evgeny Oleynik Date: Tue, 2 Dec 2025 14:17:27 +0700 Subject: [PATCH 6/7] Fix misleading comment about wrap_under_get_rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrap_under_get_rule retains the object (Get rule semantics), not the opposite as the comment incorrectly stated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- native/text-writer/src/keyboard_layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/text-writer/src/keyboard_layout.rs b/native/text-writer/src/keyboard_layout.rs index 0c99e0a8..e27b6eb7 100644 --- a/native/text-writer/src/keyboard_layout.rs +++ b/native/text-writer/src/keyboard_layout.rs @@ -80,7 +80,7 @@ fn build_char_to_keycode_map() -> HashMap { return map; } - // Wrap the CFData without retaining (it's owned by input_source) + // Wrap the CFData with retain (Get rule: we don't own it, so we retain for safe use) let layout_data: CFData = TCFType::wrap_under_get_rule(layout_data_ref.cast()); // Cast byte pointer to UCKeyboardLayout pointer per Apple documentation let layout_ptr = layout_data.bytes().as_ptr().cast::(); From 29186bbd10f01bee818bf0ebc32172152cd2bf85 Mon Sep 17 00:00:00 2001 From: Evgeny Oleynik Date: Tue, 2 Dec 2025 17:54:40 +0700 Subject: [PATCH 7/7] Fix paste for non-Latin layouts (Russian, Greek, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use TISCopyCurrentASCIICapableKeyboardLayoutInputSource() instead of TISCopyCurrentKeyboardLayoutInputSource() to always query the user's Latin keyboard layout for shortcut keycodes. This fixes Cmd+V paste when a non-Latin layout like Russian is active, especially for users with alternative Latin layouts like Dvorak. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- native/text-writer/src/keyboard_layout.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/native/text-writer/src/keyboard_layout.rs b/native/text-writer/src/keyboard_layout.rs index e27b6eb7..9b9fb06d 100644 --- a/native/text-writer/src/keyboard_layout.rs +++ b/native/text-writer/src/keyboard_layout.rs @@ -32,7 +32,7 @@ type UniCharCount = usize; // See: Apple Text Input Sources Reference, Unicode Utilities Reference #[link(name = "Carbon", kind = "framework")] extern "C" { - fn TISCopyCurrentKeyboardLayoutInputSource() -> CFTypeRef; + fn TISCopyCurrentASCIICapableKeyboardLayoutInputSource() -> CFTypeRef; fn TISGetInputSourceProperty(input_source: CFTypeRef, property_key: CFTypeRef) -> CFTypeRef; fn LMGetKbdType() -> u32; static kTISPropertyUnicodeKeyLayoutData: CFTypeRef; @@ -67,7 +67,9 @@ fn build_char_to_keycode_map() -> HashMap { let mut map = HashMap::new(); unsafe { - let input_source = TISCopyCurrentKeyboardLayoutInputSource(); + // Use ASCII-capable layout to get the user's Latin keyboard (e.g., Dvorak) + // regardless of the currently active layout (e.g., Russian) + let input_source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource(); if input_source.is_null() { return map; }