Nhật ký phát triển GõKey - Tuần 6
Ngày 20/02/2023
Sửa lỗi gõ tiếng Việt trên bàn phím layout non-US
Góc khoe mẽ: Entry này được gõ bằng GõKey trên bàn phím layout Dvorak :))
Mấy hôm gần đây chả hiểu sao code cái gì cũng ko chạy, từ GõKey đến dự án ở công ty tụt mood nghiêm trọng. Mình bèn chuyển bàn phím sang layout Dvorak gõ cho refresh tinh thần. Nào ngờ chuyển xong thì phát hiện ra GõKey không hề hoạt động. Lại tụt mood tiếp...
Thực ra là có chạy nhưng toàn bộ phím nhấn bị map sai vị trí, bộ gõ gứi phím theo layout QWERTY thay vì Dvorak. Ví dụ, trên layout Dvorak gõ "ee" thì bộ gõ nhận diện thành "dd".
Nguyên nhân thì khá là rõ ràng. Máy tính của mình là máy dùng bàn phím US, có layout mặc định là QWERTY. Khi sử dụng các layout thay thế như Dvorak thì phải cấu hình trong Input Source setting.
nó là một con Vortex Pok3r, hỗ trợ sẵn 3 layout vật lý QWERTY, Colemak và Dvorak. Giá mà giờ còn xài nó thì đã không phát hiện ra con bug đáng chán này.
Anyway, có bug thì phải fix thôi.
Dạo quanh một vòng các bộ gõ khác thì thấy cũng có nhiều người gặp vấn đề y vậy. Ngoài các layout dân chơi như Colemak hay Dvorak ra thì vấn đề này còn xuất hiện trên các layout của các ngôn ngữ khác như tiếng Nhật, Đức,... vậy thì không thể bỏ qua được rồi.
Quay lại vài tuần trước, khi implement event tap, mỗi phím nhấn sẽ được gửi ra hàm callback dưới dạng một giá trị kiểu CGKeyCode
. Bộ gõ phải chuyển nó thành các ASCII character để xử lý, mình dùng một hàm convert như này:
fn get_char(keycode: CGKeyCode) -> Option<char> { match keycode { 0 => Some('a'), 1 => Some('s'), 2 => Some('d'), 3 => Some('f'), ...
Đến đây thì đã rõ, chúng ta đang map trực tiếp các keycode từ layout vật lý sang kiểu kí tự. Và chính xác hơn là map theo layout QWERTY.
Để map được CGKeyCode
sang kí tự đúng theo input source hiện tại, thì có 2 cách:
- Một là convert
CGEvent
object thành kiểuNSEvent
rồi dùng hàmNSEvent::charactersIgnoringModifiers()
để lấy các kí tự nếu có của event đó. - Hai là dùng Text Input Source Services của Cocoa Framework trên macOS
Mình thử cách NSEvent
đầu tiên nhưng gặp lỗi crash khi gọi hàm charactersIgnoringModifiers()
, có thể là do cast con trỏ của event bị sai (xài Rust mà phải cast con trỏ, nghe thôi đã thấy sai rồi, welcome to FFI world), tuy nhiên lỗi crash xảy ra ở phía macOS API, và rất khó để debug trong Rust nên mình tạm bỏ qua cách này và thử cách thứ 2.
Cách xài Text Input Source Services (TIS) thì khá là interesting, đây là cách được khá nhiều dự án sử dụng, trong đó có cả Chromium, Firefox. Tuy nhiên, ở thời điểm này thì hoàn toàn không tìm được bất cứ document nào từ Apple về API này.
Để sử dụng TIS, thì chúng ta cần viết thêm binding cho một số hàm từ Cocoa Framework của macOS.
#[cfg(target_os = "macos")]
#[link(name = "Cocoa", kind = "framework")]
#[link(name = "Carbon", kind = "framework")]
extern "C" { fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef; fn TISGetInputSourceProperty(source: TISInputSourceRef, property: *mut c_void) -> CFDataRef; fn UCKeyTranslate( layout: *const u8, code: u16, key_action: u16, modifier_state: u32, keyboard_type: u32, key_translate_options: OptionBits, dead_key_state: *mut u32, max_length: UniCharCount, actual_length: *mut UniCharCount, unicode_string: *mut [UniChar; BUF_LEN], ) -> OSStatus; fn LMGetKbdType() -> u32; static kTISPropertyUnicodeKeyLayoutData: *mut c_void;
}
Và để convert từ một giá trị kiểu CGKeyCode
sang một unicode string, ta thực hiện như sau:
// Lấy thông tin về Input Source hiện tại
let keyboard = TISCopyCurrentKeyboardInputSource();
// Lấy thông tin về layout hiện tại, ví dụ QWERTY hay Dvorak,...
let layout = TISGetInputSourceProperty(keyboard, kTISPropertyUnicodeKeyLayoutData);
let layout_ptr = CFDataGetBytePtr(layout);
// Convert CGKeyCode sang String
let mut length = 0;
let mut buf = [0_u16; MAX_BUF_LENGTH];
let _retval = UCKeyTranslate( layout_ptr, input_keycode, ..., &mut length as *mut UniCharCount, &mut buf as *mut [UniChar; BUF_LEN],
);
Về mặt ý tưởng thì giải pháp này khá là gọn và tường minh. Tuy nhiên khi hí hửng build rồi chạy thì mỗi lần hàm convert ở trên được gọi, bộ gõ lại crash kèm theo dòng thông báo không thể nào vô dụng hơn:
pub fn insert_ư_if_vowel_not_present( input: &mut String, is_uppercase: bool
) -> bool { ...
}
Implement System Tray Icon
Với kinh nghiệm dealing với main thread đã thu được khi implement chức năng hỗ trợ multiple keyboard layout cách đây mấy hôm, thì hôm nay mình quyết định sẽ quay trở lại giải quyết nốt chức năng system tray.
Lần này mình không dùng thư viện có sẵn như tray-item-rs nữa mà sẽ sử dụng trực tiếp API của macOS.
Theo như document của Apple, thì việc add thêm system tray cho một app được thực hiện theo các bước như sau:
- Lấy reference đến đối tượng
NSMenu
của app hiện tại - Lấy reference đến system status bar của hệ thống
- Dùng system status bar, tạo một
NSStatusItem
mới, thiết lập icon hoặc display text cho status item này - Đặt
NSStatusItem
này vàoNSMenu
đã lấy ở bước 1
Nghe có vẻ khá đơn giản, implementation trên Rust cũng khá gọn, tất cả các type từ macOS API đều được cung cấp bởi core-graphics
, core-foundation
và cocoa
:
let app = NSApp();
app.activateIgnoringOtherApps_(YES);
// Lấy reference đến Menu của app hiện tại
let menu = NSMenu::new(nil).autorelease();
// Tạo một status item mới trên system status bar
let item = NSStatusBar::systemStatusBar(nil).statusItemWithLength_(-1.0);
let title = NSString::alloc(nil).init_str("VN");
item.setTitle_(title);
// Gắn vào NSMenu ở bước 1
item.setMenu_(menu);
Đoạn code trên nếu chạy ở main thread thì OK không vấn đề gì. Nhưng chúng ta cần phải update status bar item khi người dùng thay đổi trạng thái của bộ gõ nữa. Vậy cách tốt nhất là giữ tham chiếu của NSStatusItem
ở trong UI app, mà cụ thể ở đây là trong UIDataAdapter
(đối tượng chứa toàn bộ UI state của app).
pub struct SystemTray { item: *mut objc::runtime::Object
} #[derive(Clone, Data, Lens, PartialEq, Eq)]
pub struct UIDataAdapter { ... systray: SystemTray,
}
Đến đây phát sinh một vấn đề, để một type có thể là member của struct UIDataAdapter
, thì type đó phải được implement trait druid::Data
, nhưng ở phía Rust, thì đối tượng NSStatusItem
thực chất là một tham chiếu kiểu *mut objc::runtime::Object
, và chúng ta không thể implement trait cho các 3rd party type một cách trực tiếp.
Vấn đề này có thể giải quyết bằng cách tạo một type mới để wrap kiểu *mut objc::runtime::Object
, sau đó implement Data
trait cho kiểu wrapper này:
struct Wrapper(*mut objc::runtime::Object);
impl Data for Wrapper { fn same(&self, _other: &Self) -> bool { true }
}
Tương tự như cách mà chúng ta implement kiểu SharedBox<T>
đã đề cập ở note hồi tuần 1.
Bằng việc đưa NSStatusItem
vào UIDataAdapter
, chúng ta có thể access và thiết đặt thuộc tính title
cho tray item này trên UI khi cần.
fn update(&mut self) { ... self.systray.set_title(match self.is_enabled { true => "VN" false => "EN" });
}
Và kết quả là từ bây giờ, GõKey đã có thể hiện biểu tượng thông báo chế độ gõ trên System Tray.
Chi tiết implementation các bạn có thể xem tại commit f313a64.