Làm bộ gõ tiếng Việt cho macOS

36 phút đọcBản Markdown

Bài này do AI viết: Claude Code (mô hình Claude Opus 5.5) viết từ các prompt của mình. Ý tưởng, các prompt và những chỗ phải sửa nằm ở cuối bài.

Tóm tắt

  • Với mỗi phím Telex hoặc VNI, engine của JOKey thử mọi luật gõ rồi tra từ điển để chọn kết quả; đổi cách đặt dấu (hòa hay hoà) chỉ là đổi từ điển. Engine
  • Kiểm tra chính tả dùng thêm một từ điển tiếng Anh, nên class và affect được giữ nguyên, còn test ra tét vì đó là một chữ tiếng Việt. Kiểm tra chính tả
  • JOKey 2.0 chỉ gõ Unicode. TCVN3 và VNI Windows là bảng mã 8 bit cũ, cần font riêng. Bảng mã
  • Trên macOS, JOKey 2.0 là một keyboard layout cộng một app chạy nền dùng event tap: khi JOKey còn là input method, các app WebKit nhận phím sai thứ tự. Ba cách đưa chữ vào app
  • Phần lớn lỗi nằm ở cách từng app nhận phím (gợi ý tự động, Spotlight, Secure Input), nên JOKey được kiểm thử bằng cách gõ vào Safari, Chrome, Google Docs, Spotlight và Terminal, cả bằng một bàn phím ESP32. Kiểm thử

Đọc trong 1 phút. Phần dưới đi vào chi tiết; chọn mục bạn quan tâm trong mục lục.

Mục lục (12 mục)
  1. Tiếng Việt khó gõ ở đâu
  2. Engine
  3. Kiểm tra chính tả
  4. Bảng mã TCVN3, VNI Windows và Unicode
  5. Ba cách đưa chữ vào app trên macOS
  6. Gửi phím xoá và chữ vào app
  7. Bộ nhớ
  8. Kiểm thử
  9. Các bộ gõ khác
  10. Nếu bạn định làm bộ gõ
  11. Cài JOKey
  12. Bài này được viết thế nào

JOKey là bộ gõ tiếng Việt mình làm cho macOS. Engine của nó có từ năm 2018, khi mình viết ibus-teni, một bộ gõ cho Linux, bằng Go. Năm 2021 mình mang engine đó sang macOS thành JOKey. Tháng 10/2026 mình dùng Claude Code viết lại toàn bộ thành JOKey 2.0: engine chuyển sang Rust, app Swift build không cần Xcode, và JOKey không còn là một input method nữa. Trước đó nữa, năm 2008, hồi học cấp 3, mình viết thư viện TCVN để hiển thị và gõ tiếng Việt trong Turbo Pascal.

Mã nguồn của JOKey và của engine Go (vengine) không công khai. Các đoạn mã trong bài là trích đoạn ngắn từ mã nguồn đó, có thêm chú thích tiếng Việt; ibus-teni, bản gốc của engine, là mã nguồn mở (GPL-3.0).

Bộ gõ nhận các phím vieetj và cho ra chữ việt. Để chữ đó hiện đúng trong mọi ô nhập, từ TextEdit tới thanh địa chỉ của Safari, Spotlight, Google Docs hay Terminal, JOKey phải xử lý nhiều việc hơn thế. Bài này lần lượt nói về luật gõ, engine, từ điển, kiểm tra chính tả, bảng mã, các cách macOS cho bộ gõ can thiệp vào phím, và các lỗi chỉ xuất hiện khi gõ vào từng app. Các số đo lấy từ các lần kiểm thử của JOKey. Giữa bài có một ô gõ thử chạy engine của JOKey trong trình duyệt.

Tiếng Việt khó gõ ở đâu

Chữ Quốc ngữ có 29 chữ cái, trong đó bảy chữ không có trên bàn phím Latin: ă, â, đ, ê, ô, ơ, ư. Thêm năm dấu thanh (sắc, huyền, hỏi, ngã, nặng) đặt trên 12 nguyên âm a, ă, â, e, ê, i, o, ô, ơ, u, ư, y. Đếm cả chữ hoa, bộ gõ phải tạo ra 134 ký tự mà bàn phím không có: 12 nguyên âm × 5 thanh là 60, thêm 6 nguyên âm có mũ, móc, trăng và chữ đ là 67, nhân đôi cho chữ hoa. Bảng tone_charset trong engine của JOKey có 134 ký tự, và một test kiểm tra con số này.

Mỗi chữ tiếng Việt là một âm tiết, gồm tối đa năm phần: phụ âm đầu, âm đệm, âm chính, âm cuối và thanh. Dấu thanh đặt trên một nguyên âm, và nguyên âm đó phụ thuộc vào cấu tạo của cả chữ:

ChữÂm đầuÂm đệmÂm chínhÂm cuốiThanhDấu nằm trên
ngườingươihuyềnơ
thuyềnthuyênhuyềnê
giữagiưangãư
hoànghoanghuyềna
hòa / hoàhoahuyềno hoặc a

Dòng cuối cho thấy hai cách đặt dấu đang cùng được dùng. Chúng chỉ khác nhau ở các vần oa, oe, uy không có âm cuối: kiểu cũ viết hòa, khỏe, thủy; kiểu mới viết hoà, khoẻ, thuỷ. Khi có âm cuối, hai kiểu giống nhau: hoàng, thuyền, khuyết. Từ điển của JOKey có 111 chữ viết được theo hai kiểu này. Quy định chính tả cho sách giáo khoa phổ thông (Quyết định 1989/QĐ-BGDĐT, 2018) dùng kiểu mới, còn engine UniKey và OpenKey mặc định dùng kiểu cũ. JOKey có tuỳ chọn “Dấu thanh cân đối: hòa, thủy (tắt: hoà, thuỷ)”, bật sẵn.

Kiểu gõ Telex và VNI

Bàn phím chỉ có 26 chữ cái, nên mỗi kiểu gõ quy ước phím nào thêm dấu nào. JOKey có hai kiểu gõ phổ biến nhất là Telex và VNI:

Dấu Telex VNI Ví dụ Telex → chữ
sắc s 1 as → á
huyền f 2 af → à
hỏi r 3 ar → ả
ngã x 4 ax → ã
nặng j 5 aj → ạ
â, ê, ô aa ee oo a6 e6 o6 ddaay → đây
ă aw a8 awn → ăn
ơ, ư ow uw o7 u7 nguwowif → người
đ dd d9 ddi → đi
bỏ dấu z 0 asz → a (tuỳ chọn)

Telex dùng chữ cái nên tay không phải rời hàng phím chữ, nhưng các phím s f r x j w a e o d vừa là chữ vừa là dấu, nên bộ gõ phải nhận ra lúc người dùng gõ tiếng Anh (xem phần kiểm tra chính tả). VNI dùng phím số nên ít nhầm hơn, nhưng phải với tay lên hàng số.

Người gõ không chọn vị trí đặt dấu; bộ gõ chọn. Người dùng Telex có thể gõ dấu ở cuối chữ (vieetj), sau nguyên âm (vieejt) hoặc ở giữa (vietje), và cả ba cách phải ra việt.

Engine

Vì sao không thể chỉ sửa ký tự cuối

Cách đơn giản nhất là cho mỗi phím dấu sửa ký tự cuối cùng, nhưng cách này sai với nhiều chữ thường gặp. Dưới đây là chữ engine của JOKey hiện ra sau mỗi phím (mỗi mũi tên là một phím):

vieetj      v → vi → vie → viê → viêt → việt
vietje      v → vi → vie → viet → viẹt → việt
thuowngf    t → th → thu → thuo → thuơ → thuơn → thương → thường
hoafn       h → ho → hoa → hòa → hoàn
mongo       m → mo → mon → mong → mông
  • Phím dấu thường không sửa ký tự cuối: trong vieetj, phím j đặt dấu nặng lên ê nằm trước t.
  • Kết quả của một phím còn phụ thuộc vào các phím gõ sau nó: thuo + w hiện thuơ (để còn thành thuở), gõ tiếp ng thì phải thành thương. hoa + f hiện hòa, gõ thêm n thì dấu phải chạy từ o sang a: hoàn.
  • Cùng một phím, lúc là dấu, lúc là chữ: chữ o cuối của mongo biến o đầu thành ô.
  • Có những chuỗi phím không được thêm dấu: class, window, javascript.

Thử mọi luật rồi tra từ điển

Engine của JOKey không cài cứng quy tắc đặt dấu. Với mỗi phím, nó sinh ra mọi cách hiểu có thể, tra từng cách trong từ điển, rồi giữ cách tốt nhất. Luật gõ nằm trong bốn bảng (rules.rs):

  • char_adding: phím thêm dấu cho một ký tự: s biến a → á, ư → ứ…; d biến d → đ; a biến a → â; w biến a → ă, o → ơ, u → ư; z gỡ dấu.
  • char_changing: phím đổi dấu đang có: s biến à → á; w biến â → ă, ô → ơ.
  • str_adding: phím biến cả một cụm: uo + w thành uơ hoặc ươ (thử theo thứ tự).
  • str_changing: úo + w thành ướ, oa + w thành oă…

VNI không có bảng riêng; mỗi phím số dùng lại bảng của phím Telex tương ứng.

Xem mã: VNI dùng lại bảng luật của Telex (Rust)
/// VNI digits reuse the Telex letter rules (typerule_replace_vni.go).
fn add_vni_char_rules(adding: &mut CharMap, changing: &mut CharMap) {
    for (digit, letter) in [('0', 'z'), ('1', 's'), ('2', 'f'), ('3', 'r'), ('4', 'x'), ('5', 'j')] {
        if let Some(m) = adding.get(&letter).cloned() {
            adding.insert(digit, m);
        }
        // ...
    }
    let six = join(&[adding.get(&'a'), adding.get(&'e'), adding.get(&'o')]);
    adding.insert('6', six);
    adding.insert('7', pairs(&[("oóòỏõọ", "ơớờởỡợ"), ("uúùủũụ", "ưứừửữự")]));
    adding.insert('8', pairs(&[("aáàảãạ", "ăắằẳẵặ")]));
    // 9 là d → đ
}

Với mỗi phím, engine lần lượt thử: đổi cả một cụm (str), đổi một ký tự, tìm từ cuối chữ lên đầu (char), thêm phím như một chữ cái thường (append), và bỏ dấu khi phím dấu được gõ lặp (revert, ví dụ ss bỏ dấu sắc). Mỗi ứng viên được tra trong từ điển và chấm điểm:

pub enum Find {
    NotMatch = 0,    // không thể thành chữ nào
    MatchPrefix = 1, // mới là phần đầu của một chữ
    Revert = 2,      // gõ lặp phím dấu để bỏ dấu
    MatchFull = 3,   // là một chữ trong từ điển
}

impl Case {
    fn better(&self, other: &Case) -> bool {
        self.find > other.find || (self.find == other.find && self.value.len() < other.value.len())
    }
}

(Chú thích tiếng Việt được thêm cho bài viết.) Ứng viên có điểm cao hơn được chọn; nếu bằng điểm thì chọn chữ ngắn hơn. Engine dừng tìm khi gặp một ứng viên MatchFull.

Ví dụ hoa + f. Luật char tìm từ cuối chữ lên đầu nên thử a trước, ra hoà. Với dấu kiểu cũ, hoà chưa phải một chữ mà chỉ là phần đầu của hoàn, hoàng (MatchPrefix), nên engine thử tiếp o và được hòa, một chữ trọn vẹn. Với dấu kiểu mới, hoà đã là một chữ nên engine dừng ở đó. Vì thế, để đổi kiểu đặt dấu, JOKey chỉ cần đổi từ điển: hai kiểu khác nhau ở 111 chữ nói trên, nằm trong hai tệp vietnamese.std.dict và vietnamese.new.dict.

Khi một chữ cái gõ thêm làm chữ đang có không còn đúng chính tả (hòa + n thành hòan), engine gỡ dấu ra rồi đặt lại bằng phím đã tạo ra dấu đó (try_swap_tone): hòan → hoan + f → hoàn. Dấu móc được xử lý tương tự: thuơ + ng = thuơng chỉ là phần đầu của một chữ, nên engine gỡ móc thành thuong rồi áp lại phím w. Cụm uo + w có hai cách đổi: uơ (thuơng, vẫn chỉ là phần đầu) và ươ (thương, một chữ trọn vẹn); engine chọn cách thứ hai.

Xem mã: try_swap_tone (Rust)
/// When a word does not match, try moving its tone mark onto a vowel
/// where it does (e.g. "hoà" -> "hòa").
fn try_swap_tone(&self, original: Case) -> Case {
    if original.find == Find::MatchFull {
        return original;
    }
    // bỏ dấu đầu tiên tìm thấy, nhớ phím đã tạo ra nó (f, s, w…)
    // rồi thử lại phím đó với luật str và char; giữ kết quả nếu tốt hơn
}

Từ điển

Từ điển tiếng Việt của JOKey có 8.104 chữ thông dụng, 286 chữ đặc biệt (chữ viết tắt như bđs, cđ; chữ mượn như café, axít, boóng) và 111 chữ theo kiểu đặt dấu. Tệp giấy phép kèm theo ghi nguồn: dữ liệu từ điển của Hồ Ngọc Đức (GPL), Wiktionary tiếng Việt (CC BY-SA) và danh sách chữ viết tắt của Ngô Quốc Hưng (được tác giả cho phép).

Từ điển phải trả lời hai câu hỏi: chuỗi đang gõ đã là một chữ chưa, và nó còn có thể trở thành chữ nào không. Câu hỏi thứ hai khó hơn, vì chữ gõ dở thường thiếu dấu: khi mới gõ tới tieng, chữ chưa có dấu mũ và dấu sắc. Engine thêm mỗi chữ vào trie cùng mọi cách viết “hạ bậc” (down-level) của từng chữ cái, ví dụ ấ hạ được thành â, á hoặc a. Chữ tiếng vì thế tạo ra trong trie cả các đường tiêng, tiéng, tieng. Chuỗi tieng cho kết quả MatchPrefix (vẫn có thể thành tiếng), còn tiengx cho NotMatch.

Xem mã: các test tra từ điển (Rust)
let d = Dict::new(true, true);
assert_eq!(d.find_viet(&w("tiếng")), Find::MatchFull);
assert_eq!(d.find_root(&w("tieng")), Find::MatchPrefix);
// một cách viết hạ bậc là đường đi trong từ điển gõ, nhưng không phải một chữ
assert_eq!(d.find_viet(&w("tieng")), Find::NotMatch);
assert_eq!(d.find_root(&w("tiengx")), Find::NotMatch);

Cả hai loại mục nằm trong cùng một trie, phân biệt bằng cờ trên mỗi nút: ROOT_FULL (kết thúc một mục của từ điển gõ), WORD_FULL (kết thúc một chữ trong danh sách từ) và WORD_PATH (nút nằm trên đường đi của một chữ trong danh sách từ).

Phím xoá

Mỗi phím đẩy thêm một kết quả lên ngăn xếp, nên phím xoá chỉ cần bỏ kết quả trên cùng:

Phím Chữ hiện
v v
i vi
e vie
e viê
t viêt
j việt
⌫ viêt (bỏ kết quả của phím j, tức dấu nặng)

Cách xoá này giúp sửa dấu nhanh: gõ nhầm dấu thì nhấn ⌫ một lần, dấu mất mà chữ vẫn còn. Nhiều người dùng quen với việc ⌫ xoá cả chữ cái cuối như ở các bộ gõ khác và đã đề nghị có tuỳ chọn này, nên JOKey 2.0 thêm tuỳ chọn “Phím ⌫ xoá cả ký tự”. Sau khi xoá, engine phải ở trạng thái như thể người dùng đã gõ chữ ngắn hơn, để phím tiếp theo vẫn ghép dấu được. JOKey làm việc này qua ba bước:

  1. Bỏ dần từng phím cho tới khi chữ ngắn đi một ký tự. tiếng (gõ tieesng) ⌫ → bỏ g → tiến.
  2. Nếu không được (bỏ j của việt ra viêt, bỏ tiếp t ra viê, không bao giờ qua việ), gõ lại chữ ngắn hơn từ đầu bằng phép ngược của bảng luật (spelling.rs: việ → vieej) và chỉ giữ kết quả nếu engine hiện ra việ. Gõ tiếp n ra viện.
  3. Nếu vẫn không được, giữ nguyên các ký tự làm “phím” (khoảng 0,6% trường hợp). Ví dụ hoàng ⌫⌫ → hoà: với dấu kiểu cũ, gõ lại hoaf sẽ ra hòa, không phải hoà.

Test của tính năng này xoá qua từng chữ trong từ điển ở cả 8 chế độ (237 nghìn lần xoá, mỗi lần chữ phải ngắn đi một ký tự), gõ lại chữ cái vừa xoá cho 26 nghìn chữ, và chạy 120 nghìn chuỗi phím ngẫu nhiên.

JOKey còn có một ngăn xếp ở mức từ. Khi bạn gõ dấu cách, JOKey giữ lại tối đa hai từ vừa gõ, nên ⌫ lùi qua dấu cách đưa từ trước đó về trạng thái đang gõ. Gõ vieet nam, xoá lùi về viêt rồi gõ j sẽ ra việt.

Gõ thử engine của JOKey

Ô dưới đây chạy engine Rust của JOKey, biên dịch sang WebAssembly. Gõ phím vào ô như khi dùng bàn phím ABC (nếu máy bạn đang bật bộ gõ tiếng Việt thì tắt đi, hoặc bấm các nút thử nhanh). Engine xử lý lại từng từ và hiện từng bước của từ cuối cùng. Phần tô vàng là từ chưa kết thúc.

Tiếng Việt

  1. VV
  2. iVi
  3. eVie
  4. eViê
  5. tViêt
  6. jViệt

Gõ dấu cách để kết thúc từ.

Tắt “Kiểm tra chính tả” rồi gõ hax, hoặc bỏ chọn “Dấu kiểu cũ” rồi gõ hoaf, để xem kết quả thay đổi thế nào khi engine dùng từ điển khác.

Kiểm tra chính tả

Với Telex, mười chữ cái s f r x j w a e o d vừa là chữ vừa là dấu, nên khi người dùng gõ chữ tiếng Anh, bộ gõ dễ thêm dấu vào chỗ không cần. Tuỳ chọn “Kiểm tra chính tả (giữ nguyên từ tiếng Anh)” của JOKey (bật sẵn) dùng từ điển để tránh việc này. Bảng dưới là kết quả của engine với Telex và kiểm tra chính tả bật:

Phím gõLúc đang gõSau dấu cáchVì sao
class, window, javascriptclass, window, javascriptgiữ nguyênkhông luật nào cho ra chữ tiếng Việt
affectafectaffectphím là một chữ tiếng Anh
coffee, sorrycofee, sorycoffee, sorrynhư trên
etecêt → etecetec"êtc" không thành chữ, trả lại phím
haxhãhax"hã" không có trong từ điển
tiengstiéngtiengsthiếu dấu mũ, chưa thành chữ
testtéttét"tét" là một chữ tiếng Việt
tessttesttestss bỏ dấu sắc
mongomôngmông"mông" là một chữ tiếng Việt
useuéuétừ điển Anh không có chữ dưới 4 chữ cái
useruseruserphím là một chữ tiếng Anh
chuwaaa, ddiiichưaaa, điiichưaaa, điiichữ có trong từ điển, chỉ lặp chữ cái cuối

Engine làm việc này ở ba chỗ:

  1. Khi không luật nào cho ra chữ có trong từ điển: nếu chữ đã có dấu, kết quả trở lại thành các phím đã gõ cộng phím mới (etec). Một người dùng hay viết lẫn tiếng Anh và tiếng Việt từng đề nghị điều này, để gõ etec mà không phải gõ eteec.
  2. Khi các phím là một chữ tiếng Anh: nếu các phím đã gõ là một chữ trong từ điển tiếng Anh còn kết quả tiếng Việt không phải chữ nào, engine giữ các phím. Từ điển tiếng Anh có 14.672 mục, mỗi mục chỉ giữ 7 chữ cái đầu (abbrevi, abdomin…) và không có chữ nào dưới 4 chữ cái, nên use vẫn ra ué. Riêng những chữ ngắn hay gặp mà kết thúc bằng phím dấu (as, if, of, bar, box, fix, tax, yes… 40 chữ) có một danh sách riêng.
  3. Khi kết thúc từ (dấu cách, dấu câu): một chữ chưa trọn mà đã có dấu thì được viết lại bằng các phím (hax, tiengs).

Gõ lặp phím dấu để bỏ dấu (ss, ff, aaa, ddd) là luật có từ lâu của Telex, nhưng nó làm hỏng các chữ tiếng Anh có chữ cái đôi: aff bỏ dấu huyền và mất một chữ f, nên affect từng ra afect. JOKey 2.0 sửa lỗi này: nếu các phím là một chữ tiếng Anh thì JOKey viết lại các phím đã gõ (affect, arrow, official, boss). Trước khi sửa, 370 chữ trong từ điển tiếng Anh bị mất một chữ cái theo cách này. Có 7 chữ vẫn giữ cách cũ, vì chữ ngắn hơn cũng là tiếng Anh và gõ lặp là cách duy nhất để gõ ra nó: off ra of, barr ra bar, marry ra mary.

Kiểm tra chính tả không phải lúc nào cũng cho kết quả người dùng muốn: test ra tét vì tét là một chữ tiếng Việt. Khi đó, phím Esc trả từ đang gõ về các phím đã gõ: gõ esc hiện éc, nhấn Esc thành esc. Khi tắt kiểm tra chính tả, mọi chuỗi có dạng một âm tiết đều được thêm dấu, kể cả hax thành hã, nhưng class cũng thành clas. Mỗi cách hợp với một kiểu người dùng, nên JOKey để đây là một tuỳ chọn.

Những trường hợp như vậy được ghi thành test case, mỗi dòng là “kết quả mong đợi” rồi “các phím gõ”:

life life
data data
mongo mongoo
basic bassic
error errror
user user

Bảng mã TCVN3, VNI Windows và Unicode

Trước Unicode, máy tính ở Việt Nam dùng bảng mã 8 bit: mỗi ký tự một byte, tối đa 256 ký tự, mà 128 chỗ đầu đã thuộc về ASCII. 134 chữ có dấu không đủ chỗ, nên mỗi bảng mã xếp chúng theo một cách riêng. Một bài báo năm 2002 của VnExpress đếm được hơn 40 bảng mã 8 bit từng được dùng ở Việt Nam. Ngoài Unicode, JOKey 1.x gõ được hai bảng hay gặp nhất:

  • TCVN3 (còn gọi là ABC), một phần của tiêu chuẩn TCVN 5712:1993: có mã cho đủ 67 chữ thường nhưng chỉ 7 chữ hoa (Ă Â Ê Ô Ơ Ư Đ). Chữ hoa có dấu thanh không có mã riêng; muốn có thì đổi sang font chữ hoa của cùng bộ, như .VnTimeH thay cho .VnTime, font này vẽ mã chữ thường thành chữ hoa. Trong bảng mã của JOKey 1.x, ế và Ế cùng là byte 0xD5.
  • VNI Windows: mỗi chữ có dấu là hai byte: chữ cái gốc cộng một byte dấu, dùng với các font VNI-… như VNI-Times. ệ là e (0x65) rồi 0xE4.
  • Unicode: từ Unicode 1.1 (1993), mỗi chữ tiếng Việt có một mã riêng trong khối U+1EA0–U+1EF9 và vài khối Latin khác (dựng sẵn, ệ là U+1EC7). Unicode cũng cho phép ghép chữ gốc với dấu kết hợp: dạng chuẩn hoá NFD của ệ là e + U+0323 + U+0302.

Ngoài ra còn VISCII và cách viết VIQR (Vie^.t) cùng được mô tả trong RFC 1456 năm 1993, Windows-1258 của Microsoft ghép chữ có mũ, móc với một dấu thanh kết hợp. Năm 2001 Việt Nam ban hành TCVN 6909:2001, bộ mã 16 bit dựa trên Unicode. Quyết định 72/2002/QĐ-TTg yêu cầu các tổ chức Đảng và Nhà nước dùng nó khi trao đổi thông tin điện tử từ 1/1/2003, và thông tư hướng dẫn đi kèm yêu cầu dạng dựng sẵn.

Chữ “Tiếng Việt” trong từng bảng mã, và hình dạng của nó khi mở bằng một font thường:

Bảng mãCác byteMở bằng font thường
TCVN354 69 D5 6E 67 20 56 69 D6 74TiÕng ViÖt
VNI Windows54 69 65 E1 6E 67 20 56 69 65 E4 74Tieáng Vieät
Unicode dựng sẵn, UTF-854 69 E1 BA BF 6E 67 20 56 69 E1 BB 87 74Tiếng Việt
Unicode tổ hợp, UTF-854 69 65 CC 82 CC 81 6E 67 20 56 69 65 CC A3 CC 82 74Tiếng Việt

“TiÕng ViÖt” và “Tieáng Vieät” là hai kiểu “lỗi font” thường gặp khi mở văn bản cũ. Các byte không sai; font đang dùng không phải font TCVN3 hay VNI nên vẽ các byte đó thành chữ Latin có dấu khác.

JOKey 1.x gõ bảng mã cũ thế nào

Bộ gõ không ghi byte vào tài liệu mà gửi ký tự cho app. Vì vậy, để gõ TCVN3, JOKey 1.x gửi ký tự có mã trùng với byte TCVN3: muốn có ế thì gửi Õ (U+00D5). Tài liệu lưu byte 0xD5, font TCVN3 vẽ byte đó thành ế. JOKey 1.x có một bảng 133 dòng, mỗi dòng là mã Unicode, cặp byte VNI và byte TCVN3:

1EBF,E165,D5     ế:  VNI = e + á (0x65 0xE1),  TCVN3 = 0xD5
1EC7,E465,D6     ệ:  VNI = e + ä (0x65 0xE4),  TCVN3 = 0xD6
0111,00F1,AE     đ:  VNI = ñ (0xF1),           TCVN3 = 0xAE
Xem mã: JOKey 1.x gửi ký tự theo bảng mã (Swift)
// JOKey 1.x, gửi từng ký tự theo bảng mã đang chọn
for c in s.utf16 {
    let char = c.littleEndian
    if inputCode == .Unicode {
        sendUnicode(char)
    } else if let t = convertTable[char] {
        if inputCode == .TCVN3 {
            sendUnicode(t.TCVN3)
        } else { // .VNI: chữ gốc rồi byte dấu
            sendUnicode(UInt16(t.VNI.1))
            if t.VNI.0 != 0 {
                sendUnicode(UInt16(t.VNI.0))
            }
        }
    }
}

Với VNI, một chữ có dấu trên màn hình là hai ký tự trong tài liệu, nên để xoá chữ đó, bộ gõ phải gửi hai phím xoá. Bản 1.x tính lại số phím xoá cho từng chữ có dấu. Nó cũng có tuỳ chọn nhớ bảng mã riêng cho từng app.

JOKey 2.0 bỏ cả hai bảng mã cũ cùng tuỳ chọn nhớ bảng mã theo app: giờ JOKey chỉ gõ Unicode dựng sẵn. (JOKey 2.0 vẫn có kiểu gõ VNI; kiểu gõ VNI và bảng mã VNI Windows là hai thứ khác nhau.)

Unicode dựng sẵn và tổ hợp

Hai chuỗi “Tiếng Việt” ở hai dòng cuối bảng trên trông giống hệt nhau nhưng không bằng nhau: dạng dựng sẵn (NFC) có 10 code point, 14 byte UTF-8; dạng tổ hợp (NFD) có 14 code point, 18 byte. Tìm kiếm, so sánh, đếm độ dài hay cắt chuỗi đều có thể sai nếu hai dạng lẫn vào nhau. Trong JavaScript, 'ệ'.length là 1 với NFC và 3 với NFD. Ngoài ra, lựa chọn “Unicode tổ hợp” trong các bộ gõ như UniKey không phải NFD mà là chữ có mũ cộng một dấu thanh kết hợp (ê + U+0323), giống Windows-1258. Theo chuẩn Unicode, ba dạng này tương đương nhau, nhưng chúng là ba chuỗi byte khác nhau. Riêng hòa và hoà là hai cách viết của cùng một chữ, nhưng máy tính coi là hai chữ khác nhau.

Nếu app nhận văn bản tiếng Việt từ nhiều nguồn, nên chuẩn hoá về một dạng trước khi lưu hoặc so sánh, và trước khi chuyển sang bảng mã cũ: iconv trên macOS chuyển “Việt” dạng NFC sang VISCII được, nhưng báo lỗi với dạng NFD.

'Tiếng Việt'.normalize('NFC').length; // 10
'Tiếng Việt'.normalize('NFD').length; // 14
let s = input.precomposedStringWithCanonicalMapping // NFC

Ba cách đưa chữ vào app trên macOS

Trên macOS, bộ gõ có thể đứng giữa bàn phím và ô nhập theo ba cách, và JOKey đã dùng cả ba.

Ba cách bộ gõ chen vào giữa bàn phím và app Input method: app chuyển phím cho input method, input method trả lại chữ gạch chân. JOKey 1.x không gạch chân: event tap gửi phím xoá và chữ thẳng vào app, còn phím người dùng gõ đi vòng qua input method. JOKey 2.0: layout JOKey được chọn, event tap gửi mọi thứ cùng một đường. Input method (IMK) macOS, JOKey 1.x gạch chân IM + event tap JOKey 1.x không gạch chân Layout + event tap JOKey 2.0 Bàn phím Bàn phím Bàn phím macOS macOS macOS App Event tap JOKey Event tap JOKey phím chữ gạch chân Input method phím gõ ⌫ + chữ App (WebKit) phím gõ đi vòng Input method JOKey phím gõ ⌫ + chữ App Không có input method: mọi phím đi cùng một đường Chữ đang gõ gạch chân, xong từ mới chèn hẳn vào app ⌫ + chữ có thể tới trước phím gõ trước đó chưa tới 15 ms Layout "JOKey" là công tắc: chọn nó thì JOKey gõ tiếng Việt
Ba cách một bộ gõ đứng giữa bàn phím và app. Mũi tên màu là phím xoá và chữ do JOKey gửi; nét đứt là đường đi vòng của phím người dùng gõ khi JOKey 1.x vẫn là input method đang chọn.

Input method

Một input method (IM) là một app riêng nằm trong ~/Library/Input Methods, viết bằng InputMethodKit. Khi bạn chọn nó trong menu nhập liệu, hệ thống văn bản của app đang gõ chuyển từng phím cho IM qua IMKInputController.handle(_:client:). IM hiển thị chữ đang gõ dưới dạng marked text (chữ gạch chân) bằng setMarkedText, và khi xong một từ thì chèn hẳn vào bằng insertText. Đây là cách JOKey 1.x làm ở chế độ gạch chân:

Xem mã: marked text và insertText (Swift)
// JOKey 1.x: chữ đang gõ là marked text, gạch chân
c.setMarkedText(aText, selectionRange: NSMakeRange(NSNotFound, NSNotFound),
                replacementRange: NSMakeRange(NSNotFound, NSNotFound))
// ... xong từ thì chèn hẳn
client.insertText(comText, replacementRange: NSMakeRange(NSNotFound, NSNotFound))

Bộ gõ tiếng Việt có sẵn của macOS cũng là một input method như vậy (xem phần các bộ gõ khác). Input method không cần quyền đặc biệt và gõ được vào mọi app dùng hệ thống nhập liệu của macOS. Nó cũng không gặp lỗi gợi ý tự động nói ở phần sau, vì Chromium tắt gợi ý trong thanh địa chỉ khi input method đang có chữ soạn dở. Nhược điểm là chữ đang gõ luôn bị gạch chân, và có người dùng đã nói rõ là không muốn dùng chế độ gạch chân. Vì vậy bản 1.x có thêm chế độ “không gạch chân”.

Event tap

Cách thứ hai là dùng CGEventTap, một điểm chặn trên luồng phím của cả hệ thống. Bộ gõ thấy mọi phím trước app, giữ lại các phím cần đổi, rồi tự gửi phím xoá và chữ Unicode để sửa chữ trên màn hình. Chữ hiện thẳng trong ô nhập, không có gạch chân.

Xem mã: tạo event tap (Swift)
let tap = CGEvent.tapCreate(
    tap: .cghidEventTap, place: .tailAppendEventTap, options: .defaultTap,
    eventsOfInterest: Self.mask, callback: tapCallback,
    userInfo: Unmanaged.passUnretained(self).toOpaque())

Với cách này, bộ gõ phải tự xử lý những việc mà hệ thống làm sẵn cho input method: con trỏ đang ở đâu, ô nhập có gợi ý tự động không, app có nhận ký tự Unicode gửi tới không. Event tap cũng cần quyền Accessibility, và không nhận được phím nào khi Secure Input đang bật (xem phần Secure Input).

JOKey 1.x: input method và event tap cùng lúc

Chế độ không gạch chân của JOKey 1.x dùng cả hai cách trên: JOKey vẫn là input method được chọn trong menu, nhưng gõ chữ qua event tap. Trên macOS 26, người dùng báo chữ bị loạn và mất ký tự trong Spotlight, Safari và các app dựa trên WebKit (Orion, Nook, Slidepad). Mình tìm ra hai nguyên nhân, đều liên quan tới thứ tự phím:

  1. Phím xoá JOKey gửi không mang ký tự nào (bản 1.x chỉ gắn ký tự cho một danh sách app). Khi một input method đang được chọn, phím không có ký tự tới app sau những phím có ký tự được gửi sau nó. Trong các app WebKit, phím xoá vì thế chạy sau chữ thay thế, và việt thành viet.
  2. Sau khi sửa lỗi thứ nhất vẫn còn lỗi thứ hai. Khi JOKey là input method đang chọn, WebKit chuyển phím người dùng gõ qua nó một cách bất đồng bộ, còn phím JOKey gửi đi thẳng tới app, nên phím của JOKey có thể tới trước những phím người dùng gõ trước đó chưa tới 15 ms. Trong một app WKWebView trống, gõ 1 ms mỗi phím thì chỉ 1/12 câu đúng.

Lỗi thứ hai không sửa được trong bộ gõ: khi còn là input method, JOKey vẫn nằm trên đường đi của phím người dùng gõ.

JOKey 2.0: keyboard layout và app chạy nền

Bản 2.0 bỏ input method. “JOKey” trong menu nhập liệu giờ là một keyboard layout: một tệp .keylayout có các phím giống hệt bàn phím ABC, kèm biểu tượng của JOKey. Bản thân layout không làm gì; nó chỉ cho JOKey biết lúc nào cần gõ tiếng Việt. Khi layout này được chọn, JOKey.app (chạy nền từ lúc đăng nhập, bằng một launch agent) gõ tiếng Việt qua event tap. Giữa phím và app không còn thành phần nào khác, nên phím không bị đảo thứ tự: trong mọi app mình chạy thử, 12/12 câu đúng ở 30 ms và ở khoảng 1 ms mỗi phím.

Không còn là input method, JOKey có thể chỉ là một app có biểu tượng trên thanh menu và phím tắt riêng để bật tắt tiếng Việt, như phần lớn các bộ gõ dùng event tap (OpenKey, EVKey, GoTiengViet). Mình vẫn giữ JOKey trong menu nhập liệu vì mình dùng cả bộ gõ tiếng Nhật, và muốn bộ gõ tiếng Việt nằm cùng chỗ với nó: chỉ một phím tắt của hệ thống, phím 🌐 (Fn) hoặc ⌃ Space, chuyển qua lại giữa JOKey, ABC và bàn phím tiếng Nhật. Với một app riêng, tiếng Việt sẽ bật tắt bằng phím tắt của app, còn tiếng Nhật bằng phím tắt của hệ thống.

Một script Swift sinh tệp layout từ dữ liệu của bàn phím ABC: mọi phím dưới 8 tổ hợp phím bổ trợ, cộng 5 dead key của phím Option. Kết quả được so với ABC trên 6.144 lần dịch phím, để chữ tiếng Anh, phím tắt và ký tự đặc biệt gõ bằng layout JOKey giống hệt ABC.

Xem mã: đầu tệp JOKey.keylayout
<!-- Generated by tools/keylayout/make.swift from ABC: do not edit. -->
<keyboard group="126" id="-20518" name="JOKey" maxout="2">

Cách này gặp vài giới hạn của macOS (thử trên macOS 26). Việc phải đăng xuất sau khi cài layout có ghi trong một technote năm 2002 (TN2056); các giới hạn còn lại mình không thấy tài liệu nào nhắc tới:

  • Chương trình không thể tự thêm một input source bên thứ ba vào menu nhập liệu. TISEnableInputSource trả về noErr, và chương trình gọi nó còn thấy input source đã bật, nhưng thay đổi không bao giờ được ghi vào com.apple.inputsources. Với layout của Apple thì hàm này vẫn có tác dụng. Vì vậy người dùng phải tự thêm JOKey một lần trong System Settings.
  • Một layout mới chỉ hiện trong System Settings sau khi đăng xuất rồi đăng nhập lại. JOKey kiểm tra điều này (so thời điểm tạo tệp layout với lúc đăng nhập) và nhắc người dùng.
  • Cho tới lần đăng nhập sau, macOS bỏ qua mã input source đã khai báo và tự đặt một mã khác, nên JOKey tìm layout của mình bằng bundle id thay vì mã input source.
  • Menu nhập liệu và System Settings chỉ đọc danh sách input source lúc khởi động. Lệnh cài đặt khởi động lại menu nhập liệu và thoát System Settings để hai nơi này đọc lại danh sách.

Gửi phím xoá và chữ vào app

So chữ cũ với chữ mới

JOKey nhớ phần chữ nó đã gõ lên màn hình cho từ đang gõ. Sau mỗi phím, nó so chữ đang hiện với chữ cần hiện, giữ phần đầu giống nhau, xoá phần còn lại và gõ phần mới:

Đang hiệnviêt
Cần hiệnviệt
JOKey gửi⌫ ⌫ ệ t
let old = Array(lastTyped), new = Array(text)
var same = 0
while same < old.count, same < new.count, old[same] == new[same] {
    same += 1
}
let backspaces = old.count - same
let toType = String(new[same...])

Nếu phím vừa gõ chỉ thêm ký tự của nó vào cuối chữ (phần lớn các chữ cái), JOKey cho phím đó đi qua nguyên vẹn, với mã phím và phím bổ trợ ban đầu. JOKey 1.x gõ lại cả những phím này bằng sự kiện Unicode, nên phím tắt Shift+A của Figma tới app thành chữ a thường.

Mọi phím gửi đi đều mang ký tự

Sau lỗi thứ tự ở trên, JOKey 2.0 có quy tắc: mọi sự kiện phím nó gửi đều mang ký tự. Phím xoá mang 0x08, còn chữ được gửi bằng một sự kiện mới cho mỗi đơn vị UTF-16 (một sự kiện đã gửi có thể vẫn nằm trong hàng đợi khi ký tự tiếp theo sửa nó). Các sự kiện này đi từ một event source riêng, để khi chúng đi qua event tap, JOKey nhận ra phím do mình gửi và cho qua.

Xem mã: event source riêng (Swift)
let privateEventSource = CGEventSource(stateID: .privateState)!

private static func key(_ code: Int, down: Bool, unicode: UniChar) -> CGEvent {
    let e = CGEvent(keyboardEventSource: privateEventSource, virtualKey: CGKeyCode(code), keyDown: down)!
    var u = unicode
    e.keyboardSetUnicodeString(stringLength: 1, unicodeString: &u)
    return e
}

// trong callback của event tap
if event.getIntegerValueField(.eventSourceStateID) == privateEventStateID {
    return Unmanaged.passUnretained(event) // phím của JOKey: cho qua
}

Gợi ý tự động trong thanh địa chỉ và Spotlight

Thanh địa chỉ của trình duyệt và Spotlight thường gợi ý phần còn lại của chữ ở sau con trỏ, dưới dạng chữ đang được chọn. Gõ go, Chrome hiện google.com. Khi đó phím xoá đầu tiên chỉ xoá phần gợi ý, không xoá ký tự nào bạn đã gõ:

Cách gửi khi gõ thêm x (muốn gõ)Sau từng bướcKết quả
⌫, rồi õgoogle.com → go → goõgoõ
ký tự vô hình U+202C, ⌫⌫, rồi õgoogle.com → go‬ → g → gõgõ
Spotlight: xoá xuôi (fn-⌫), ⌫, rồi õgoogle.com → go → g → gõgõ

JOKey dùng cách thứ hai từ bản 1.x: gõ một ký tự vô hình (U+202C) thay cho phần gợi ý, rồi xoá thêm một lần. Mình thử 23 chữ có phần đầu trùng với trang hay vào (“go” + “ogle.com”, gõ thêm x thành “gõ”) trên thanh địa chỉ của Chrome, Safari và Brave, và cả 23 chữ đều ra đúng.

Cách này không dùng được với Spotlight: Spotlight bỏ qua U+202C khi tìm kiếm và hiện lại gợi ý, nên chào vẫn ra chaào. Khi thử 30 chữ, mỗi chữ trong một Spotlight vừa mở, ở 150 và 300 ms mỗi phím, chỉ 15/30 chữ ra đúng. JOKey 2.0 gửi cho Spotlight một phím xoá xuôi (fn-⌫) trước các phím xoá: phím này xoá phần gợi ý đang được chọn, và không làm gì khi con trỏ ở cuối chữ. Sau khi sửa, kết quả là 30/30 ở 80, 150 và 300 ms mỗi phím, và 8/8 ở 1 giây. Sau khi bạn bấm phím mũi tên, Home, End hoặc bấm chuột, con trỏ có thể nằm giữa chữ và phím xoá xuôi sẽ xoá mất một ký tự của bạn, nên lúc đó JOKey dùng lại ký tự vô hình.

Bảng tìm kiếm của Spotlight cũng không bao giờ trở thành frontmost app (app mà macOS coi là đang được dùng, NSWorkspace.frontmostApplication), nên JOKey từng gõ vào Spotlight bằng cấu hình của app nằm sau nó. JOKey 2.0 theo dõi thông báo focus (qua Accessibility) của ô tìm kiếm Spotlight để biết lúc nào người dùng đang gõ vào đó.

Luật riêng cho từng app

Bộ gõ dùng event tap không biết trước một app sẽ xử lý phím gửi tới ra sao. Header CGEvent.h của Apple cũng ghi rằng framework của app có thể bỏ qua chuỗi Unicode trong sự kiện phím và tự dịch lại từ mã phím. Vì vậy JOKey có một bảng luật theo bundle id (AppRules.swift):

  • Bỏ qua hoàn toàn: màn hình đăng nhập, hộp thoại bảo mật của hệ thống, Activity Monitor, và mọi app điều khiển máy khác: Screen Sharing, iPhone Mirroring, Windows App, Citrix, VNC, RustDesk, Parsec, TeamViewer, AnyDesk, Parallels, VMware, UTM, VirtualBox. Với các app này, máy ở phía bên kia tự xử lý tiếng Việt, nên JOKey để phím đi qua.
  • Không gửi ký tự vô hình trước phím xoá: Terminal, iTerm2, VS Code, Xcode, Slack, Messages, các IDE của JetBrains, các app của Adobe…
  • Gửi phím ở mức HID thay vì gửi tiếp từ event tap của JOKey: các app của Adobe.

Người dùng tự thêm app hoặc trang web không muốn gõ tiếng Việt. Với trang web, JOKey đọc địa chỉ của tab đang mở bằng AppleScript trên Safari và các trình duyệt Chromium.

Một cú bấm chuột có thể đưa con trỏ đi chỗ khác, nên khi có cú bấm chuột, JOKey bỏ từ đang gõ. Phím mũi tên, Home và End thì kết thúc từ đang gõ.

Secure Input

Khi một ô mật khẩu đang được focus, hoặc Terminal bật “Secure Keyboard Entry”, macOS bật Secure Input: theo TN2150, hệ thống ngừng chuyển phím cho mọi chương trình chặn phím, dù app bật Secure Input đang ở trước hay chạy nền. Lúc đó bộ gõ dùng event tap không nhận được phím nào. Có những app bật Secure Input rồi không tắt, và người dùng chỉ thấy bộ gõ ngừng gõ tiếng Việt. JOKey tìm process đang giữ Secure Input qua IORegistry và báo cho người dùng biết nên thoát app nào:

Xem mã: tìm app đang giữ Secure Input (Swift)
static func holderPID() -> pid_t {
    guard IsSecureEventInputEnabled() else { return 0 }
    let root = IORegistryGetRootEntry(mach_port_t(MACH_PORT_NULL))
    // IOConsoleUsers → kCGSSessionSecureInputPID của phiên đang dùng
    // ...
}
Hộp thoại của JOKey: hãy thử thoát app đang bật Secure Input
JOKey chỉ ra app đang bật Secure Input.

Bộ nhớ

Bộ gõ chạy từ lúc đăng nhập tới lúc tắt máy và xử lý mọi phím, nên nó cần dùng ít bộ nhớ và không được rò rỉ.

Lỗi rò bộ nhớ của bản 1.x

Có người dùng báo JOKey 1.0.0 dùng tới 1,2 GB bộ nhớ. Khi viết lại, mình tìm ra các nguyên nhân:

  • callback của event tap trả về sự kiện bằng passRetained, nên mỗi phím, mỗi cú bấm chuột trên toàn hệ thống rò khoảng 1 KB;
  • luồng chạy event tap không bao giờ dọn autorelease pool;
  • vòng kiểm tra Secure Input mỗi giây rò mảng IOConsoleUsers, khoảng 1,8 KB mỗi giây trong lúc có app giữ Secure Input;
  • mỗi lần khởi động lại, event tap cũ không được huỷ, và tap bị hệ thống tắt (do callback chạy quá lâu) không được bật lại.

Cách tránh các lỗi này giờ là quy tắc trong EventTap.swift, và JOKey selftest (chạy trong mỗi lần build) báo lỗi nếu 200 nghìn sự kiện đi qua callback làm bộ nhớ tăng quá 256 KB:

Xem mã: callback của event tap (Swift)
func tapCallback(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent,
                 refcon: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
    // ...
    // luồng này có run loop riêng, không tự dọn autorelease pool
    let swallow = autoreleasepool { tap.handle(proxy, type, event) }
    // sự kiện thuộc về hệ thống: trả lại mà không retain
    return swallow ? nil : Unmanaged.passUnretained(event)
}

Từ điển nhúng trong tệp chạy

Engine Go dựng trie lúc chạy, mỗi chữ cái một nút trên heap. Bản port Rust đầu tiên cũng làm vậy, và các trie chiếm 4,4 MB, cộng khoảng 2,5 MB phân mảnh do quá trình dựng để lại mà bộ cấp phát không trả lại được. Bản Rust bây giờ dựng trie lúc biên dịch (build.rs) thành bốn mảng phẳng rồi nhúng vào tệp chạy bằng include_bytes!. Dữ liệu chỉ đọc, nằm trong các trang bộ nhớ ánh xạ từ tệp, nên không tốn heap và không tính vào footprint của app. Phần bộ nhớ engine và từ điển thêm vào giảm từ 2,66 MB xuống 0,19 MB, và lúc đó JOKey khởi động với 7,8 MB thay vì 10,5 MB.

Trie của ba chữ ai, an, anh và bốn mảng phẳng của nó Nút 0 là gốc, cạnh a tới nút 1; từ nút 1, cạnh i tới nút 2 và cạnh n tới nút 3; từ nút 3, cạnh h tới nút 4. Nút 2, 3, 4 là hết một chữ. Mảng first là 0 1 3 3 4 4, chars là a i n h, targets là 1 2 3 4. a i n h 0 1 2 3 4 ai an anh first = [0, 1, 3, 3, 4, 4] chars = [a, i, n, h] targets = [1, 2, 3, 4] flags = [ , , ●, ●, ●] Con của nút k là chars[first[k] .. first[k+1]], xếp theo thứ tự nên tìm bằng tìm kiếm nhị phân.
Một trie nhỏ và bốn mảng phẳng của nó. Nút tô màu là hết một chữ. Trie của JOKey có khoảng 17 nghìn nút cho mỗi từ điển tiếng Việt (khoảng 220 KB) và 32.646 nút cho tiếng Anh.
Xem mã: tìm nút con trong trie (Rust)
impl Trie {
    fn child(&self, node: u32, c: char) -> Option<u32> {
        let (a, b) = (self.first[node as usize] as usize, self.first[node as usize + 1] as usize);
        self.chars[a..b].binary_search(&(c as u32)).ok().map(|i| self.targets[a + i])
    }
}

Có bốn bảng tiếng Việt (bật/tắt kiểm tra chính tả × hai kiểu đặt dấu) và một bảng tiếng Anh, đều nhúng trong tệp chạy; khi đổi tuỳ chọn, engine chỉ trỏ sang bảng khác. Một test so các bảng đã nhúng với bảng dựng lại từ tệp từ điển, để bảng nhúng luôn khớp với dữ liệu.

Cửa sổ chạy ở process riêng

Khi một process mở cửa sổ đầu tiên, AppKit và SwiftUI nạp khoảng 17 MB mà process không trả lại được: mình đo được bộ nhớ tăng từ 8,3 MB lên 25 MB, và đóng cửa sổ chỉ giảm khoảng 3 MB. Một process chạy cả ngày không nên giữ thêm 17 MB cho một cửa sổ ít khi mở. Vì vậy JOKey không tự mở cửa sổ nào: cửa sổ Cài đặt, hướng dẫn cấp quyền và cảnh báo Secure Input chạy trong một process phụ, process này tự thoát khi cửa sổ cuối cùng đóng. Hai process dùng chung một vùng UserDefaults và báo cho nhau qua một distributed notification mỗi khi cài đặt thay đổi.

Cửa sổ Cài đặt của JOKey 2.0: kiểu gõ Telex hoặc VNI, kiểm tra chính tả, dấu thanh cân đối, gõ nhanh ư bằng w, xoá dấu bằng z, phím xoá cả ký tự
Cửa sổ Cài đặt chạy trong một process riêng, thoát khi đóng cửa sổ.

Các tối ưu khác

  • MallocSpaceEfficient=1 (đặt trong launch agent) bảo bộ cấp phát ưu tiên tiết kiệm bộ nhớ hơn tốc độ: bớt 1,25 MB, khoảng 20%.
  • Các sự kiện phím được tạo lần đầu khi cần: tạo chúng nạp dữ liệu bàn phím khoảng 0,45 MB.
  • Sau các thay đổi trên, lúc còn là input method, JOKey khởi động với 6,7 MB; các input method của Apple trên cùng máy dùng 17 tới 38 MB.

Lỗi crash khi đọc NSEvent.characters

Trong lúc thử bản 2.0, JOKey crash năm lần ở cùng một chỗ: event tap đọc NSEvent.characters trên luồng của nó. Thuộc tính này hỏi Text Input Source API về bàn phím đang dùng, và API đó dừng chương trình nếu không được gọi trên main queue. Giờ event tap tự đọc ký tự từ CGEvent và không tạo NSEvent nào:

Xem mã: đọc ký tự từ CGEvent (Swift)
var buf = [UniChar](repeating: 0, count: 8)
var len = 0
e.keyboardGetUnicodeString(maxStringLength: buf.count, actualStringLength: &len, unicodeString: &buf)
characters = String(utf16CodeUnits: buf, count: len)

Kiểm thử

Test case của engine

Bộ kiểm thử của engine Go có 98.716 test case, mỗi test case là một dòng “kết quả mong đợi” và “các phím”, chạy qua nhiều tổ hợp tuỳ chọn (Telex, VNI, có hay không kiểm tra chính tả, hai kiểu đặt dấu, w và z). Khi port sang Rust, mình giữ hành vi của Go theo từng nhánh, kể cả những điểm lạ (giới hạn độ dài chữ tính bằng 15 byte chứ không phải 15 ký tự), rồi so hai engine trên 1,87 triệu thao tác ngẫu nhiên (gồm cả xoá và ngăn xếp từ) và không thấy khác biệt nào. Sau đó mình mới sửa hành vi; mỗi chỗ sửa có test riêng ghi lại lý do khác Go.

Tự kiểm thử trong app

JOKey selftest gõ qua lớp Typist của app với một bộ gửi phím giả, và đo bộ nhớ của event tap như đã nói ở trên.

Gõ vào các app

Phần khó kiểm thử nhất là cách từng app xử lý phím. tools/apptest dùng JOKey đã cài để gõ vào các app rồi đọc lại chữ app hiện ra qua Accessibility: một ô WebKit trống, Safari và thanh địa chỉ của nó, Chrome, Brave, Google Docs, Google Sheets, Word và Excel bản web, VS Code, TextEdit, Spotlight, Terminal và Claude Code. Mỗi app cần một cách đọc riêng: Chrome giấu nội dung trang khỏi Accessibility nên chữ được đọc qua tiêu đề trang; Google Docs được đọc bằng ⌘A ⌘C, sau đó clipboard được trả lại như cũ. Test dừng nếu cửa sổ đang gõ mất focus, để không gõ nhầm vào chỗ khác.

Bàn phím ESP32

Phím do chương trình gửi không hoàn toàn giống phím từ bàn phím vật lý, nên mình nạp firmware cho một bo ESP32-S3: cổng USB native của bo là một bàn phím HID, cổng UART nhận lệnh type <ms> <text>. Lỗi thứ tự trong WebKit ở trên cũng xuất hiện với phím gõ từ ESP32: ở 30 ms và khoảng 4 ms mỗi phím, mọi app đều 12/12, trừ ô WebKit trống khi gõ nhanh hơn khoảng 15 ms mỗi phím.

Các bộ gõ khác

Các bộ gõ khác cho macOS cũng gặp hầu hết các vấn đề trong bài này. Bảng dưới tổng hợp từ mã nguồn (với bộ gõ mã nguồn mở), tài liệu và trang web của từng bộ gõ, tính tới ngày 17/10/2026:

Bộ gõMã nguồnCách đưa chữ vào appBảng mãKiểm tra chính tảBản mới nhất
Có sẵn trong macOSđóng, engine UniKeyinput method, chữ gạch chânUnicodekhông rõđi kèm macOS
OpenKeyGPL-3.0, C++, Obj-Cevent tap, ⌫ + UnicodeUnicode, TCVN3, VNI, tổ hợp, CP1258luật ghép âm2.0.5 (3/2022)
GoTiengVietđóngcần Accessibility (có lẽ event tap)nhiều bảng mãsửa vài lỗi hay gặp2.4 (2023)
EVKeyđóngcần Accessibility (có lẽ event tap)Unicode, TCVN3, VNI, tổ hợp…không rõMac 3.3.10
Gõ NhanhBSD-3, Rust, Swiftevent tap, chọn cách xoá theo loại ô nhậpUnicodeluật + danh sách từ, trả lại chữ tiếng Anh1.0.165 (10/2026)
XKeyMIT, Swiftevent tap, hoặc input methodUnicode, TCVN3, VNItừ điển1.2.25 (10/2026)
PHTVAGPL-3.0, Swiftevent tapUnicode, TCVN3, VNI, tổ hợp, CP1258có, trả lại chữ tiếng Anh3.6.8 (10/2026)
VietTelexMIT, Swiftinput method sửa chữ tại chỗ, không gạch chânUnicodetrả lại chữ tiếng Anh1.8.24 (10/2026)
JOKey 2đónglayout + event tap, ⌫ + UnicodeUnicodetừ điển2.0.1 (10/2026)

Bộ gõ có sẵn của macOS

Bộ gõ tiếng Việt có sẵn của macOS dùng engine UniKey. Trong VietnameseIM.app của macOS 26 có dòng “UniKey engine copyright © Pham Kim Long, 2006”, tệp chạy có các hàm C của UniKey (UnikeyFilter, UnikeyBackspacePress…), và tệp Acknowledgements của macOS ghi UniKey theo giấy phép MIT. Theo trang của UniKey, năm 2006 tác giả phát hành mã x-unikey theo giấy phép MIT để Apple dùng; Apple đưa engine này vào Mac OS X Leopard (2007) và vào bàn phím tiếng Việt của iOS từ iOS 4. Bộ gõ có bốn kiểu gõ (Simple Telex, Telex, VNI, VIQR) và tuỳ chọn “Use modern orthography” cho dấu kiểu mới. Simple Telex khác Telex ở chỗ w đứng một mình không thành ư và không dùng [ ]; JOKey mặc định cũng để w là w (tuỳ chọn “Gõ nhanh chữ ư bằng phím w” tắt sẵn).

Các bộ gõ dùng event tap

Phần lớn bộ gõ bên thứ ba dùng event tap như JOKey: OpenKey, Gõ Nhanh, XKey, PHTV, và có lẽ cả GoTiengViet, EVKey (mã nguồn đóng, nhưng cả hai đều cần quyền Accessibility). OpenKey cũng gửi phím từ một event source riêng và nhận ra phím của mình bằng mã của event source đó. Các bộ gõ khác nhau ở cách xử lý những ô nhập có gợi ý:

  • Ký tự vô hình trước phím xoá: OpenKey và Gõ Nhanh dùng U+202F, JOKey dùng U+202C.
  • Chọn rồi gõ đè: bấm Shift+← vài lần rồi gõ chữ mới lên phần đã chọn. OpenKey dùng cách này trong tuỳ chọn “Sửa lỗi Chromium”, Gõ Nhanh dùng cho ô tìm kiếm, Excel và IDE của JetBrains. Tác giả OpenKey ghi rằng cách này hỏng trên Google Sheets.
  • Ghi thẳng qua Accessibility: Gõ Nhanh với Spotlight và Arc, MKey (một bản dựa trên engine OpenKey) với Spotlight.
  • Xoá xuôi cho gợi ý của Spotlight: Gõ Nhanh và JOKey.

Gõ Nhanh còn chọn cách gửi theo loại ô nhập đang được focus (ô tìm kiếm, combo box…, đọc qua Accessibility), còn JOKey chọn theo từng app.

Thanh địa chỉ

Trong một issue của OpenKey, tác giả phân tích rằng khi lịch sử có nhiều trang trùng phần đầu, Chrome trên macOS giữ gợi ý thêm khoảng 0,8 giây sau ký tự vô hình, nên cách dùng ký tự vô hình không đủ. Tác giả cũng gặp lỗi này với UniKey và EVKey trên Windows 10. Input method dùng chữ gạch chân không gặp lỗi này, như đã nói ở trên.

VietTelex: input method không gạch chân

VietTelex (2026) dùng một cách thứ tư: nó là một input method nhưng không dùng chữ gạch chân. Mỗi lần sửa chữ, nó gọi insertText(_:replacementRange:) với phạm vi bao các ký tự cần thay. Cách này cần app báo chính xác vị trí con trỏ; Terminal và iTerm bỏ qua replacementRange, nên trong hai app này VietTelex quay về chữ gạch chân, hoặc dùng event tap nếu được cấp quyền. Dự án ghi lại các kết quả đo về input method trên macOS trong ghi chép kỹ thuật.

Bảng mã và kiểm tra chính tả

OpenKey, PHTV, XKey và EVKey vẫn gõ được TCVN3 và VNI Windows; Gõ Nhanh, VietTelex và JOKey 2 chỉ gõ Unicode. Về kiểm tra chính tả, OpenKey kiểm tra theo luật ghép âm (phụ âm đầu, vần, phụ âm cuối) mà không cần từ điển; XKey và JOKey dùng từ điển; Gõ Nhanh kết hợp luật với một danh sách từ, cộng một từ điển tiếng Anh để trả lại chữ tiếng Anh khi gặp dấu cách.

Phân phối

Ngày 1/9/2026, Homebrew tắt gói cài OpenKey và EVKey vì không qua được kiểm tra Gatekeeper. Gõ Nhanh và XKey ký bằng Developer ID và notarize trong quy trình phát hành. JOKey không còn tài khoản Apple Developer nên ký ad-hoc, và lệnh cài đặt tự gỡ cờ quarantine của macOS.

Linux và Windows

Trên Linux (IBus, Fcitx), bộ gõ có ba cách: preedit (chữ soạn dở, thường gạch chân), surrounding text (bộ gõ yêu cầu app xoá các ký tự quanh con trỏ rồi chèn chữ mới) và phím giả (BackSpace qua IBus, XTest hay /dev/uinput). Không cách nào chạy được ở mọi app: ibus-bamboo cho chọn cách theo từng app, còn fcitx5-unikey chỉ dùng preedit. ibus-teni, tổ tiên của engine JOKey, là một bộ gõ IBus dùng preedit có gạch chân. Trên Windows, phần lớn bộ gõ bên thứ ba làm tương tự event tap: đặt một hook bàn phím rồi tự gửi phím xoá và chữ (OpenKey dùng SetWindowsHookEx và SendInput). Từ các bản Windows 10 năm 2019, Windows cũng có sẵn kiểu gõ Telex và VNI.

Nếu bạn định làm bộ gõ

Một số điều mình rút ra khi làm JOKey:

  • Không cài cứng luật đặt dấu mà sinh ứng viên rồi để từ điển chọn; khi đó đổi kiểu đặt dấu chỉ là đổi từ điển.
  • Xử lý phím xoá bằng cách bỏ một trạng thái trên ngăn xếp, không tính ngược từ chữ đang hiện. Xoá theo ký tự là một tính năng riêng và khó hơn nhiều.
  • Tách engine khỏi app. Engine không gọi tới hệ điều hành thì chạy được hàng trăm nghìn test case trong vài giây, và chạy được ở nơi khác, như ô gõ thử trong bài này.
  • Khi viết lại, giữ nguyên hành vi cũ trước rồi mới sửa, và so hai bản bằng dữ liệu ngẫu nhiên.
  • Gửi Unicode dựng sẵn, và chuẩn hoá NFC khi xử lý văn bản tiếng Việt.
  • Với event tap: trả sự kiện bằng passUnretained, bọc callback trong autoreleasepool, bật lại tap khi hệ thống tắt nó, huỷ tap khi dừng.
  • Không gọi API giao diện ngoài main thread, kể cả các thuộc tính như NSEvent.characters.
  • Mọi phím gửi đi phải mang ký tự và đi từ một event source riêng, để bộ gõ nhận ra phím của mình.
  • Gợi ý tự động, Secure Input và các app điều khiển máy khác đều gặp thường xuyên, nên cần tính tới từ khi thiết kế.
  • Kiểm thử trong các app người dùng hay dùng, bằng bàn phím vật lý, ở cả tốc độ gõ bình thường lẫn rất nhanh. Phần lớn lỗi người dùng báo chỉ xuất hiện trong những điều kiện đó.
  • Đo bộ nhớ thường xuyên, vì bộ gõ chạy suốt cả ngày.

Cài JOKey

JOKey miễn phí, cho macOS 11 trở lên, không kết nối mạng. Cài bằng một lệnh trong Terminal:

curl -sS https://afixer.app/jokey/install.sh | sh

Sau đó thêm bàn phím JOKey vào menu nhập liệu và cấp quyền Accessibility theo hướng dẫn cài đặt. Xem thêm ở trang JOKey.

Bài này được viết thế nào

Phần thêm cho ai đọc tới đây. Bài này do Claude Code (mô hình Claude Opus 5.5) viết. Dưới đây là ý tưởng của mình, các prompt chính mình đã gõ (viết lại bằng tiếng Việt cho dễ đọc) và những chỗ phải sửa.

Ý tưởng

Mình muốn ghi lại cách JOKey làm việc (luật gõ, engine, kiểm tra chính tả, các bảng mã cũ, cách macOS và từng app nhận phím, so sánh với các bộ gõ khác) để giúp các bạn đang làm bộ gõ hoặc xử lý tiếng Việt. JOKey 2.0 cũng được viết lại bằng Claude Code, nên các số đo và các lỗi trong bài lấy từ những phiên làm việc đó.

Các prompt

  1. Tôi muốn viết một bài blog về việc làm bộ gõ tiếng Việt trên macOS, đi sâu vào các vấn đề: luật gõ tiếng Việt và các ngoại lệ, engine biến phím thành chữ Việt, cách làm cho tối ưu, kiểm tra chính tả và trả lại phím đã gõ, các bảng mã cũ như VNI/TCVN3 đã bị bỏ, macOS hỗ trợ thế nào, các app xử lý ra sao, so sánh các bộ gõ, và JOKey giải quyết các vấn đề đó thế nào… có thể thêm nữa, không giới hạn. Bài nên có hình ảnh, ví dụ…, và có ích cho các lập trình viên đang học hoặc gặp vấn đề tương tự. Bạn sẽ đăng bài lên afixer.app.

    Claude đọc mã nguồn JOKey và engine Go, giao cho ba agent phụ tìm hiểu các bộ gõ khác, các bảng mã và luật đặt dấu, rồi viết bản nháp, vẽ hình SVG và dựng ô gõ thử (engine Rust biên dịch sang WebAssembly).

  2. Đây là những dấu hiệu tôi gặp quá nhiều khi nghe hoặc đọc nội dung do AI viết: "có ... thật", "là chuyện", "có đúng", "ngay ở"... Tìm cách khắc phục.

    Claude viết lại các câu kiểu này trong bài và trong các trang JOKey, rồi viết script check-vi.mjs để dò chúng. Trang web không build được nếu script còn tìm thấy.

  3. Nhắc tới cả thư viện TCVN nữa.

    Đoạn mở đầu nhắc tới thư viện TCVN mình viết năm 2008, có link sang bài riêng.

  4. Tôi muốn blog dễ đọc với người đọc ngày nay, những người có thể không đọc hết bản dài: có bản ngắn, có cách khám phá dần, có theo dõi và hiển thị tiến độ đọc; và cũng dễ đọc với các AI crawler.

    Mọi bài có phần tóm tắt ở đầu, mục lục đánh dấu các mục đã đọc, thanh tiến độ, và bản Markdown cho AI (bản của bài này, /llms-full.txt).

  5. Trả lời câu hỏi của AI: Bài JOKey trích các đoạn mã ngắn từ repo JOKey và vengine, vốn không công khai. Như vậy có được không?

    Được, nhưng phải ghi rõ trước rằng đó là mã nguồn không công khai.

    Thêm ghi chú mã nguồn không công khai liền sau đoạn mở đầu, trước mọi đoạn mã.

  6. Tôi muốn ghi rõ ở mọi bài đã đăng và sắp đăng rằng bài đó do AI viết, và chia sẻ chi tiết các prompt, ý tưởng và những chỗ tôi sửa, như phần thưởng cho ai đọc hết bài.

    Thêm dòng ghi chú AI viết ở đầu bài và phần này ở cuối bài, cho cả các bài đã đăng.

  7. Thêm giải thích tại sao tôi vẫn muốn tích hợp vào input source của macOS mà không làm một app độc lập có biểu tượng trên thanh menu: tôi từng dùng cả bộ gõ tiếng Nhật, và muốn bộ gõ tiếng Việt nằm cùng vị trí với bộ gõ tiếng Nhật trên hệ thống, chỉ dùng một phím tắt để chuyển giữa các bộ gõ.

    Mục JOKey 2.0: keyboard layout và app chạy nền có thêm đoạn giải thích này.

  8. "Ca gõ" có nên gọi là test case không? Tìm thuật ngữ tiếng Anh cho "app đứng trước".

    Bài dùng "test case" thay cho "ca gõ", và "frontmost app" (tên macOS dùng trong API) thay cho "app đứng trước".

  9. Thêm link vào ibus-teni.

    Mọi chỗ nhắc tới ibus-teni trong bài đều có link tới repo trên GitHub.

Những chỗ phải sửa

  • Bản nháp đầu dùng nhiều kiểu câu mình hay gặp ở nội dung AI viết (prompt 2). Giờ script check-vi.mjs chạy mỗi lần build để chặn các kiểu câu đó.
  • Bản nháp chưa nói mã nguồn JOKey không công khai trước khi trích mã (prompt 5), và chưa nói bài do AI viết (prompt 6).
  • Bảng so sánh bị cắt mép trên màn hình máy tính, và mục About trên thanh menu bị cắt ở màn hình điện thoại 375 px khi trang có thêm mục Writing. Claude thấy hai lỗi này khi chụp lại trang để kiểm tra, rồi sửa CSS.