vietc/engine/src/engine.rs
vndangkhoa 95f661aaa0 Viet+ v0.1.1
- Flexible diacritic placement: modifiers/tone marks at end of syllable
  (Telex: tranaf -> tran, VNI: tran62 -> tran)
- uinput injector as primary backend (avoids X11/Unicode ordering bugs)
- ydotool for atomic backspace+text injection (same uinput device)
- Fix run_as_user to use explicit 'env VAR=val' (sudo compat)
- Remove deb/flatpak/aur packaging (AppImage only)
- Fix Telex key mappings (aa=aa, aw=a, ow=o)
- Fix VNI key mappings (a6=aa, a8=a, e6=e, o6=o, o7=o, u7=u)
- Fix X11Injector ydotool fallback for Unicode chars
- 162+ engine tests passing
2026-06-24 17:29:12 +07:00

321 lines
10 KiB
Rust

use crate::telex::TelexEngine;
use crate::vni::VniEngine;
use crate::english::EnglishDict;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMethod {
Telex,
Vni,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EngineEvent {
Replace { backspaces: usize, insert: String },
Insert(String),
Flush(String),
AutoRestore(String),
/// ESC undo: strip all tone marks from current word
UndoTones { backspaces: usize, restored: String },
}
pub struct Engine {
input_method: InputMethod,
telex: TelexEngine,
vni: VniEngine,
english: EnglishDict,
enabled: bool,
macros: std::collections::HashMap<String, String>,
raw_buffer: String,
}
impl Engine {
pub fn new(method: InputMethod) -> Self {
Self {
input_method: method,
telex: TelexEngine::new(),
vni: VniEngine::new(),
english: EnglishDict::new(),
enabled: true,
macros: std::collections::HashMap::new(),
raw_buffer: String::new(),
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.flush();
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn set_method(&mut self, method: InputMethod) {
self.input_method = method;
self.reset();
}
pub fn reset(&mut self) {
self.telex.reset();
self.vni.reset();
self.raw_buffer.clear();
}
pub fn flush(&mut self) -> Option<EngineEvent> {
match self.input_method {
InputMethod::Telex => self.telex.flush(),
InputMethod::Vni => self.vni.flush(),
}
}
/// Add a macro shortcut
pub fn add_macro(&mut self, shortcut: String, expansion: String) {
self.macros.insert(shortcut, expansion);
}
/// Clear all macros
pub fn clear_macros(&mut self) {
self.macros.clear();
}
/// Process ESC key - undo tones from current word
pub fn process_escape(&mut self) -> Option<EngineEvent> {
let buffer = match self.input_method {
InputMethod::Telex => self.telex.buffer(),
InputMethod::Vni => self.vni.buffer(),
};
if buffer.is_empty() {
return None;
}
// Strip all diacritics from the buffer
let stripped = strip_diacritics(buffer);
let backspaces = buffer.chars().count();
let had_tones = stripped != buffer;
self.reset();
if had_tones {
Some(EngineEvent::UndoTones {
backspaces,
restored: stripped,
})
} else {
Some(EngineEvent::Flush(stripped))
}
}
pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> {
if !self.enabled {
return None;
}
// ESC = undo tones
if ch == '\x1b' {
return self.process_escape();
}
if ch == '\x08' {
// Backspace handling: pop from inner engine and sync raw_buffer
match self.input_method {
InputMethod::Telex => self.telex.pop(),
InputMethod::Vni => self.vni.pop(),
}
let inner_len = self.buffer().chars().count();
// Truncate raw_buffer to match inner engine buffer's character count
let char_indices: Vec<(usize, char)> = self.raw_buffer.char_indices().collect();
if char_indices.len() > inner_len {
if inner_len == 0 {
self.raw_buffer.clear();
} else {
let cut_idx = char_indices[inner_len].0;
self.raw_buffer.truncate(cut_idx);
}
}
return None;
}
if ch == ' ' || ch == '\t' || ch == '.' || ch == ',' || ch == '!' || ch == '?'
|| ch == ';' || ch == ':' || ch == '\n'
{
if self.raw_buffer.is_empty() {
return None;
}
// Check for macro expansion before auto-restore
let macro_expansion = self.macros.get(&self.raw_buffer).cloned();
if let Some(expansion) = macro_expansion {
let previous_raw_len = self.raw_buffer.chars().count();
self.reset();
return Some(EngineEvent::Replace {
backspaces: previous_raw_len + 1,
insert: format!("{}{}", expansion, ch),
});
}
// Try auto-restore before flushing
let clean_raw = self.raw_buffer.to_lowercase();
if self.english.should_restore(&clean_raw) {
let inner_buf = self.buffer().to_string();
let clean_inner = strip_diacritics(&inner_buf).to_lowercase();
let has_diacritics = clean_inner != inner_buf.to_lowercase();
let original_raw = self.raw_buffer.clone();
let inner_len = inner_buf.chars().count();
self.reset();
if has_diacritics {
return Some(EngineEvent::Replace {
backspaces: inner_len + 1,
insert: format!("{}{}", original_raw, ch),
});
} else {
return None;
}
}
// Flush buffer with trailing character
let previous_inner = self.buffer().to_string();
let previous_inner_len = previous_inner.chars().count();
let flush_event = self.flush();
let mut final_word = previous_inner.clone();
if let Some(EngineEvent::Flush(word)) = flush_event {
final_word = word;
}
let result = if final_word != previous_inner {
Some(EngineEvent::Replace {
backspaces: previous_inner_len + 1,
insert: format!("{}{}", final_word, ch),
})
} else {
None
};
self.reset();
return result;
}
// Regular character processing
let previous_inner = self.buffer().to_string();
self.raw_buffer.push(ch);
match self.input_method {
InputMethod::Telex => { self.telex.process_key(ch); }
InputMethod::Vni => { self.vni.process_key(ch); }
}
let new_inner = self.buffer().to_string();
let expected_screen = format!("{}{}", previous_inner, ch);
if new_inner != expected_screen {
Some(EngineEvent::Replace {
backspaces: previous_inner.chars().count() + 1,
insert: new_inner,
})
} else {
None
}
}
pub fn buffer(&self) -> &str {
match self.input_method {
InputMethod::Telex => self.telex.buffer(),
InputMethod::Vni => self.vni.buffer(),
}
}
}
/// Strip all Vietnamese diacritics from a string, returning base ASCII
fn strip_diacritics(s: &str) -> String {
s.chars()
.map(|c| match c {
// a variants
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ'
| 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
// A variants
'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ'
| 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A',
// e variants
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e',
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E',
// i variants
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I',
// o variants
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ'
| 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o',
'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ'
| 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O',
// u variants
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u',
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U',
// y variants
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y',
// đ
'đ' => 'd',
'Đ' => 'D',
// Everything else unchanged
other => other,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_diacritics() {
assert_eq!(strip_diacritics("chào"), "chao");
assert_eq!(strip_diacritics("cám ơn"), "cam on");
assert_eq!(strip_diacritics("Việt Nam"), "Viet Nam");
assert_eq!(strip_diacritics("hello"), "hello");
assert_eq!(strip_diacritics("đường"), "duong");
assert_eq!(strip_diacritics("Nguyễn"), "Nguyen");
}
#[test]
fn test_esc_undo_tones() {
let mut engine = Engine::new(InputMethod::Telex);
// Type "chào" then ESC
for ch in "chào".chars() {
engine.process_key(ch);
}
let event = engine.process_escape();
match event {
Some(EngineEvent::UndoTones { backspaces, restored }) => {
assert_eq!(backspaces, 4); // "chào" is 4 chars
assert_eq!(restored, "chao");
}
_ => panic!("Expected UndoTones event, got {:?}", event),
}
}
#[test]
fn test_macro_expansion() {
let mut engine = Engine::new(InputMethod::Telex);
engine.add_macro("ko".into(), "không".into());
engine.add_macro("ok".into(), "được".into());
// Type "ko" + space
let events: Vec<_> = "ko ".chars()
.filter_map(|ch| engine.process_key(ch))
.collect();
// Should contain the macro expansion
let output: String = events.iter().filter_map(|e| match e {
EngineEvent::Flush(s) => Some(s.as_str()),
EngineEvent::Insert(s) => Some(s.as_str()),
EngineEvent::Replace { insert, .. } => Some(insert.as_str()),
_ => None,
}).collect();
assert!(output.contains("không"));
}
}