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

<- Quay về trang chủ

Có một cái fun fact khá thú vị mà mình quên nhắc tới, đó là series bài viết này được gõ bằng bộ gõ GõKey. Vẫn còn một số lỗi vặt tuy nhiên có thể tạm coi là dogfooding thành công =))


Kiến trúc sơ bộ của GõKey tính đến thời điểm này có thể được tóm tắt như hình sau:

INPUT LAYER
┌──────────────────┐              FRONTEND                ENGINE
│ macOS            │ [d,d,a,a,y]   ┌─────────┐ "ddaay"     ┌───────┐
│  +- CGEventTap   ├─────────────►│ goxkey  ├───────────►│ vi-rs │
│                  │               └┬──▲────┘             └┬──────┘
│ Linux   (TBD)    │                │  │                    │
│ Windows (TBD)    │                │  │              "đây" │
└──────────────────┘                │  └────────────────────┘
                                    │
                                    │ (send_key)
                                    ▼
                                 Target
                                 Application

GõKey đóng vai trò là một cái frontend để tiếp nhận keyboard event từ hệ điều hành. Các phím nhận vào sẽ được lưu vào một buffer để gửi đến engine xử lý tiếng Việt (là vi-rs). Engine có nhiệm vụ transform cái buffer đấy và trả về một từ tiếng Việt hoàn chỉnh. Frontend sẽ thực hiện việc xoá và thay thế từ đang gõ trên màn hình thành từ tiếng Việt đó thông qua kĩ thuật Backspace giả (gửi backspace xoá từ đang gõ, gửi tiếp từ cần gõ vào).

Ngày 15/01/2023

Vấn đề mất chữ khi gõ

Tính đến thời điểm này, tốc độ gõ đã cải thiện được một cách tương đối. Nếu gõ với tốc độ chậm rãi thì bộ gõ hoạt động trơn tru, tuy nhiên, nếu gõ nhanh hơn một tí thì sẽ xảy ra hiện tượng mất chữ, ví dụ:

ccá gì cơ?

Lỗi này xảy ra một cách ngẫu nhiên trong khi gõ nên việc debug cũng hơi khó. Nhất là khi ở phía bộ gõ lẫn engine thì mọi việc trông rất bình thường:

Bộ gõ tiếp nhận phím đúng thứ tự, và cũng gửi phím đúng thứ tự.

Sau khi đem vấn đề này ra bàn thì @unrealhoang (lại là một siêu nhân khác) tìm ra ngay nguyên nhân đó là thứ tự gửi phím từ GõKey thì đúng, nhưng thứ tự mà hệ điều hành nhận được phím thì không. Ví dụ trong trường hợp trên:

┌────────────────────┐        ┌────────────────────────────┐
│ User gõ:   "c"     │        │ Hệ điều hành nhận: "c"     │
│ User gõ:   "a"     │        │ Hệ điều hành nhận: "a"     │
│ User gõ:   "s"     │ ────► │ Hệ điều hành nhận: "s"     │
│ GõKey gửi: 3x Bksp │        │ Hệ điều hành nhận: "i"     │
│ GõKey gửi: "cá"    │        │ Hệ điều hành nhận: 3x Bksp │
│ User gõ:   "i"     │        │ Hệ điều hành nhận: "cá"    │
└────────────────────┘        └────────────────────────────┘

Nếu đúng theo “quy trình” thì kí tự “i” sẽ được gửi sau khi bộ gõ giải quyết xong chữ “cá”, nhưng do cách xử lý của GõKey là tạo ra event object mới khi gửi phím, thứ tự các event gửi đến hệ điều hành sẽ không đúng như ban đầu. Hệ quả là, user gõ vào “casi” (cái), thì hệ điều hành sẽ nhận hết “casi” sau đó mới nhận được lệnh xoá 3 kí tự, tiếp đó là nhận được string “cá”, kết quả trên màn hình hiện ra “ccá”.

Để giải quyết vấn đề này, chúng ta phải thực hiện việc gửi backspace và từ tiếng Việt mới từ chính event diễn ra khi nhấn “s”. Thay vì sử dụng một event object mới khi gửi phím, chúng ta cần tạo một event sử dụng proxy từ event gốc nhận được từ hệ điều hành. Nếu xài interface của core-graphics thì làm việc này không tiện, cho nên @unrealhoang phải viết lại luôn toàn bộ binding.

Sau khi merge bản fix thì bộ gõ hoạt động khá ổn định, gõ chữ nào ra chữ đấy, không bị mất chữ nữa.

Đổi tên dự án

Ban đầu dự án được đặt tên là bogo-rs (bộgõ-rs), nhưng cái tên bogo thì gần như là “thương hiệu” của team làm ibus-bogo rồi, với lại nếu chẳng may sản phẩm của mình có người dùng, thì đâu ai quan tâm tới chuyện nó được làm bằng Rust đâu mà phải thêm cái đuôi “-rs” vào.

Nghĩ thế nên mình quyết định đổi tên, cách mình nghĩ ra tên mới cũng rất đơn giản: bỏ chữ Bộ, và thêm chữ Key cho bà con biết đây là một cái bộ gõ, thế là cái tên GõKey chính thức được sử dụng

Handle các modifiers key

Hiện tại, bộ gõ chỉ mới handle được các phím bình thường, và phím Shift, chưa xử lý các phím modifiers khác như Ctrl, Cmd, Alt,… nên mình implement thêm phần này. Cơ chế handle modifier thì rất đơn giản, dùng kĩ thuật bitflags trên một giá trị kiểu u32. Mỗi khi một phím modifier được nhấn thì flip một bit ở một vị trí cụ thể trong giá trị u32 đó. Cách này khá phổ biến nên mình không đi sâu vào làm gì nữa.

Từ chức năng trên, mình implement tiếp chức năng bật/tắt bộ gõ khi một tổ hợp phím tắt được nhấn, hiện tại mình đang dùng tổ hợp phím mình quen xài, là ^ ⌘ Space

// Toggle Vietnamese input mod with Ctrl + Cmd + Space key
if modifiers.is_control() && modifiers.is_super() && keycode == KEY_SPACE {
	input_state.toggle_vietnamese();
	return true;
}

Cũng từ khả năng xử lý modifier key thì mình thêm vào một chức năng đó là xoá input buffer hiện tại khi một trong các modifier key được nhấn.

Ngày 16/01/2023

Hôm nay nghỉ lễ nên không code gì nhiều.

Vẫn còn một bug khá nghiêm trọng, đó là khi bộ gõ được bật, và néu bạn xài một editor nào đó như Vim chẳng hạn, các phím di chuyển vẫn sẽ được track, và khi bộ gõ bắt đầu gửi phím thì sẽ gửi luôn các phím đã được track trước đó:

User gõ: "jkjkigox"
Bộ gõ dịch: "jkjkĩgo"

Mình tạm giải quyết vấn đề này bằng cách ngừng việc cập nhật input buffer khi chế độ gõ tiếng Việt được tắt.

Đến bước này thì trải nghiệm gõ tiếng Việt có vẻ đã khá là OK. Nên mình tập trung vào một phần khác đó là UI.

Việc lựa chọn UI framework trong Rust cũng khá là mệt. Có rất nhiều framework khác nhau nhưng có rất ít framework đáp ứng tốt cho việc sử dụng trên nhiều hệ điều hành.

Đối với một bộ gõ thì UI cũng không quá phức tạp, chỉ cần 1 bảng điều khiển nhỏ, nên mình quyết định sẽ dùng native UI, các option dùng web-based UI như Azul, Tauri hay phức tạp như Dioxus sẽ bị gạt qua (Dioxus có backend cho native Desktop nhưng vẫn đang phát triển, chưa ready để sử dụng).

Các framework dùng immediate mode như imgui-rs hay egui có vẻ tạm đáp ứng được yêu cầu, nhưng UI quá xấu :))

Các thư viện sử dụng Elm Architecture như IcedRelm thì có API trông rất dễ xài, nhưng Relm thì hình như chỉ có GTK backend, không biết xài trên macOS và Windows có được không. Iced là một ứng cử viên sáng giá, với nhiều showcase xịn, nhưng nếu sử dụng thì có vẻ phải tự vẽ các UI element lại để cho cảm giác gần giống với native UI, tốn effort vào những việc như thế thì không đáng.

Có 2 thư viện cung cấp native UI binding của hệ điều hành là libUIcacao, trong đó libUI có thể sử dụng được trên cả macOS, Windows và Linux, còn cacao thì chỉ hoạt động trên macOS. Như vậy libUI là phù hợp hơn, chỉ tiếc là mình không compile được trên con lap M1 😟

Như vậy, ứng viên duy nhất là Druid, mình đã sử dụng framework này cho một project trước đây, trải nghiệm không hẳn là 10/10 nhưng cũng đủ xài. Bản thân Druid tồn tại khá nhiều vấn đề, và hiện tại team phát triển của Druid đang chuyển sang một hướng mới đó là Xilem, tuy nhiên Druid vẫn đang tiếp tục được maintain. Thôi thì xài Druid.

Ngày 17/01/2023

Sau khi chọn được UI framework thì mình bắt tay vào implement phiên bản đầu tiên. Cấu trúc của chương trình sau khi tích hợp thêm UI trông nó như này:

UI Thread                                  IME Thread
┌------------------------------------┬---------------┐
|          UI Events                 |               |
|         ┌────────────┐             |               |
| ┌───────┴───┐    ┌───▼─────────┐  |     ┌───────┐ |
| │ UI Window │    │ Data Adapter ├──────►│ GõKey │ |
| └──────▲───┘    └───┬──────────┘  |     └───────┘ |
|         └────────────┘             |               |
|          State Updates             |               |
└------------------------------------┴---------------┘

Chúng ta sẽ có 2 thread, một dành cho bộ gõ, và một dành cho UI. Phần UI sẽ giao tiếp với bộ gõ (tạm gọi là IME thread) thông qua một Data Adapter, mình tạm đặt tên là GoxData . Adapter này có nhiệm vụ đọc và báo cho UI biết trạng thái của bộ gõ (như là, chế độ tiếng Việt có đang bật không, đang xài kiểu gõ gì,…), và khi UI có cập nhật (ví dụ người dùng bấm nút bật tắt bộ gõ), thì Adapter sẽ update trạng thái của bộ gõ.

Giao diện đầu tiên của GõKey, trông rất “uy tín” =)))

Cho đến lúc này thì giao tiếp giữa IME và Data Adapter chỉ là một chiều (từ phía Data Adapter), chưa có cách nào để IME chủ động “liên lạc” với UI thread. Trong trường hợp có thay đổi xảy ra với IME từ một nguồn khác (ví dụ user nhấn tổ hợp phím bật tắt bộ gõ, việc này được handle ở IME thread chứ không phải ở UI), thì chúng ta cần có cách để IME thông báo cho UI và update.

Druid có một cơ chế cho phép user gửi/nhận custom event thông qua việc implement trait druid::Controller . Ở đây, chúng ta chỉ cần IME gửi đến UI một event đơn giản để thông báo cho UI biết là đã đến lúc phải cập nhật lại UI, nên việc implement cũng khá đơn giản:

pub struct GoxUIController;
impl<W: Widget<GoxData>> Controller<GoxData, W> for GoxUIController {
    fn event(
        &mut self,
        child: &mut W,
        ctx: &mut EventCtx,
        event: &Event,
        data: &mut GoxData,
        env: &Env,
    ) {
        match event {
            Event::Command(cmd) => {
                match cmd.get(UPDATE_UI) {
                    Some(_) => {
                        let input_state = INPUT_STATE.lock().unwrap();
                        data.update(&input_state);
                    },
                    None => {}
                }
            },
            _ => {}
        }
        child.event(ctx, event, data, env)
    }
}

Custom Event sẽ được truyền vào UI thread thông qua một Event Sink, được tạo ra khi khởi tạo UI, và được truyền vào IME thread:

// Khởi tạo UI app
let app = AppLauncher::with_window(win);
let event_sink = app.get_external_handle();

thread::spawn(|| {
  // Khởi động IME thread, truyền event_sink vào
	run_event_listener(&event_handler, event_sink);
});

Cấu trúc của chương trình lúc này trông sẽ như này:

UI Thread                                  IME Thread
┌------------------------------------┬---------------┐
|         UI Events                  |               |
|         ┌────────────┐             |               |
| ┌───────┴───┐    ┌───▼─────────┐  |     ┌───────┐ |
| │ UI Window │    │ Data Adapter ├──────►│ GõKey │ |
| └──────▲───┘    └───┬──▲──────┘  |     └───┬───┘ |
|         └────────────┘   │         |   Event Sink  |
|          State Updates   │         |         │     |
|                          │         |         │     |
|              ┌───────────┴──────┐  |         │     |
|              │ Event Controller ├────────────┘     |
|              └──────────────────┘  |               |
└------------------------------------┴---------------┘

Ban đầu thì mình truyền Event Sink vào IME thread, nhưng sau đó @unreadhoang gợi ý là, có thể để Event Sink là một global object, dùng OnceCell. Event Sink của UI chỉ được tạo ra ở runtime (trong scope của hàm main), trong khi global object phải được initialize ngay khi được declare (thường là ở bên ngoài hàm main ), và xài OnceCell giải quyết được vấn đề này:

// declare trước
static UI_EVENT_SINK: OnceCell<ExtEventSink> = OnceCell::new();

// initialize sau
fn main() {
	let event_sink = app.get_external_handle();
	UI_EVENT_SINK.set(event_sink);
}

Đây là thành phẩm của UI sau hơn 20 tiếng code, trông ngầu hơn rất nhiều:

Sẵn đang làm UI nên mình làm luôn cái logo để làm icon cho app, vừa làm xong thì @zerox-dg cũng suggest việc tạo logo :)) Nhìn chung mọi thứ có vẻ đã rất tươm tất.

À nhân tiện, nếu các bạn quan tâm và muốn xài thử, thì từ bây giờ đã có thể checkout mã nguồn về và compile với lệnh cargo bundle, sau đó chỉ việc copy file Gõ Key.app vào thư mục /Applications để sử dụng.

Ngày 18/01/2023

Hôm nay implement thêm một chức năng nhỏ cho GõKey, cho phép bộ gõ tạm dừng track việc gõ một từ mà không cần phải tắt hoàn toàn chế độ gõ tiếng Việt.

Chức năng này hữu ích trong trường hợp người dùng đang gõ một từ tiếng Anh trong chế độ gõ tiếng Việt. Bộ gõ sẽ phát hiện khi nào người dùng cố tìm cách gõ một từ tiếng Anh và tắt chế độ tracking đi. Ví dụ:

rus -> rú
rús -> rus
(stop tracking)
rust

Logic để phát hiện việc người dùng cố tìm cách gõ tiếng Anh, tạm thời sẽ được quyết định bằng quy tắc sau, khi engine xử lý tiếng Việt trả về kết quả: Nếu input buffer có chứa một nguyên âm có dấu, và output buffer không chứa nguyên âm nào có dấu, thì đó là khi user đang cố tìm cách gõ tiếng Anh.

Ở ví dụ trên, khi cố gắng gõ chữ “rust”, ở vị trí “rú”, chúng ta phải gõ thêm một chữ “s” nữa, khi đó input buffer là [r, ú, s], output sẽ là [r, u, s]

Chức năng này phụ thuộc nhiều vào khả năng huỷ bỏ dấu của engine tiếng Việt, nhưng tính tới thời điểm hiện tại thì hoạt động khá hiệu quả.

Đến đây thì mình tạm thời không code tiếp nữa mà ngồi viết blog =)) nên mới có bài viết tuần trước và bài viết này. Viết lại thì thấy không dài, nhưng cũng tốn khá nhiều thời gian. Tuy nhiên nếu không viết thì ít hôm nữa lại quên hết.


Bây giờ thì tắt máy chuẩn bị ăn Tết, ra Tết lại code tiếp. Rất cảm ơn các bạn đã theo dõi bài viết, và chúc các bạn một năm mới an khang thịnh vượng, vạn sự như ý, gặt hái nhiều thành công! (Nhưng mà, chắc là hết Tết rồi thì mọi người mới đọc được bài viết này :)))