
Khi bắt đầu viết lại core engine cho uvie-rs, mình không hề nghĩ đến finite state machine (FSM).
Ý tưởng ban đầu rất đơn giản: làm sao để mỗi lần người dùng nhấn một phím, engine có thể phản hồi gần như ngay lập tức, không cần replay toàn bộ chuỗi, không cần cấp phát bộ nhớ, và vẫn xử lý đúng tất cả quy tắc của tiếng Việt.
Nhưng càng viết, mình càng nhận ra IME (Input Method Engine) thực chất không phải là một bài toán xử lý chuỗi (string processing), mà là một bài toán xử lý trạng thái.
IME không xử lý text, mà xử lý state
Nhiều bộ gõ truyền thống có xu hướng coi chuỗi đang gõ là dữ liệu gốc.
Mỗi khi người dùng nhấn thêm một phím:
- đọc lại toàn bộ chuỗi,
- phân tích âm tiết,
- xác định vị trí đặt dấu,
- xử lý modifier,
- render lại kết quả.
Cách tiếp cận này hoàn toàn đúng, nhưng đồng nghĩa với việc mỗi keystroke đều phải "đọc lại lịch sử".
Trong uvie-rs, mình chọn hướng ngược lại.
Engine không coi string là source of truth.
Source of truth là trạng thái của âm tiết.
Mỗi ký tự được lưu dưới dạng một Syl:
base
out
tone
flags
base là ký tự ASCII người dùng gõ.
tone lưu dấu thanh.
flags lưu các modifier như mũ, râu, đ, trạng thái literal...
Còn out chỉ là ký tự được render từ toàn bộ state đó.
Điều này có nghĩa là string mà người dùng nhìn thấy chỉ là kết quả của quá trình render state, chứ không phải dữ liệu mà engine thao tác trực tiếp.
Mỗi lần nhấn phím chỉ là một state transition
Sau khi refactor vài lần, mình nhận ra kiến trúc này gần như trùng khớp với Mealy Machine.
Một transition của engine có dạng:
(current_state, input_key)
↓
(next_state, output)
Trong đó:
current_statelà toàn bộSylBuf.input_keylà phím người dùng vừa nhấn.next_statelàSylBufsau khi được cập nhật.outputlà chuỗi mới cần hiển thị.
Điểm quan trọng là output phụ thuộc đồng thời vào state và input, đúng với định nghĩa của Mealy Machine.
Thay vì viết hàng trăm nhánh if-else thao tác trên string, engine chỉ cần cập nhật state rồi render lại.
Pipeline luôn cố định
Một keystroke luôn đi qua cùng một pipeline:
classify
↓
dispatch
↓
mutate state
↓
render
Đầu tiên engine phân loại input bằng bảng lookup O(1).
Sau đó dispatch tới handler tương ứng:
- vowel
- consonant
- modifier
- tone
Handler chỉ cập nhật một vài field trong Syl.
Ví dụ:
- đổi tone,
- bật
F_CIRCUMFLEX, - bật
F_HORN, - đánh dấu literal.
Sau đó render lại toàn bộ output.
Không replay.
Không compact.
Không bubble.
Không reorder ký tự.
Thực chất hầu hết transition chỉ là một vài phép cập nhật bit trên state.
Vì sao không sửa trực tiếp string?
Nếu thao tác trực tiếp trên string, rất nhiều bài toán sẽ trở nên khó hơn:
- tone placement
- modifier
- rollback
- diff cho IME
- backspace
- validation
Trong khi nếu mọi thông tin đều nằm trong state thì các thao tác này chỉ còn là cập nhật state rồi render.
Ví dụ khi người dùng đổi vị trí dấu hoặc thêm modifier, engine không cần sửa chuỗi Unicode đã render.
Nó chỉ cần cập nhật tone hoặc flags trên đúng Syl, sau đó render lại.
Mô hình dữ liệu quyết định độ phức tạp của thuật toán.
Frontend gần như không biết tiếng Việt
Một lợi ích khá thú vị của cách thiết kế này là frontend cực kỳ đơn giản.
Frontend không cần biết:
- Telex,
- VNI,
- vị trí đặt dấu,
- quy tắc âm tiết,
- hay bất kỳ logic tiếng Việt nào.
Nó chỉ gửi keystroke vào engine và nhận lại:
(backspace_count, suffix_to_type)
Đúng với những gì một IME trên macOS, Windows hay terminal cần.
Toàn bộ logic tiếng Việt đều nằm trong core engine.
Điều mình học được
Trước khi làm project này, mình nghĩ tối ưu IME chủ yếu là giảm allocation hoặc giảm số lần copy string.
Sau khi hoàn thành, mình thấy điều quan trọng hơn là chọn đúng mô hình dữ liệu.
Khi state trở thành source of truth, rất nhiều bài toán vốn phức tạp bỗng trở nên tự nhiên hơn.
Có lẽ đó cũng là lý do sau vài lần refactor, mình mới nhận ra mình không còn viết một "string processor" nữa, mà đang xây dựng một Mealy Machine cho tiếng Việt.




