Nhật ký phát triển GõKey - Tuần 1

<- Quay về trang chủ

Gõ tiếng Việt trên máy tính là một chủ đề khá thú vị và xây dựng một bộ gõ tiếng Việt là ước mơ của mình từ khi mới chập chững học lập trình.

Giai đoạn 2006 thì mình có tham gia một diễn đàn công nghệ, và được may mắn theo dõi một số cuộc thảo luận về vấn đề bộ gõ, nhờ đó mà cũng lờ mờ nắm được ý tưởng về các kĩ thuật xử lý như Backspace giả, Clipboard... nhưng ở thời điểm đó, mình chỉ biết rồi thôi.

Thực ra xây dựng bộ gõ không phải là một vấn đề quá khó, cái khó nhất có lẽ là sẽ có rất nhiều behavior cần phải xử lý. Thêm nữa, vì chưa thấy có nhiều tài liệu nói về vấn đề xây dựng bộ gõ, nên ban đầu mình nảy ra ý tưởng làm một bộ gõ đơn giản rồi viết bài hướng dẫn, mô tả kĩ thuật, hy vọng sẽ lôi kéo được thêm nhiều người quan tâm đến chủ đề này

Lúc bắt tay vào làm rồi thì tự nhiên thấy đây là một dự án khá thú vị, nên có lẽ mình sẽ làm tiếp một thời gian nữa .

Dưới đây là vài ghi chú mình viết lại mỗi ngày khi thực hiện dự án này, đôi chỗ có thể sẽ khó hiểu, tuy nhiên nếu đọc chơi thì mình nghĩ chắc cũng không thành vấn đề, vì thế nên mình post lại lên blog.

Trước khi bắt đầu, mình rêcômmnd các bạn đọc qua vài bài viết về vấn đề bộ gõ / xử lý tiếng Việt:

Mã nguồn của dự án GõKey các bạn có thể tham khảo tại đây: https://github.com/huytd/goxkey/

Ngày 10/01/2023

Commit: 82aa2

Sau khi có ý tưởng thực hiện một bộ gõ thì việc đầu tiên mình làm là... đọc source của một vài bộ gõ đã có trước đó, chủ yếu là ibus-bogoOpenKey.

Mục tiêu chính sẽ là làm bộ gõ cho macOS, đơn giản vì mình đang xài macOS. Ngôn ngữ sử dụng sẽ là Rust, lý do thì rất hiển nhiên, mình là fanboi của Rút. JK. Đặc thù của dự án này đòi hỏi phải tương tác với những API bên dưới của hệ điều hành, việc gõ phím đòi hỏi tốc độ xử lý nhanh và ứng dụng phải chạy thật ổn định. Rust hội tụ đủ tất cả mọi yếu tố cần thiết đó: dễ dàng sử dụng các low level API từ hệ điều hành giống như C/C++, hiệu suất hoạt động miễn bàn, có rất nhiều feature/thư viện cho phép mình làm implement những gì mình muốn một cách nhanh chóng mà lại còn đảm bảo tính an toàn, xài Rust làm cho mình yên tâm đến mức có thể nghĩ là: Code compile được là chương trình sẽ hoạt động một cách ổn định và hiệu quả (đừng quá tin vào câu này =))). Mình không phải là một lập trình viên giỏi, và Rust có thể hướng cho mình viết code an toàn hơn, không giống như khi xài C/C++, mình có thể tạm yên tâm với performance, nhưng chưa bao giờ yên tâm về độ an toàn, mọi nước đi mình luôn sợ sẽ gây ra SEGFAULT Trong khi xài JS hay Python thì mình có thể yên tâm về productivity nhưng chưa bao giờ yên tâm về performance.

Tất nhiên những cái benefits trên khi xài Rust, mình vẫn có thể có nếu xài Go. Vấn đề duy nhất là mình không có rành Go

Trước khi bắt đầu thì mình cũng lờ mờ biết là, với kĩ thuật Backspace giả, bằng cách nào đó, phải bắt được thông tin các phím được nhấn, rồi "dịch" các phím đó ra thành một từ tiếng Việt có nghĩa, rồi gửi phím Backspace để xoá từ đã được gõ, rồi gửi tiếp nội dung từ tiếng Việt đó để thay thế.

Tuy nhiên bắt thông tin phím như thế nào thì chưa biết. Rất may là mã nguồn của OpenKey được trình bày tương đối rõ ràng nên mình cũng không tốn nhiều thời gian để tìm ra thứ cần tìm:

eventTap = CGEventTapCreate(kCGSessionEventTap,
                            kCGHeadInsertEventTap,
                            0,
                            eventMask,
                            OpenKeyCallback,
                            NULL);

Như vậy, từ khoá ở đây là CGEventTapCreate, một API đến từ framework CoreGraphics của macOS.

Để dùng được API của macOS trong Rust thì cần phải viết binding cho các API đó, hoặc là dùng các binding có sẵn, tuy nhiên đang máu nên mình chọn cách tự viết binding, tiện để hiểu thêm những gì xảy ra bên dưới.

Sau một hồi loay hoay thì mình cũng build được một chiếc prototype tạm coi được:

Cơ chế hoạt động của bản prototype này là:

  1. Tạo một thread mới, sử dụng CGEventTap để bắt keyboard event
  2. Với mỗi phím được nhấn, truyền key code của nó qua một channel
  3. Ở main thread, liên tục đọc các key code nằm trong channel trên
  4. Lưu các phím nhận được vào một buffer và "dịch" buffer đó ra tiếng Việt

Nếu chia một bộ gõ ra làm 2 thành phần: Frontend (giao tiếp với hệ điều hành) và Backend (engine xử lý tiếng Việt), thì bước 1, 2, 3 chính là frontend và bước 4 là engine.

Trong bản prototype này "engine" chỉ có khả năng xử lý được các kí tự: â, đà.

// engine triệu đô =))
if queue.len() >= 2 {
    if &queue[queue.len() - 2..] == &[KEY_A, KEY_A] {
        send_backspace();
        send_backspace();
        send_string("â");
    }
    if &queue[queue.len() - 2..] == &[KEY_D, KEY_D] {
        send_backspace();
        send_backspace();
        send_string("đ");
    }
    if &queue[queue.len() - 2..] == &[KEY_A, KEY_F] {
        send_backspace();
        send_backspace();
        send_string("à");
    }
}

Tắt máy đi ngủ.

À, tí nữa thì quên, lúc này mình tạm đặt tên dự án này là bogo-rs, có nghĩa là bộgõ-rs. Tạm thôi vì mình biết có những người dành hết cả thanh xuân để nghĩ tên dự án và không còn thời giờ để mà code luôn.

Ngày 11/01/2023

Commit: 04dc97

Việc đầu tiên cho hôm nay là thay thế toàn bộ phần binding tự viết bằng thư viện core-graphicscore-foundation, đây là bộ binding do team Servo phát triển, hẳn là phải rất uy tín.

Toàn bộ code trở nên rất gọn sau khi sử dụng bộ binding mới. Nhìn chung, thuật toán xử lý vẫn như bản prototype hôm qua.

Việc gửi phím backspace để xoá từ đang gõ, và gửi nội dung từ mới được thực hiện qua 2 hàm send_backspacesend_string như sau:

fn send_backspace(count: usize) -> Result<(), ()> {
    // Tạo event object mới
    let event = CGEvent::new_keyboard_event(source.clone(), KeyCode::DELETE, true)?;
    // gửi event
    for _ in 0..count {
        event.post(CGEventTapLocation::HID);
    }
    Ok(())
}

fn send_string(string: &str) -> Result<(), ()> {
    // Tạo event object mới
    let event = CGEvent::new_keyboard_event(source, 0, true)?;
    // Gán nội dung cần gửi vào event
    event.set_string(string);
    // gửi event
    event.post(CGEventTapLocation::HID);
    Ok(())
}

Một cải tiến quan trọng nữa đó là việc tích hợp engine xử lý tiếng Việt của @zerox-dg mang tên vi-rs. Nếu các bạn chưa biết thì đây là một bạn siêu nhân trẻ tuổi, từng là maintainer của BoostNote, nghe đâu mới ra trường cách đây không lâu, nhưng đã làm nhiều thứ xịn xò như là build một cái browser from scratch.

Anw, việc tích hợp vi-rs mang lại cho mình một cái lợi đó là khỏi phải tốn thời gian viết code xử lý tiếng Việt, khi có bug thì chỉ việc đổ lỗi cho cậu em @zerox-dg

Đoạn code xử lý nhập tiếng Việt rất đơn giản:

if let (true, result) = vi::telex::transform_buffer(&buf.chars().collect::<Vec<char>>()) {
    ...
    let del_count = <độ dài của tracking buffer>;
    // xoá từ vừa được gõ
    _ = send_backspace(del_count);
    // gửi từ vừa được transformed đi
    _ = send_string(&result);
    ...
}

Đến thời điểm này thì đã có thể tạm gõ được tiếng Việt rồi, tuy nhiên vẫn còn vài vấn đề như:

  1. Chỉ mới gõ được kiểu gõ Telex (vì mình chỉ gọi hàm vi::telex::transform_buffer)
  2. Performance có vẻ không ổn cho lắm, khi gõ có thể thấy gõ con trỏ bị nhảy lui (khi send backspace). Nói chung là thua xa những bộ gõ khác.
  3. Việc gõ rất bất ổn định, nhất là khi con trỏ chuột "tình cờ" di chuyển khỏi vị trí hiện tại của nó khi gõ, ví dụ (dấu | là con trỏ):
    vie|   # gõ gõ
    v|ie   # dịch chuyển con trỏ
    ve|ie  # gõ tiếp
    viê|ie # bộ gõ gửi phím
    
  4. Một lỗi khác không xảy ra thường xuyên, nhưng lâu lâu những chữ được gõ ra bị sai, ví dụ "các" thì màn hình hiện ra "ccác".

Nhưng mà thôi, đi ngủ.

Ngày 12/01/2023

Commit: 532eb9

Theo như phỏng đoán thì có lẽ việc dùng channel để gửi phím từ event tap ra hàm xử lý nhập liệu là nguyên nhân khiến cho performance của bộ gõ không được như ý. Suy cho cùng thì việc dùng channel là không cần thiết cho lắm. Nghĩ như vậy nên mình ngồi gỡ phần đó ra.

Đến đây thì có một vấn đề, làm sao để track các phím được gõ trong CGEventTapCallback khi không dùng channel?

fn callback(_proxy: CGEventTapProxy, _event_type: CGEventType, event: &CGEvent) -> Option<CGEvent> {
	// how to access main::input_buffer ???
}

fn main() {
	let input_buffer: Vec<char> = vec![];

	// Khởi tạo event tap với hàm `callback`
	CGEventTap::new(
		...
        vec![CGEventType::KeyDown],
        callback
    );
}

Đến đây thì sự lựa chọn duy nhất mình có thể nghĩ ra được đó là tạo một giá trị global để làm buffer chứa nội dung được gõ, và đọc/ghi giá trị global này mỗi khi hàm callback được gọi.

Và khi nói đến global value trong Rust, thì chúng ta đang nói đến static mut. Xài thêm Mutex cho chắc ăn thôi chứ trong trường hợp này thì không thực sự cần vì mỗi hàm callback đều được gọi một cách tuần tự theo thứ tự phím được nhấn, ít có khả năng 2 callback cùng truy xuất đến TYPING_BUF cùng lúc:

static mut TYPING_BUF: Mutex<Vec<char>> = Mutex::new(vec![]);

Và trong mỗi hàm callback:

fn callback(_proxy: CGEventTapProxy, _event_type: CGEventType, event: &CGEvent) -> Option<CGEvent> {
    unsafe {
        let mut typing_buf = TYPING_BUF.lock().unwrap();
        // đọc ghi gì với typing_buf thì triển ở đây
    }
}

Sau khi refactor xong thì chạy thử, cảm giác thấy tốc độ gõ cũng đã được cải thiện kha khá. Dụa trên cảm giác mà phán vì có đo đạc gì đâu :upside_down_face: nên làm gì có cơ sở mà kiểm chứng.

Tuy nhiên, đến đây thì bản prototype tạm coi là ổn, nên mình publish source lên Github luôn. Sau khi publish thì nhận được ngay một contribution đáng giá

Ngày 13/01/2023

Commit: f68750

Ngày xưa thời còn đi học, cuối tuần là thời điểm mà mình có thể dành toàn bộ thời gian để làm điều mình thích (ôm máy tính, code). Còn bây giờ, cuối tuần là thời điểm mà mình khó tập trung cho việc code nhất vì phải deal với 2 bạn quỷ nhỏ. Cho nên hôm nay không có nhiều progress lắm.

Việc duy nhất làm được hôm nay là tổ chức lại cấu trúc code cho dễ nhìn hơn, tách biệt giữa phần logic xử lý nhập liệu và phần logic giao tiếp với từng hệ điều hành.

Ngày 14/01/2023

Commit: 95fbc7

Dành phần lớn thời gian ngày hôm nay loay hoay tìm cách cải thiện tốc độ gõ. Thế quái nào mà, mang tiếng xài Rust, ấy thế mà performance lại cùi bắp không tưởng được, không thể chấp nhận được...

Một giải pháp được nghĩ ra đó là, khi gửi phím (trong hàm send_backspacesend_string), thay vì phải liên tục tạo thêm event object mới, thì mình sẽ tìm cách sử dụng lại các event object đã được tạo ra trước đó.

// thay vì:
let event = CGEvent::new_keyboard_event(...);

// thì dùng:
static ref BACKSPACE_DOWN: CGEvent = CGEvent::new_keyboard_event(...);

Nhưng có một vấn đề, đó là kiểu CGEvent từ trong binding của Servo không được define để có thể share giữa nhiều thread với nhau. Thế là mình phải xài dark magic, tạo ra một kiểu SharedBox<T>, để wrap kiểu CGEvent, implement các trait SyncSend:

struct SharedBox<T>(T);
unsafe impl<T> Send for SharedBox<T> {}
unsafe impl<T> Sync for SharedBox<T> {}
impl<T> SharedBox<T> {
    unsafe fn new(v: T) -> Self {
        Self(v)
    }
}

static ref BACKSPACE_DOWN: SharedBox<CGEvent> = unsafe {
    SharedBox::new(CGEvent::new_keyboard_event(
        EVENT_SOURCE.0.clone(),
        KeyCode::DELETE,
        true
    ).unwrap())
};

Mục đích của SharedBox<T> là để vỗ ngực bảo đảm với Rust rằng:

  • Mình: "Anh bảo đảm với chú là kiểu T có thể share giữa các thread một cách an toàn, chú cứ yên tâm công tác!"
  • Rust: "D' tin!"

Anyway, mặc cho cái phát minh SharedBox xịn xò mà mình chắc là ai cũng phải thán phục, bug mất chữ vẫn không được fix. Lâu lâu khi gõ thì vẫn xảy ra lỗi khó chịu như gõ "tiếng" trên màn hình sẽ hiện ra "ttiến".

Trong suốt quá trình này, @zerox-dg vẫn rất nhiệt tình và chăm chỉ fix bug từ phía vi-rs, update version mới liên tục. Mỗi lần update là một lần đổi interface, mình phải update theo mệt nghỉ =))


Kết thúc tuần 1. Tình hình bộ gõ vẫn chưa có tiến triển gì mấy ngoài một bản prototype