#![cfg(target_os = "macos")]
use crate::core::ObjectId;
use crate::platform::ime::{ImeBridge, ImeCandidatePosition, ImeComposition};
use std::sync::Mutex;
#[cfg(feature = "objc2-macos")]
fn try_activate_nstextinputcontext(
_view_ptr: *mut std::ffi::c_void,
) -> Option<Box<dyn std::any::Any + Send>> {
let _ = view_ptr;
None
}
#[cfg(feature = "objc2-macos")]
fn sync_nstextinputcontext(
_token: &dyn std::any::Any,
_marked_text: &str,
_marked_range: (usize, usize),
_selected_range: (usize, usize),
) {
}
pub struct MacOsImeBridge {
focused_widget: Mutex<Option<ObjectId>>,
active: Mutex<bool>,
marked_text: Mutex<String>,
marked_range: Mutex<(usize, usize)>,
selected_range: Mutex<(usize, usize)>,
#[allow(dead_code)]
native_token: Mutex<Option<Box<dyn std::any::Any + Send>>>,
}
impl Default for MacOsImeBridge {
fn default() -> Self {
Self::new()
}
}
impl MacOsImeBridge {
pub fn new() -> Self {
Self {
focused_widget: Mutex::new(None),
active: Mutex::new(false),
marked_text: Mutex::new(String::new()),
marked_range: Mutex::new((0, 0)),
selected_range: Mutex::new((0, 0)),
native_token: Mutex::new(None),
}
}
pub fn attach_to_view(&self, view_ptr: *mut std::ffi::c_void) {
#[cfg(feature = "objc2-macos")]
{
if let Some(token) = try_activate_nstextinputcontext(view_ptr) {
*self.native_token.lock().unwrap() = Some(token);
}
}
let _ = view_ptr;
}
pub fn set_cursor_rect(&self, x: i32, y: i32, w: u32, h: u32) {
log::debug!("[macOS IME] set_cursor_rect: x={}, y={}, w={}, h={}", x, y, w, h,);
}
pub fn process_key_event(
&self,
key_code: u32,
modifiers: u32,
pressed: bool,
) -> Option<String> {
log::debug!(
"[macOS IME] process_key_event: key={}, mods={:#x}, pressed={}",
key_code,
modifiers,
pressed,
);
if self.has_marked_text() {
return None;
}
if !pressed {
return None;
}
if (0x20..=0x7e).contains(&key_code) {
let ch = char::from_u32(key_code)?;
let final_char = if modifiers & 0x02 != 0 { ch.to_ascii_uppercase() } else { ch };
return Some(final_char.to_string());
}
if key_code == 0x0d || key_code == 0x03 {
return Some("\n".to_string());
}
if key_code == 0x09 {
return Some("\t".to_string());
}
None
}
fn clear_composition(&self) {
*self.marked_text.lock().unwrap() = String::new();
*self.marked_range.lock().unwrap() = (0, 0);
*self.selected_range.lock().unwrap() = (0, 0);
}
pub fn commit_text(&self, text: &str) {
log::info!("[macOS IME] commit_text: '{}'", text);
self.clear_composition();
}
pub fn set_marked_text(&self, text: &str, sel_start: i32, sel_end: i32) {
log::debug!("[macOS IME] set_marked_text: '{}'", text);
let utf16_len = text.encode_utf16().count();
let (sel_offset, sel_length) = if sel_start >= 0 && sel_end >= 0 {
let start = sel_start as usize;
let end = sel_end as usize;
let clamped_start = start.min(utf16_len);
let clamped_end = end.min(utf16_len);
if clamped_start <= clamped_end {
(clamped_start, clamped_end - clamped_start)
} else {
(clamped_end, clamped_start - clamped_end)
}
} else {
(utf16_len, 0)
};
*self.marked_text.lock().unwrap() = text.to_string();
*self.marked_range.lock().unwrap() = (0, utf16_len);
*self.selected_range.lock().unwrap() = (sel_offset, sel_length);
#[cfg(feature = "objc2-macos")]
{
let guard = self.native_token.lock().unwrap();
if let Some(ref token) = *guard {
sync_nstextinputcontext(
token.as_ref(),
text,
(0, utf16_len),
(sel_offset, sel_length),
);
}
}
}
pub fn get_marked_text(&self) -> Option<String> {
let text = self.marked_text.lock().unwrap();
if text.is_empty() {
None
} else {
Some(text.clone())
}
}
pub fn has_marked_text(&self) -> bool {
!self.marked_text.lock().unwrap().is_empty()
}
pub fn discard_marked_text(&self) {
log::debug!("[macOS IME] discard_marked_text");
*self.marked_text.lock().unwrap() = String::new();
*self.marked_range.lock().unwrap() = (0, 0);
*self.selected_range.lock().unwrap() = (0, 0);
}
pub fn utf16_len(s: &str) -> usize {
s.encode_utf16().count()
}
}
impl ImeBridge for MacOsImeBridge {
fn focus_in(&self, widget_id: ObjectId) {
*self.focused_widget.lock().unwrap() = Some(widget_id);
*self.active.lock().unwrap() = true;
log::info!("[macOS IME] focus_in: widget={}", widget_id);
#[cfg(feature = "objc2-macos")]
{
let guard = self.native_token.lock().unwrap();
if let Some(ref token) = *guard {
let _ = token; }
}
}
fn focus_out(&self, widget_id: ObjectId) {
*self.focused_widget.lock().unwrap() = None;
*self.active.lock().unwrap() = false;
*self.marked_text.lock().unwrap() = String::new();
*self.marked_range.lock().unwrap() = (0, 0);
*self.selected_range.lock().unwrap() = (0, 0);
log::info!("[macOS IME] focus_out: widget={}", widget_id);
#[cfg(feature = "objc2-macos")]
{
let guard = self.native_token.lock().unwrap();
if let Some(ref token) = *guard {
let _ = token; }
}
}
fn commit_text(&self, text: &str) {
log::info!("[macOS IME] commit_text: '{}'", text);
self.clear_composition();
}
fn set_composition(&self, composition: &ImeComposition) {
log::debug!("[macOS IME] set_composition: '{}'", composition.text);
let text = &composition.text;
let utf16_len = Self::utf16_len(text);
let cursor_utf16 = byte_offset_to_utf16(text, composition.cursor_position).min(utf16_len);
let sel_length_utf16 =
composition.selection_length.min(utf16_len.saturating_sub(cursor_utf16));
*self.marked_text.lock().unwrap() = text.to_string();
*self.marked_range.lock().unwrap() = (0, utf16_len);
*self.selected_range.lock().unwrap() = (cursor_utf16, sel_length_utf16);
#[cfg(feature = "objc2-macos")]
{
let guard = self.native_token.lock().unwrap();
if let Some(ref token) = *guard {
sync_nstextinputcontext(
token.as_ref(),
text,
(0, utf16_len),
(cursor_utf16, sel_length_utf16),
);
}
}
}
fn set_candidate_window_position(&self, position: ImeCandidatePosition) {
log::debug!("[macOS IME] set_candidate_window_position: ({}, {})", position.x, position.y,);
}
fn is_active(&self) -> bool {
*self.active.lock().unwrap()
}
}
fn byte_offset_to_utf16(s: &str, byte_offset: usize) -> usize {
let byte_offset = byte_offset.min(s.len());
s[..byte_offset].encode_utf16().count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::ime::ImeComposition;
#[test]
fn test_focus_in_out() {
let bridge = MacOsImeBridge::new();
assert!(!bridge.is_active());
assert!(bridge.focused_widget.lock().unwrap().is_none());
bridge.focus_in(42);
assert!(bridge.is_active());
assert_eq!(*bridge.focused_widget.lock().unwrap(), Some(42));
bridge.focus_out(42);
assert!(!bridge.is_active());
assert!(bridge.focused_widget.lock().unwrap().is_none());
}
#[test]
fn test_commit_text_clears_composition() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text("hello", 5, 5);
assert!(bridge.has_marked_text());
bridge.commit_text("hello");
assert!(!bridge.has_marked_text());
assert_eq!(bridge.get_marked_text(), None);
}
#[test]
fn test_commit_text_via_trait() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text("你好", 2, 2);
assert!(bridge.has_marked_text());
ImeBridge::commit_text(&bridge, "你好");
assert!(!bridge.has_marked_text());
}
#[test]
fn test_set_composition_trait() {
let bridge = MacOsImeBridge::new();
let comp = ImeComposition {
text: "composing".to_string(),
cursor_position: 5,
selection_length: 0,
};
bridge.set_composition(&comp);
assert!(bridge.has_marked_text());
assert_eq!(bridge.get_marked_text(), Some("composing".to_string()));
}
#[test]
fn test_set_composition_empty_clears() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text("something", 4, 0);
let empty = ImeComposition::default();
bridge.set_composition(&empty);
assert!(!bridge.has_marked_text());
}
#[test]
fn test_discard_marked_text() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text("你好世界", 2, 2);
assert!(bridge.has_marked_text());
bridge.discard_marked_text();
assert!(!bridge.has_marked_text());
assert_eq!(bridge.get_marked_text(), None);
}
#[test]
fn test_set_marked_text_with_selection() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text("hello world", 3, 7);
assert!(bridge.has_marked_text());
assert_eq!(bridge.get_marked_text(), Some("hello world".to_string()));
let sel = *bridge.selected_range.lock().unwrap();
assert_eq!(sel, (3, 4)); }
#[test]
fn test_set_marked_text_negative_sel_defaults_cursor_at_end() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text("test", -1, -1);
let sel = *bridge.selected_range.lock().unwrap();
assert_eq!(sel, (4, 0));
}
#[test]
fn test_is_active_after_events() {
let bridge = MacOsImeBridge::new();
assert!(!bridge.is_active());
bridge.focus_in(1);
assert!(bridge.is_active());
bridge.focus_out(1);
assert!(!bridge.is_active());
}
#[test]
fn test_process_key_event_no_composition() {
let bridge = MacOsImeBridge::new();
let result = bridge.process_key_event(0x61, 0x02, true);
assert_eq!(result, Some("A".to_string()));
let result = bridge.process_key_event(0x61, 0x00, true);
assert_eq!(result, Some("a".to_string()));
let result = bridge.process_key_event(0x0d, 0x00, true);
assert_eq!(result, Some("\n".to_string()));
let result = bridge.process_key_event(0x1b, 0x00, true);
assert_eq!(result, None);
}
#[test]
fn test_process_key_event_during_composition() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text(" composing", 10, 0);
let result = bridge.process_key_event(0x61, 0x00, true);
assert_eq!(result, None);
}
#[test]
fn test_set_candidate_window_position() {
let bridge = MacOsImeBridge::new();
bridge.set_candidate_window_position(ImeCandidatePosition { x: 100, y: 200 });
}
#[test]
fn test_utf16_len() {
assert_eq!(MacOsImeBridge::utf16_len("hello"), 5);
assert_eq!(MacOsImeBridge::utf16_len("你好"), 2);
assert_eq!(MacOsImeBridge::utf16_len("🚀"), 2);
}
#[test]
fn test_byte_offset_to_utf16_ascii() {
assert_eq!(byte_offset_to_utf16("hello", 0), 0);
assert_eq!(byte_offset_to_utf16("hello", 3), 3);
assert_eq!(byte_offset_to_utf16("hello", 5), 5);
}
#[test]
fn test_byte_offset_to_utf16_cjk() {
assert_eq!(byte_offset_to_utf16("你好", 0), 0);
assert_eq!(byte_offset_to_utf16("你好", 3), 1); assert_eq!(byte_offset_to_utf16("你好", 6), 2);
}
#[test]
fn test_focus_out_discards_composition() {
let bridge = MacOsImeBridge::new();
bridge.set_marked_text("pending", 7, 0);
assert!(bridge.has_marked_text());
bridge.focus_out(1);
assert!(!bridge.has_marked_text());
assert!(!bridge.is_active());
}
#[test]
fn test_set_cursor_rect() {
let bridge = MacOsImeBridge::new();
bridge.set_cursor_rect(10, 20, 100, 30);
}
#[test]
fn test_attach_to_view() {
let bridge = MacOsImeBridge::new();
let null_ptr = std::ptr::null_mut();
bridge.attach_to_view(null_ptr);
}
}