Nhật ký phát triển GõKey - Tuần 4 và 5

<- Quay về trang chủ

Thật là khó tin khi lần đầu tiên mình có một tuần bận việc ở công ty sấp mặt, không có cả thời gian để làm side project luôn

Số là mình vừa được chuyển từ vị trí Frontend sang Backend, tất nhiên là vẫn ở công ty hiện tại chứ không có nhảy việc, nhưng vừa phải bơi theo task vừa phải học lại đủ thứ để theo kịp chúng đồng nghiệp, sấp mặt. May mắn là vẫn được giữ nguyên title, nên lương không đổi Anyway, lan man quá, chuyện này chắc để dành kể ở một bài viết khác. Hy vọng tuần này có nhiều thời gian hơn để làm GõKey.

Chức năng system tray đã đề cập ở tuần trước thì tạm thời mình gác lại để tập trung vào các chức năng khác (vì làm mấy cái khác dễ hơn )

Ngày 02/07/2023

Chức năng tuỳ chỉnh hotkey (phần 1)

Hiện tại thì GõKey đang được hardcode để nhận diện tổ hợp phím ⌃ ⌘ Space cho việc bật/tắt chế độ gõ tiếng Việt, cho nên hôm nay mình implement vài thứ lặt vặt để chuẩn bị cho việc tuỳ chỉnh hotkey.

Mục tiêu là, về sau, chúng ta có thể define ra một file chứa thông tin cấu hình cho bộ gõ, file này sẽ có dạng như sau:

input_method = "TELEX"
shortcut_key = "super+ctrl+space"

Người dùng có thể thay đổi file này "bằng tay" hoặc thay đổi từ UI của bộ gõ. Để handle việc cấu hình phím tắt (shortcut_key), thì mình làm các bước sau:

  1. Implement một struct mới tên là Hotkey để cấu hình phím tắt.

    Struct này có khả năng nhận diện một string đầu vào dưới dạng "<key> + <key> + ...", ví dụ như "super + ctrl + H", "super + shift + alt + enter", và chuyển nó thành 1 biến kiểu char và 1 biến kiểu KeyModifier.

let hotkey = Hotkey::from("super+ctrl+space");

Khi Event Tap nhận được một event mới, thì chúng ta sẽ kiểm tra cặp giá trị (KeyModifier, Char) từ Event Tap với struct này, nếu các giá trị match với nhau thì tức là hotkey đang được nhấn:

fn event_handler(...) -> bool {
    match keycode {
        Some(keycode) => {
            if hotkey.is_match(modifiers, &keycode) {
                // hotkey pressed
            }
...

Ở bên dưới thì KeyModifier chỉ là một giá trị số nguyên dương 32-bit (u32), nên hàm Hotkey::is_match chỉ đơn giản là thực hiện phép so sánh giữa các giá trị cơ bản như u32, char:

pub fn is_match(&self, modifiers: KeyModifier, keycode: &char) -> bool {
    return self.modifiers == modifiers &&
           self.keycode.eq_ignore_ascii_case(keycode);
}
  1. Render một giá trị kiểu Hotkey trên UI.

    Sau khi tạo một object Hotkey rồi thì mình muốn tận dụng luôn object này để hiên trị dãy phím tắt đã được cấu hình trên UI luôn, để làm được việc này, thì chỉ cần implement trait [std::fmt::Display](https://doc.rust-lang.org/std/fmt/trait.Display.html) cho struct Hotkey, và sau đó chúng ta có thể sử dụng hàm Hotkey::to_string() để hiển thị dãy phím tắt và render lên UI.

impl Display for Hotkey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.modifiers.is_control() {
            // Phím ⌃ Control
        }
        if self.modifiers.is_super() {
            // Phím ⌘ Cmd trên macÓ
        }
        ...
        write!(f, "{}", keys)
    }
}

Bằng cách này thì chúng ta còn có thể kiểm soát được thứ tự render của các phím modifiers. Ví dụ input khi khởi tạo Hotkey được viết dưới dạng "super+ctrl+k", nhưng khi hiển thị, phím Control thường được render trước phím ⌘ Cmd.

Đến đây thì còn có một điểm tiện lợi nữa, đó là, trước đây chúng ta chia mã nguồn của từng hệ điều hành thành từng module riêng lẽ, ví dụ src/platform/macos.rs cho macOS, src/platform/windows.rs cho Windows. Nên việc define kí tự hiển thị cho từng phím modifier có thể được đưa vào từng module riêng của từng hệ điều hành, ví dụ phím Super của macOS là phím ⌘ Cmd, nhưng trên Windows thì nó là phím ⊞ Win, việc này có thể được handle bằng cách define kí tự hiển thị cho phím Super riêng ở riêng mỗi hệ điều hành:

// file macos.rs
pub const SYMBOL_SUPER: &str = "⌘";

// file windows.rs
pub const SYMBOL_SUPER: &str = "⊞";

Phần implement chi tiết các bạn có thể xem tại commit a16e2cc trên Github. Sau đây là screenshot tạm:

Đến đây coi như tạm xong công tác chuẩn bị, hôm nào có thời gian sẽ implement tiếp phần đọc file config và chức năng thay đổi config từ UI, hy vọng là xong trong tuần này :D Còn giờ thì tắt máy, đi cày phim tiếp.

Ngày 08/02/2023

Chức năng tuỳ chỉnh hotkey (phần 2)

Bước tiếp theo, chúng ta cần có một cơ chế để đọc và lưu config.

Vấn đề đầu tiên là, trên mỗi hệ điều hành khác nhau, file config sẽ nằm ở các vị trí khác nhau, ví dụ:

  • Trên macOS và Linux: $HOME/.goxkey
  • Trên Windows: C:\\Users\\<username>\\.goxkey

Để lấy được đường dẫn cho file config trên từng hệ điều hành, mình sẽ đọc từ biến môi trường:

// macOS and Linux
return env::var("HOME").ok().map(PathBuf::from);

// Windows
return env::var("USERPROFILE").ok().map(PathBuf::from)
    .or_else(|| env::var("HOMEDRIVE").ok().and_then(|home_drive| {
        env::var("HOMEPATH").ok().map(|home_path| {
            PathBuf::from(format!("{}{}", home_drive, home_path))
        })
    }))

Sau khi có đường dẫn thì chỉ việc mở file để đọc/ghi.

Để cho đơn giản thì file config sẽ có định dạng bao gồm nhiều dòng, mỗi dòng là một cặp giá trị key = value phân cách với nhau bởi kí tự =. Ở trong code, chúng ta sẽ có một struct tên là ConfigStore để làm nhiệm vụ đọc/ghi từ file này, và lưu giá trị vào một HashMap<String, String>. Khi khởi tạo, ConfigStore sẽ đọc toàn bộ nội dung của file .goxkey, lưu vào hashmap để có thể truy xuất bất cứ lúc nào. Khi một giá trị trong hashmap được thay đổi, chúng ta ghi toàn bộ nội dung hashmap vào lại file .goxkey.

Sau khi đã build xong cơ chế đọc/ghi config, chúng ta cần tiếp một struct để chứa nội dung config, gọi là ConfigManager. Struct này có nhiệm vụ mapping các key từ ConfigStore thành các giá trị cần dùng cho bộ gõ. Ở đây chúng ta có 2 giá trị config cần dùng, đó là Hotkey (phím tắt cho chế độ gõ tiếng Việt) và TypingMethod (chế độ VNI hay Telex).

Implementation chi tiết các bạn có thể xem tại commit 6bb6444.

Ngày 09/02/2023

Từ Contributor: Chức năng bỏ dấu dùng phím Z

Một chức năng mới được merge trong tuần này đó là khả năng bỏ dấu tiếng Việt của từ đang gõ, dùng phím Z trong chế độ Telex. Ví dụ:

từ đang gõ: đấy
gõ:         z
từ đang gõ: đây
gõ:         z
từ đang gõ: đay

Để implement thì cần phải add thêm chức năng remove dấu từ engine xử lý tiếng Việt (vi-rs), và add thêm phần handle phím z từ bộ gõ. Rất cảm ơn bạn @jason-nguyen17 đã contribute cho chức năng này. Các bạn có thể tham khảo các PR cho chức năng này tại đây:

Chức năng tuỳ chỉnh hotkey (phần 3)

Hôm nay bắt đầu tích hợp ConfigManager vào các phần còn lại của bộ gõ, thế là phát hiện ra có cái gì đó sai sai

Với cấu trúc hiện tại, toàn bộ thông tin của bộ gõ (như kiểu gõ, chế độ tiếng Việt,…) được giữ trong INPUT_STATE , nên có thể coi đây là single source of truth, ngoài ra không có bất cứ chỗ nào có thể chứa các thông tin này (để tiện quản lý).

À trừ một trường hợp, đó là UIDataAdapter, có chứa một bản tham chiếu của trạng thái gõ tiếng Việt (INPUT_STATE.is_enabled()) và chế độ gõ hiện tại (INPUT_STATE.typing_method()) để làm nhiệm vụ hiển thị trên UI. Từ phía UI, khi có thay đổi xảy ra, thì các giá trị sẽ được ghi trở lại vào UIDataAdapter và từ đó mình phải catch được những gì đã thay đổi, truyền lại đến INPUT_STATE

Để bảo đảm INPUT_STATE vẫn là single source of truth, thì mình chỉ có thể sử dụng ConfigManager để khởi tạo giá trị ban đầu cho INPUT_STATE khi bộ gõ khởi động, và thực hiện thao tác ghi config vào file từ các hàm setter của INPUT_STATE , và sau một hồi suy nghĩ thì mình nghĩ là sự tồn tại của ConfigManager có vẻ hơi thừa, đáng ra chỉ cần dùng mỗi ConfigStore

Nhưng mà thôi, cứ tạm note lại rồi sang tuần sau làm tiếp. 😔

Ngày 15/02/2023

ZeroX-DG release phiên bản v0.3.5 cho vi-rs, cải thiện thuật toán đặt dấu âm, và add thêm quy tắc cho các âm ư, ưu,... Fix được một số lỗi được report trong project GõKey như:

Hôm nay không code nên mình ngồi viết wiki, một vấn đề phổ biến khi sử dụng GõKey (và các bộ gõ khác) trên macOS là hiện tượng không gõ được tiếng Việt nữa sau một thời gian sử dụng, thông thường có 2 lý do:

  • Chưa cấp quyền Accessibility hoặc mất quyền Accessibility (do update binary)
  • Do chế độ Secure Keyboard Input được bật, hoặc do conflict với một trình password manager nào đó

Chi tiết về cách xử lý thì mình đã note lại trên Wiki nên sẽ không ghi ra đây, các bạn có thể tìm đọc tại link sau:

https://github.com/huytd/goxkey/wiki/Hướng-dẫn-sửa-lỗi-không-gõ-được-tiếng-Việt-trên-macOS

Ngày 16/02/2023

Chức năng tuỳ chỉnh hotkey (phần cuối)

Đây là giao diện của chức năng tuỳ chỉnh hotkey sau khi hoàn thành, sau bản update hôm nay thì người dùng GõKey đã có thể thay đổi cấu hình phím tắt để bật tắt chế độ gõ tiếng Việt, cũng như chuyển đổi giữa 2 chế độ gõ Telex và VNI tuỳ thích. Đây là screenshot mới nhất của GõKey tính tới thời điểm này:

Hôm nay tiếp tục với chức năng tuỳ chỉnh hotkey. Như đã đề cập ở tuần trước, hôm nay mình tiến hành gỡ hoàn toàn phần implementation của ConfigManager, xài một mình ConfigStore là đủ rồi.

Có một vấn đề mà mình đã tính sai từ khi implement ConfigStore đó là việc throw error khi khởi tạo ConfigStore trong trường hợp file cấu hình $HOME/.goxkey không tồn tại. Đáng ra, khi trường hợp này xảy ra thì mình phải nạp vào nội dung config mặc định:

let mut file = File::open(config_path.as_path());
let mut buf = String::new();

if let Ok(mut file) = file {
    // mở file thành công
    file.read_to_string(&mut buf);
} else {
    // mở file không thành công
    // nạp cấu hình mặc định
    buf = format!(
        "{} = {}\\n{} = {}",
        HOTKEY_CONFIG_KEY, "super+ctrl+space",
        TYPING_METHOD_CONFIG_KEY, "telex"
    );
}

Tiếp theo, thông tin về typing method (chế độ gõ Telex/VNI) và hotkey sẽ được đưa hết về INPUT_STATE để quản lý, các thành phần khác như event tap (để kiểm tra phím nhấn) hay UI (để hiển thị thông tin cấu hình) đều sẽ lấy các giá trị này từ bên trong INPUT_STATE. Flow tương tác giữa các thành phần trong bộ gõ có thể tóm tắt như hình sau:

Ở trong INPUT_STATE, mình implement thêm các method như set_hotkey(), get_hotkey() để đọc và ghi thông tin hotkey, set_method(), get_method() để đọc và ghi thông tin về typing method. Ở trong các hàm setter, bên cạnh việc cập nhật giá trị cho INPUT_STATE, chúng ta sẽ ghi luôn nội dung cấu hình vào ConfigStore:

pub fn set_hotkey(&mut self, key_sequence: &str) {
    // cập nhật giá trị cho INPUT_STATE
    self.hotkey = Hotkey::from_str(key_sequence);
    // ghi cấu hình vào ConfigStore
    CONFIG_MANAGER.lock().unwrap()
        .write(HOTKEY_CONFIG_KEY, key_sequence);
    if let Some(event_sink) = UI_EVENT_SINK.get() {
        _ = event_sink.submit_command(UPDATE_UI, (), Target::Auto);
    }
}

Ở event tap, khi xử lý từng phím nhấn thì chúng ta sử dụng hàm set_hotkey() của INPUT_STATE như này:

fn event_handler(
    handle: Handle,
    keycode: Option<char>,
    modifiers: KeyModifier
) -> bool {
    match keycode {
        Some(keycode) => {
            if INPUT_STATE.get_hotkey().is_match(modifiers, &keycode) {

Tiếp theo là implement UI để hiển thị và update cấu hình. Phần này khá là rối và tốn nhiều thời gian, có lẽ mình sẽ không đi vào chi tiết.

Ý tưởng là, một tổ hợp phím tắt sẽ bao gồm các phím như Super, Control, Alt, Shift và một phím kí tự cuối cùng. Ví dụ: Ctrl + Shift + Space.

Tạm thời trong phiên bản đầu tiên này, mình sử dụng một nhóm checkbox để người dùng có thể tick vào khi tuỳ chọn phím tắt, kèm theo một textbox để nhập vào phím kí tự cuối cùng trong tổ hợp phím.

Ở các phiên bản sau, có thể mình sẽ implement thao tác cấu hình phím tiện dụng hơn, như kiểu GoTiengViet, có thể không (lười :)))

Cách hoạt động của Druid (UI framework mà chúng ta sử dụng) là, khi khởi tạo từng UI element trên form, chúng ta sẽ kết nối element đó với một giá trị trong UIDataAdapter thông qua hàm .lens(). Khi có thay đổi ở một field trong UIDataAdapter, UI element tương ứng sẽ được update. Và tương tự, khi chúng ta cập nhật trạng thái của một UI element, thì field tương ứng trong UIDataAdapter cũng được cập nhật theo.

Việc cập nhật UI/state này được capture thông qua method update() của UI Controller.

fn update(&mut self,
    ...
    old_data: &UIDataAdapter,
    data: &UIDataAdapter,
    ...
) {
    // do something here
}

Bên trong method này, tham số old_data là phiên bản của UI state trước khi thay đổi xảy ra, và tham số data là trạng thái mới sau khi thay đổi.

Khi có thay đổi xảy ra trên UIDataAdapter, chúng ta tiến hành ghi những thay đổi đó vào INPUT_STATE:

if old_data.typing_method != data.typing_method {
    INPUT_STATE.set_method(data.typing_method);
}

Việc so sánh old_datadata giúp chúng ta phân biệt được khi nào thì UI state thực sự thay đổi để tiến hành update và ghi config, tránh việc ghi quá nhiều lần không cần thiết.

Implementation chi tiết các bạn có thể tham khảo tạo commit fefe07e.


Có một điều khá vui là dạo gần đây có nhiều bạn đã clone project về và tự compile để sử dụng, và report bug. Mặc dù thấy bug thì khá mệt nhưng mà mình rất vui vì ít ra như thế tức là đã bắt đầu có user rất cảm ơn các bạn đã nhiệt tình ủng hộ dự án, và mình hy vọng sẽ nhận được nhiều bug report hơn nữa.

Như đã đề cập ở đầu bài thì mấy tuần gần đây dự án tiến triển rất chậm, nên devlog ra không đều và không nhiều, rất mong các bạn thông cảm. Sau khi ổn định lại thì mình sẽ chăm viết devlog hơn :D Ngoài ra, devlog này chỉ cover những gì xảy ra trong dự án GõKey, rất nhiều những vấn đề hay ho về gõ tiếng Việt được xử lý từ phía vi-rs, mình nghĩ các bạn nên gây áp lực để tác giả vi-rs chịu ngồi xuống và viết devlog hàng tuần giống như mình :smiling_imp:.