the notorious
snacky

<- Quay về trang chủ

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ói về layout của bàn phím, chúng ta có 2 khái niệm: Physical Layout (layout vật lý của bàn phím) và Logical Layout (layout "mềm", được map bởi phần mềm). Sau khi chuyển input source, các phím nhấn từ layout vật lý sẽ được macOS map lại theo đúng cấu hình của layout đang chọn. Và việc mapping này xày ra sau khi event phím được xử lý bởi CGEventTap.

Bộ gõ của chúng ta "bắt" event từ event tap, nên hiển nhiên chỉ "bắt" được các phím theo layout vật lý chứ không theo input source hiện tại.

Thật là tai hại... Đến đây thì mình thấy nhớ con mech cũ của mình cực kì 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ì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:

Đến đây tạm nghỉ. Đoạn code trên mình copy từ thư viện rdev, là một thư viện xử lý input trong Rust, nên mình chuyển hướng sang thử 2 cách khác:

  1. Build một demo nhỏ chỉ sử dụng rdev thay vì custom binding, để xem hàm convert có hoạt động không. Kết quả là có, chạy ngon lành.
  2. Tích hợp rdev vào GõKey thay vì custom binding, về cơ bản không khác cái demo nhỏ bao nhiêu. Kết quả vẫn crash.

Hoang mang cực độ. Thôi nghỉ thật, ko nghỉ tạm nữa, tắt máy đi ngủ mai xử lý tiếp.

Ngày 21/02/2023

Sửa lỗi gõ tiếng Việt trên bàn phím layout non-US - phần 2

Sau khi ngủ một đêm tới sáng thì có vẻ như đầu óc trở nên thông minh hơn, code khuya thật tai hại.

Hoá ra lỗi "illegal hardware instruction" ở trên xảy ra do hàm convert được chạy ở ngoài main thread. Theo như policy của macOS, các API liên quan đến UI, input các kiểu phải được thực thi trên main thread.

Hôm qua khi gọi hàm convert, thì mình gọi nó trong mỗi hàm callback từ event tap. Tuy nhiên, event tap được chạy bằng một thread mới, và trong GõKey, main thread được dành để xử lý UI (cho config window).

Đến đây thì mình nghĩ, nếu thế, chỉ có một thời điểm duy nhất mà mình có thể sử dụng main thread cho việc convert các keyboard event, đó là lúc bộ gõ vừa được khởi động. Thế thì cách giải quyết sẽ là, thay vì thực hiện convert mỗi khi người dùng gõ phím, mình sẽ build một cái lookup map (HashMap chẳng hạn) ngay khi người dùng vừa mở app, quy trình như sơ đồ sau:

Khi người dùng gõ phím, thì chúng ta chỉ việc dùng lookup map này để lấy ra phím được gõ từ logical layout.

Kết quả chúng ta có commit 8aa549.

Với cách implement trên, bộ gõ đã hoạt động được với bất kì logical layout bàn phím nào. Tuy nhiên còn tồn tại một vấn đề đó là, khi người dùng thay đổi layout thì bộ gõ vẫn chỉ hoạt động theo layout trước đó.

Vấn đề này sau đó mình fix bằng cách rebuild lại keymap mỗi khi người dùng bật tắt chế độ gõ tiếng Việt, vì sự kiện bật tắt chế độ gõ tiếng Việt được handle ở trong UI, nên nó vẫn nằm trên main thread, việc gọi TIS để xử lý hoàn toàn không có vấn đề gì. Sơ đồ hoạt động mới trở thành như thế này:

Implemented trong commit da246fde.

Ngày 24/02/2023

Update icon mới cho bộ gõ

Sau một thời gian sử dụng thì mình thấy icon của GõKey hơi chán, với cả tông màu nhìn vào không khác gì một website nổi tiếng của cộng đồng khoe sách. Nên mình quyết định design một cái icon khác.

Vẫn giữ ý tưởng chính là biểu tượng chữ G có dấu ngã ở trên, nhìn vào sẽ thấy ngay chữ "Gõ". Mặc dù nhiều người bảo nhìn nó giống chữ "Gỡ" hơn...

Ở phiên bản mới này mình không muốn dùng thiết kế "phẳng" nữa mà muốn nó "phồng" lên một tí, như phong trào thiết kế mới theo phong cách của hệ điều hành macOS 11 (Big Sur).

Long story short, trải qua 1008 mẫu thiết kế khác nhau, cuối cùng thì GõKey cũng đã có mẫu thiết kế cho icon mới, là logo của bộ gõ trên nền một chiếc keycap, đúng với ý tưởng mà @zerox-dg đề xuất từ đầu.

Ngày 25/02/2023

Update vi-rs lên phiên bản 0.3.6

Trong phiên bản vi-rs v0.3.6, @zerox-dg add thêm một chức năng khá quan trọng, đó là việc cho phép gõ kí tự "ư" khi nhấn phím "w". Tương tự như bàn phím trên iOS, quy tắc gõ này sẽ là mặc định.

Kèm theo đó là fix thêm các lỗi khác khi đặt dấu tiếng Việt.

Thực sự thì cũng rất hiếm khi mình thấy có người viết code sử dụng tiếng Việt có dấu mà lại rất hợp với context

vi-rs/src/utils.rs#L78

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:

  1. Lấy reference đến đối tượng NSMenu của app hiện tại
  2. Lấy reference đến system status bar của hệ thống
  3. 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
  4. Đặt NSStatusItem này vào NSMenu đã 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-foundationcocoa:

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.

Tags: rustgokey