From f618c3a5b572e91b16723ef5fd205b62fb673bef Mon Sep 17 00:00:00 2001 From: vndangkhoa Date: Wed, 24 Jun 2026 20:30:14 +0700 Subject: [PATCH] Fix typing race conditions with unified channel injection, add persistent logging, and align config schemas --- daemon/src/config.rs | 5 + daemon/src/main.rs | 152 +++- engine/examples/gen_tests.rs | 89 +++ engine/examples/trace_events.rs | 74 ++ engine/gen_tests_output.json | 1000 ++++++++++++++++++++++++ engine/src/telex.rs | 290 ++++++- engine/src/tests.rs | 343 ++++++++- engine/src/vni.rs | 284 ++++++- engine/tests/generated_bulk.rs | 1065 ++++++++++++++++++++++++++ packaging/appimage/build-appimage.sh | 5 +- protocol/src/uinput_monitor.rs | 298 ++++--- protocol/src/x11_inject.rs | 121 ++- scripts/gen_test_cases.py | 159 ++++ ui/Cargo.toml | 17 +- ui/data/window.ui | 69 -- ui/src/config.rs | 106 +-- ui/src/main.rs | 178 ++++- ui/src/tray.rs | 279 +++---- ui/src/window.rs | 710 ----------------- 19 files changed, 4048 insertions(+), 1196 deletions(-) create mode 100644 engine/examples/gen_tests.rs create mode 100644 engine/examples/trace_events.rs create mode 100644 engine/gen_tests_output.json create mode 100644 engine/tests/generated_bulk.rs create mode 100644 scripts/gen_test_cases.py delete mode 100644 ui/data/window.ui delete mode 100644 ui/src/window.rs diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 89f112b..a1d1271 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -27,6 +27,9 @@ pub struct Config { #[serde(default)] pub grab: bool, + + #[serde(default = "default_false")] + pub debug: bool, } #[derive(Debug, Deserialize)] @@ -75,6 +78,7 @@ fn default_input_method() -> String { "telex".into() } fn default_toggle_key() -> String { "space".into() } fn default_start_enabled() -> bool { true } fn default_true() -> bool { true } +fn default_false() -> bool { false } fn default_restore_keys() -> Vec { vec!["space".into(), "escape".into()] } fn default_english_apps() -> Vec { @@ -160,6 +164,7 @@ impl Default for Config { app_state: AppStateConfig::default(), macros, grab: false, + debug: false, } } } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 71ea131..ac3f6a2 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -15,6 +15,62 @@ mod display; use config::Config; use app_state::AppStateManager; +fn get_log_path() -> Option { + dirs::config_dir().map(|p| p.join("vietc").join("vietc.log")) +} + +fn get_timestamp() -> String { + if let Ok(n) = std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH) { + let secs = n.as_secs(); + let millis = n.subsec_millis(); + unsafe { + let t = secs as libc::time_t; + let mut tm = std::mem::zeroed::(); + if !libc::localtime_r(&t, &mut tm).is_null() { + return format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}", + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday, + tm.tm_hour, + tm.tm_min, + tm.tm_sec, + millis + ); + } + } + } + "".to_string() +} + +fn log_info(msg: &str) { + eprintln!("{}", msg); + + if let Some(log_path) = get_log_path() { + if let Some(parent) = log_path.parent() { + let _ = fs::create_dir_all(parent); + } + + // Rotate log if it exceeds 10MB + if let Ok(metadata) = fs::metadata(&log_path) { + if metadata.len() > 10 * 1024 * 1024 { + let backup_path = log_path.with_extension("log.old"); + let _ = fs::rename(&log_path, backup_path); + } + } + + if let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + use std::io::Write; + let timestamp = get_timestamp(); + let _ = writeln!(file, "[{}] {}", timestamp, msg); + } + } +} + struct Daemon { engine: Engine, config: Config, @@ -77,7 +133,7 @@ impl Daemon { if let Ok(content) = fs::read_to_string(&status_path) { let expect_enabled = content.trim() == "vn"; if self.engine.is_enabled() != expect_enabled { - eprintln!("[vietc] Syncing enabled status from file: {}", expect_enabled); + log_info(&format!("[vietc] Syncing enabled status from file: {}", expect_enabled)); self.engine.set_enabled(expect_enabled); self.engine_enabled.store(expect_enabled, Ordering::SeqCst); } @@ -94,7 +150,7 @@ impl Daemon { return false; } - eprintln!("[vietc] Config changed, reloading..."); + log_info("[vietc] Config changed, reloading..."); match Config::load_from(&self.config_path) { Ok(new_config) => { let method = match new_config.input_method.as_str() { @@ -116,11 +172,11 @@ impl Daemon { self.grab_enabled = new_config.grab; self.config = new_config; self.config_modified = modified; - eprintln!("[vietc] Config reloaded successfully"); + log_info("[vietc] Config reloaded successfully"); true } Err(e) => { - eprintln!("[vietc] Failed to reload config: {}", e); + log_info(&format!("[vietc] Failed to reload config: {}", e)); false } } @@ -130,7 +186,7 @@ impl Daemon { let mut commands = Vec::new(); if let Some(event) = self.engine.process_key(ch) { - eprintln!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event); + log_info(&format!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event)); match event { EngineEvent::Flush(text) => { commands.push(OutputCommand::Type(text)); @@ -153,7 +209,7 @@ impl Daemon { } } } else { - eprintln!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer()); + log_info(&format!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer())); } commands @@ -191,11 +247,11 @@ fn main() -> Result<(), Box> { let display = display::detect_display_server(); let compositor = display::detect_compositor(); - eprintln!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION")); - eprintln!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into())); - eprintln!("Input method: {:?}", daemon.config.input_method); - eprintln!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase()); - eprintln!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" }); + log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION"))); + log_info(&format!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into()))); + log_info(&format!("Input method: {:?}", daemon.config.input_method)); + log_info(&format!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase())); + log_info(&format!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" })); // Spawn background monitor for active window, config changes, and status changes let shared_active_window = Arc::new(Mutex::new(String::new())); @@ -254,7 +310,7 @@ fn main() -> Result<(), Box> { match open_keyboard_device() { Ok((device, path)) => { - eprintln!("[vietc] Keyboard device: {}", path); + log_info(&format!("[vietc] Keyboard device: {}", path)); run_with_evdev( device, &mut daemon, @@ -266,8 +322,8 @@ fn main() -> Result<(), Box> { )?; } Err(e) => { - eprintln!("[vietc] No keyboard device: {}", e); - eprintln!("[vietc] Running in stdin test mode"); + log_info(&format!("[vietc] No keyboard device: {}", e)); + log_info("[vietc] Running in stdin test mode"); run_stdin_mode( &mut daemon, shared_active_window, @@ -362,22 +418,23 @@ fn run_with_evdev( let grabbed = if daemon.grab_enabled { match device.grab() { Ok(()) => { - eprintln!("[vietc] Keyboard grabbed — race condition eliminated"); + log_info("[vietc] Keyboard grabbed — race condition eliminated"); true } Err(e) => { - eprintln!("[vietc] Could not grab keyboard: {} (run as root for grab)", e); - eprintln!("[vietc] Falling back to non-grabbing mode (may have race)"); + log_info(&format!("[vietc] Could not grab keyboard: {} (run as root for grab)", e)); + log_info("[vietc] Falling back to non-grabbing mode (may have race)"); false } } } else { - eprintln!("[vietc] Keyboard grab disabled (config grab = false)"); - eprintln!("[vietc] Set grab = true in vietc.toml to enable (needs root)"); + log_info("[vietc] Keyboard grab disabled (config grab = false)"); + log_info("[vietc] Set grab = true in vietc.toml to enable (needs root)"); false }; let mut consumed_keys: HashSet = HashSet::new(); + let mut last_active_window = String::new(); // Safety: if grab is active and no events arrive for 30 seconds, // release the grab so the user isn't locked out. @@ -386,7 +443,7 @@ fn run_with_evdev( loop { // Check for event timeout (grab safety) if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) { - eprintln!("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety"); + log_info("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety"); let _ = device.ungrab(); return Ok(()); } @@ -401,6 +458,17 @@ fn run_with_evdev( status_changed.store(false, Ordering::SeqCst); } + // Track window changes and reset engine buffer + { + let active_window = shared_active_window.lock().unwrap().clone(); + if active_window != last_active_window { + log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window)); + last_active_window = active_window.clone(); + daemon.engine.reset(); + log_info("[vietc] Reset engine buffer due to window change"); + } + } + // Check for app changes instantly using the cached state from background thread if daemon.config.app_state.enabled { let active_window = shared_active_window.lock().unwrap().clone(); @@ -505,11 +573,11 @@ fn run_stdin_mode( if !io::stdin().is_terminal() { - eprintln!("[vietc] Warning: No keyboard device and no terminal."); - eprintln!("[vietc] Retrying keyboard access every 5 seconds..."); - eprintln!("[vietc] Ensure you are in the 'input' group:"); - eprintln!(" sudo usermod -aG input $USER"); - eprintln!(" Then log out and back in."); + log_info("[vietc] Warning: No keyboard device and no terminal."); + log_info("[vietc] Retrying keyboard access every 5 seconds..."); + log_info("[vietc] Ensure you are in the 'input' group:"); + log_info(" sudo usermod -aG input $USER"); + log_info(" Then log out and back in."); // Retry loop: periodically attempt to reopen the keyboard device loop { @@ -526,7 +594,7 @@ fn run_stdin_mode( } if let Ok((device, path)) = open_keyboard_device() { - eprintln!("[vietc] Keyboard device found: {}", path); + log_info(&format!("[vietc] Keyboard device found: {}", path)); return run_with_evdev( device, daemon, shared_active_window, @@ -541,8 +609,9 @@ fn run_stdin_mode( let injector = create_injector(display)?; let mut buffer = [0u8; 1]; + let mut last_active_window = String::new(); - eprintln!("[vietc] Type to test, Ctrl+C to exit"); + log_info("[vietc] Type to test, Ctrl+C to exit"); let stdin = io::stdin(); let mut handle = stdin.lock(); @@ -553,6 +622,17 @@ fn run_stdin_mode( status_changed.store(false, Ordering::SeqCst); } + // Track window changes and reset engine buffer + { + let active_window = shared_active_window.lock().unwrap().clone(); + if active_window != last_active_window { + log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window)); + last_active_window = active_window.clone(); + daemon.engine.reset(); + log_info("[vietc] Reset engine buffer due to window change"); + } + } + // Check for app changes instantly using the cached state from background thread if daemon.config.app_state.enabled { let active_window = shared_active_window.lock().unwrap().clone(); @@ -573,7 +653,7 @@ fn run_stdin_mode( execute_commands(&*injector, &commands, false); } Err(e) => { - eprintln!("[vietc] Read error: {}", e); + log_info(&format!("[vietc] Read error: {}", e)); break; } } @@ -593,18 +673,18 @@ fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[Outp match cmd { OutputCommand::Backspace(count) => { let adjusted = if grabbed { count.saturating_sub(1) } else { *count }; - eprintln!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted); + log_info(&format!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted)); pending_backspaces += adjusted; } OutputCommand::Type(text) => { - eprintln!("[vietc] cmd: Type(\"{}\")", text); + log_info(&format!("[vietc] cmd: Type(\"{}\")", text)); pending_text.push_str(text); } } } if pending_backspaces > 0 || !pending_text.is_empty() { - eprintln!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text); + log_info(&format!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text)); injector.inject_replacement(pending_backspaces, &pending_text); } injector.flush(); @@ -615,7 +695,7 @@ fn create_injector(display: display::DisplayServer) -> Result Result { - eprintln!("[vietc] Using uinput injection (primary)"); + log_info("[vietc] Using uinput injection (primary)"); return Ok(Box::new(injector)); } Err(e) => { - eprintln!("[vietc] uinput not available: {}", e); + log_info(&format!("[vietc] uinput not available: {}", e)); } } @@ -638,11 +718,11 @@ fn create_injector(display: display::DisplayServer) -> Result { - eprintln!("[vietc] Using X11 injection (XTEST fallback)"); + log_info("[vietc] Using X11 injection (XTEST fallback)"); return Ok(Box::new(injector)); } Err(e) => { - eprintln!("[vietc] X11 not available: {}", e); + log_info(&format!("[vietc] X11 not available: {}", e)); } } } diff --git a/engine/examples/gen_tests.rs b/engine/examples/gen_tests.rs new file mode 100644 index 0000000..8377f32 --- /dev/null +++ b/engine/examples/gen_tests.rs @@ -0,0 +1,89 @@ +use std::io::{self, Write}; +use vietc_engine::{Engine, EngineEvent, InputMethod}; + +fn get_display(events: &[EngineEvent]) -> String { + let mut display = String::new(); + for ev in events { + match ev { + EngineEvent::Flush(text) => { if !display.ends_with(text) { display.push_str(text); } } + EngineEvent::Insert(text) => display.push_str(text), + EngineEvent::Replace { backspaces, insert } => { + for _ in 0..*backspaces { display.pop(); } + display.push_str(insert); + } + EngineEvent::AutoRestore(word) => { + for _ in 0..word.len() { display.pop(); } + display.push_str(word); + } + EngineEvent::UndoTones { backspaces, restored } => { + for _ in 0..*backspaces { display.pop(); } + display.push_str(restored); + } + } + } + display +} + +fn process_input(e: &mut Engine, input: &str) -> Vec { + let mut events = Vec::new(); + for ch in input.chars() { + if let Some(ev) = e.process_key(ch) { events.push(ev); } + } + events +} + +const INITIALS: &[&str] = &[ + "", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n", + "ng", "ngh", "nh", "p", "ph", "q", "r", "s", "t", "th", "tr", "v", "x", +]; + +const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"]; + +fn is_valid(init: &str, fin: &str) -> bool { + if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { return false; } + if init == "gh" && !fin.is_empty() { return false; } + if init == "q" { return false; } + if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { return false; } + if fin == "ch" && init == "" { return false; } + if fin == "nh" && init == "" { return false; } + true +} + +fn main() { + // Telex base vowels (as typed, before mod) + let telex_vowels: Vec<(&str, &str)> = vec![ + ("a", "af"), ("a", "as"), ("a", "aj"), ("a", "ar"), ("a", "ax"), + ("a", "aw"), ("a", "aa"), + ("e", "ee"), + ("o", "oo"), ("o", "ow"), + ("u", "uw"), + ]; + + let mut count = 0; + let stdout = io::stdout(); + let mut handle = stdout.lock(); + + for &init in INITIALS { + for &fin in FINALS { + if !is_valid(init, fin) { continue; } + for &(base, mod_str) in &telex_vowels { + let plain = format!("{}{}{}", init, base, fin); + let full = format!("{}{}", plain, mod_str); + if plain.len() > 10 { continue; } + + let mut e = Engine::new(InputMethod::Telex); + let result = get_display(&process_input(&mut e, &full)); + + if !result.is_empty() && result.len() <= 12 && result != full && result != plain { + count += 1; + let _ = writeln!(handle, "{{\"i\":\"{full}\",\"e\":\"{result}\",\"m\":\"telex\"}}"); + } + if count >= 1000 { break; } + } + if count >= 1000 { break; } + } + if count >= 1000 { break; } + } + + eprintln!("Generated {count} test cases"); +} diff --git a/engine/examples/trace_events.rs b/engine/examples/trace_events.rs new file mode 100644 index 0000000..61e3304 --- /dev/null +++ b/engine/examples/trace_events.rs @@ -0,0 +1,74 @@ +use vietc_engine::{Engine, InputMethod, EngineEvent}; + +fn trace(input: &str, method: InputMethod) { + let mut e = Engine::new(method); + eprintln!("\n=== {:?}: {} ===", method, input); + eprintln!("Ch | prev_buf → new_buf | expected_screen | Event"); + eprintln!("---+-----------+-----------+---------------------+------"); + for ch in input.chars() { + let prev = e.buffer().to_string(); + let event = e.process_key(ch); + let curr = e.buffer().to_string(); + let expected = format!("{}{}", prev, ch); + let event_str = match &event { + Some(EngineEvent::Replace { backspaces, insert }) => + format!("Replace({}, {:?})", backspaces, insert), + Some(EngineEvent::Insert(t)) => format!("Insert({:?})", t), + Some(EngineEvent::Flush(t)) => format!("Flush({:?})", t), + Some(EngineEvent::AutoRestore(w)) => format!("AutoRestore({:?})", w), + Some(EngineEvent::UndoTones { backspaces, restored }) => + format!("UndoTones({}, {:?})", backspaces, restored), + None => "None".to_string(), + }; + let backspaces = match &event { + Some(EngineEvent::Replace { backspaces, .. }) => format!("bs={}", backspaces), + _ => " ".to_string(), + }; + eprintln!("'{}' | {:<9} → {:<9} | {:<19} | {}", + ch, prev, curr, expected, event_str); + if let Some(EngineEvent::Replace { backspaces, insert }) = &event { + // In grab mode, backspace - 1 (key consumed) + let grab_bs = backspaces.saturating_sub(1); + // In non-grab mode, full backspace + eprintln!(" | | | grab_bs={} non_grab_bs={} insert={:?}", + grab_bs, backspaces, insert); + } + } + // Flush + if let Some(event) = e.flush() { + eprintln!("FL | | | | {:?}", event); + } +} + +fn main() { + // Category 1: Basic A group + trace("traan", InputMethod::Telex); // trâ + trace("traanw", InputMethod::Telex); // trân → w → trăn + trace("tranwa", InputMethod::Telex); // trăn → a → trân + + // Category 2: Basic O group + trace("coon", InputMethod::Telex); // côn + trace("coonw", InputMethod::Telex); // côn → w → cơn + trace("conwo", InputMethod::Telex); // cơn → o → côn + + // Category 3: Smart cluster + trace("chuoonw", InputMethod::Telex); // chuôn → w → chươn + trace("chuonwo", InputMethod::Telex); // chươn → o → chuôn + + // Category 4: With tones + trace("traansw", InputMethod::Telex); // trấn → w → trắn + + // Basic typing + trace("chaof ", InputMethod::Telex); // chào + space + + // VNI tests + trace("tran6", InputMethod::Vni); + trace("tran61", InputMethod::Vni); + trace("tran618", InputMethod::Vni); + trace("con67", InputMethod::Vni); + trace("con627", InputMethod::Vni); + + // Smart cluster VNI + trace("chuon67", InputMethod::Vni); + trace("chuon76", InputMethod::Vni); +} diff --git a/engine/gen_tests_output.json b/engine/gen_tests_output.json new file mode 100644 index 0000000..0add505 --- /dev/null +++ b/engine/gen_tests_output.json @@ -0,0 +1,1000 @@ +{"i":"aaf","e":"ầ","m":"telex"} +{"i":"aas","e":"ấ","m":"telex"} +{"i":"aaj","e":"ậ","m":"telex"} +{"i":"aar","e":"ẩ","m":"telex"} +{"i":"aax","e":"ẫ","m":"telex"} +{"i":"aaw","e":"ă","m":"telex"} +{"i":"aaa","e":"â","m":"telex"} +{"i":"eee","e":"ê","m":"telex"} +{"i":"ooo","e":"ô","m":"telex"} +{"i":"oow","e":"ơ","m":"telex"} +{"i":"uuw","e":"uư","m":"telex"} +{"i":"acaf","e":"ầc","m":"telex"} +{"i":"acas","e":"ấc","m":"telex"} +{"i":"acaj","e":"ậc","m":"telex"} +{"i":"acar","e":"ẩc","m":"telex"} +{"i":"acax","e":"ẫc","m":"telex"} +{"i":"acaw","e":"ăc","m":"telex"} +{"i":"acaa","e":"âc","m":"telex"} +{"i":"ecee","e":"êc","m":"telex"} +{"i":"ocoo","e":"ôc","m":"telex"} +{"i":"ocow","e":"ơc","m":"telex"} +{"i":"ucuw","e":"ucư","m":"telex"} +{"i":"amaf","e":"ầm","m":"telex"} +{"i":"amas","e":"ấm","m":"telex"} +{"i":"amaj","e":"ậm","m":"telex"} +{"i":"amar","e":"ẩm","m":"telex"} +{"i":"amax","e":"ẫm","m":"telex"} +{"i":"amaw","e":"ăm","m":"telex"} +{"i":"amaa","e":"âm","m":"telex"} +{"i":"emee","e":"êm","m":"telex"} +{"i":"omoo","e":"ôm","m":"telex"} +{"i":"omow","e":"ơm","m":"telex"} +{"i":"umuw","e":"umư","m":"telex"} +{"i":"anaf","e":"ần","m":"telex"} +{"i":"anas","e":"ấn","m":"telex"} +{"i":"anaj","e":"ận","m":"telex"} +{"i":"anar","e":"ẩn","m":"telex"} +{"i":"anax","e":"ẫn","m":"telex"} +{"i":"anaw","e":"ăn","m":"telex"} +{"i":"anaa","e":"ân","m":"telex"} +{"i":"enee","e":"ên","m":"telex"} +{"i":"onoo","e":"ôn","m":"telex"} +{"i":"onow","e":"ơn","m":"telex"} +{"i":"unuw","e":"unư","m":"telex"} +{"i":"angaf","e":"ầng","m":"telex"} +{"i":"angas","e":"ấng","m":"telex"} +{"i":"angaj","e":"ậng","m":"telex"} +{"i":"angar","e":"ẩng","m":"telex"} +{"i":"angax","e":"ẫng","m":"telex"} +{"i":"angaw","e":"ăng","m":"telex"} +{"i":"angaa","e":"âng","m":"telex"} +{"i":"engee","e":"êng","m":"telex"} +{"i":"ongoo","e":"ông","m":"telex"} +{"i":"ongow","e":"ơng","m":"telex"} +{"i":"unguw","e":"ungư","m":"telex"} +{"i":"apaf","e":"ầp","m":"telex"} +{"i":"apas","e":"ấp","m":"telex"} +{"i":"apaj","e":"ập","m":"telex"} +{"i":"apar","e":"ẩp","m":"telex"} +{"i":"apax","e":"ẫp","m":"telex"} +{"i":"apaw","e":"ăp","m":"telex"} +{"i":"apaa","e":"âp","m":"telex"} +{"i":"epee","e":"êp","m":"telex"} +{"i":"opoo","e":"ôp","m":"telex"} +{"i":"opow","e":"ơp","m":"telex"} +{"i":"upuw","e":"upư","m":"telex"} +{"i":"ataf","e":"ầt","m":"telex"} +{"i":"atas","e":"ất","m":"telex"} +{"i":"ataj","e":"ật","m":"telex"} +{"i":"atar","e":"ẩt","m":"telex"} +{"i":"atax","e":"ẫt","m":"telex"} +{"i":"ataw","e":"ăt","m":"telex"} +{"i":"ataa","e":"ât","m":"telex"} +{"i":"etee","e":"êt","m":"telex"} +{"i":"otoo","e":"ôt","m":"telex"} +{"i":"otow","e":"ơt","m":"telex"} +{"i":"utuw","e":"utư","m":"telex"} +{"i":"baaf","e":"bầ","m":"telex"} +{"i":"baas","e":"bấ","m":"telex"} +{"i":"baaj","e":"bậ","m":"telex"} +{"i":"baar","e":"bẩ","m":"telex"} +{"i":"baax","e":"bẫ","m":"telex"} +{"i":"baaw","e":"bă","m":"telex"} +{"i":"baaa","e":"bâ","m":"telex"} +{"i":"beee","e":"bê","m":"telex"} +{"i":"booo","e":"bô","m":"telex"} +{"i":"boow","e":"bơ","m":"telex"} +{"i":"buuw","e":"buư","m":"telex"} +{"i":"bacaf","e":"bầc","m":"telex"} +{"i":"bacas","e":"bấc","m":"telex"} +{"i":"bacaj","e":"bậc","m":"telex"} +{"i":"bacar","e":"bẩc","m":"telex"} +{"i":"bacax","e":"bẫc","m":"telex"} +{"i":"bacaw","e":"băc","m":"telex"} +{"i":"bacaa","e":"bâc","m":"telex"} +{"i":"becee","e":"bêc","m":"telex"} +{"i":"bocoo","e":"bôc","m":"telex"} +{"i":"bocow","e":"bơc","m":"telex"} +{"i":"bucuw","e":"bucư","m":"telex"} +{"i":"bachaf","e":"bầch","m":"telex"} +{"i":"bachas","e":"bấch","m":"telex"} +{"i":"bachaj","e":"bậch","m":"telex"} +{"i":"bachar","e":"bẩch","m":"telex"} +{"i":"bachax","e":"bẫch","m":"telex"} +{"i":"bachaw","e":"băch","m":"telex"} +{"i":"bachaa","e":"bâch","m":"telex"} +{"i":"bechee","e":"bêch","m":"telex"} +{"i":"bochoo","e":"bôch","m":"telex"} +{"i":"bochow","e":"bơch","m":"telex"} +{"i":"buchuw","e":"buchư","m":"telex"} +{"i":"bamaf","e":"bầm","m":"telex"} +{"i":"bamas","e":"bấm","m":"telex"} +{"i":"bamaj","e":"bậm","m":"telex"} +{"i":"bamar","e":"bẩm","m":"telex"} +{"i":"bamax","e":"bẫm","m":"telex"} +{"i":"bamaw","e":"băm","m":"telex"} +{"i":"bamaa","e":"bâm","m":"telex"} +{"i":"bemee","e":"bêm","m":"telex"} +{"i":"bomoo","e":"bôm","m":"telex"} +{"i":"bomow","e":"bơm","m":"telex"} +{"i":"bumuw","e":"bumư","m":"telex"} +{"i":"banaf","e":"bần","m":"telex"} +{"i":"banas","e":"bấn","m":"telex"} +{"i":"banaj","e":"bận","m":"telex"} +{"i":"banar","e":"bẩn","m":"telex"} +{"i":"banax","e":"bẫn","m":"telex"} +{"i":"banaw","e":"băn","m":"telex"} +{"i":"banaa","e":"bân","m":"telex"} +{"i":"benee","e":"bên","m":"telex"} +{"i":"bonoo","e":"bôn","m":"telex"} +{"i":"bonow","e":"bơn","m":"telex"} +{"i":"bunuw","e":"bunư","m":"telex"} +{"i":"bangaf","e":"bầng","m":"telex"} +{"i":"bangas","e":"bấng","m":"telex"} +{"i":"bangaj","e":"bậng","m":"telex"} +{"i":"bangar","e":"bẩng","m":"telex"} +{"i":"bangax","e":"bẫng","m":"telex"} +{"i":"bangaw","e":"băng","m":"telex"} +{"i":"bangaa","e":"bâng","m":"telex"} +{"i":"bengee","e":"bêng","m":"telex"} +{"i":"bongoo","e":"bông","m":"telex"} +{"i":"bongow","e":"bơng","m":"telex"} +{"i":"bunguw","e":"bungư","m":"telex"} +{"i":"banhaf","e":"bầnh","m":"telex"} +{"i":"banhas","e":"bấnh","m":"telex"} +{"i":"banhaj","e":"bậnh","m":"telex"} +{"i":"banhar","e":"bẩnh","m":"telex"} +{"i":"banhax","e":"bẫnh","m":"telex"} +{"i":"banhaw","e":"bănh","m":"telex"} +{"i":"banhaa","e":"bânh","m":"telex"} +{"i":"benhee","e":"bênh","m":"telex"} +{"i":"bonhoo","e":"bônh","m":"telex"} +{"i":"bonhow","e":"bơnh","m":"telex"} +{"i":"bunhuw","e":"bunhư","m":"telex"} +{"i":"bapaf","e":"bầp","m":"telex"} +{"i":"bapas","e":"bấp","m":"telex"} +{"i":"bapaj","e":"bập","m":"telex"} +{"i":"bapar","e":"bẩp","m":"telex"} +{"i":"bapax","e":"bẫp","m":"telex"} +{"i":"bapaw","e":"băp","m":"telex"} +{"i":"bapaa","e":"bâp","m":"telex"} +{"i":"bepee","e":"bêp","m":"telex"} +{"i":"bopoo","e":"bôp","m":"telex"} +{"i":"bopow","e":"bơp","m":"telex"} +{"i":"bupuw","e":"bupư","m":"telex"} +{"i":"bataf","e":"bầt","m":"telex"} +{"i":"batas","e":"bất","m":"telex"} +{"i":"bataj","e":"bật","m":"telex"} +{"i":"batar","e":"bẩt","m":"telex"} +{"i":"batax","e":"bẫt","m":"telex"} +{"i":"bataw","e":"băt","m":"telex"} +{"i":"bataa","e":"bât","m":"telex"} +{"i":"betee","e":"bêt","m":"telex"} +{"i":"botoo","e":"bôt","m":"telex"} +{"i":"botow","e":"bơt","m":"telex"} +{"i":"butuw","e":"butư","m":"telex"} +{"i":"caaf","e":"cầ","m":"telex"} +{"i":"caas","e":"cấ","m":"telex"} +{"i":"caaj","e":"cậ","m":"telex"} +{"i":"caar","e":"cẩ","m":"telex"} +{"i":"caax","e":"cẫ","m":"telex"} +{"i":"caaw","e":"că","m":"telex"} +{"i":"caaa","e":"câ","m":"telex"} +{"i":"ceee","e":"cê","m":"telex"} +{"i":"cooo","e":"cô","m":"telex"} +{"i":"coow","e":"cơ","m":"telex"} +{"i":"cuuw","e":"cuư","m":"telex"} +{"i":"cacaf","e":"cầc","m":"telex"} +{"i":"cacas","e":"cấc","m":"telex"} +{"i":"cacaj","e":"cậc","m":"telex"} +{"i":"cacar","e":"cẩc","m":"telex"} +{"i":"cacax","e":"cẫc","m":"telex"} +{"i":"cacaw","e":"căc","m":"telex"} +{"i":"cacaa","e":"câc","m":"telex"} +{"i":"cecee","e":"cêc","m":"telex"} +{"i":"cocoo","e":"côc","m":"telex"} +{"i":"cocow","e":"cơc","m":"telex"} +{"i":"cucuw","e":"cucư","m":"telex"} +{"i":"cachaf","e":"cầch","m":"telex"} +{"i":"cachas","e":"cấch","m":"telex"} +{"i":"cachaj","e":"cậch","m":"telex"} +{"i":"cachar","e":"cẩch","m":"telex"} +{"i":"cachax","e":"cẫch","m":"telex"} +{"i":"cachaw","e":"căch","m":"telex"} +{"i":"cachaa","e":"câch","m":"telex"} +{"i":"cechee","e":"cêch","m":"telex"} +{"i":"cochoo","e":"côch","m":"telex"} +{"i":"cochow","e":"cơch","m":"telex"} +{"i":"cuchuw","e":"cuchư","m":"telex"} +{"i":"camaf","e":"cầm","m":"telex"} +{"i":"camas","e":"cấm","m":"telex"} +{"i":"camaj","e":"cậm","m":"telex"} +{"i":"camar","e":"cẩm","m":"telex"} +{"i":"camax","e":"cẫm","m":"telex"} +{"i":"camaw","e":"căm","m":"telex"} +{"i":"camaa","e":"câm","m":"telex"} +{"i":"cemee","e":"cêm","m":"telex"} +{"i":"comoo","e":"côm","m":"telex"} +{"i":"comow","e":"cơm","m":"telex"} +{"i":"cumuw","e":"cumư","m":"telex"} +{"i":"canaf","e":"cần","m":"telex"} +{"i":"canas","e":"cấn","m":"telex"} +{"i":"canaj","e":"cận","m":"telex"} +{"i":"canar","e":"cẩn","m":"telex"} +{"i":"canax","e":"cẫn","m":"telex"} +{"i":"canaw","e":"căn","m":"telex"} +{"i":"canaa","e":"cân","m":"telex"} +{"i":"cenee","e":"cên","m":"telex"} +{"i":"conoo","e":"côn","m":"telex"} +{"i":"conow","e":"cơn","m":"telex"} +{"i":"cunuw","e":"cunư","m":"telex"} +{"i":"cangaf","e":"cầng","m":"telex"} +{"i":"cangas","e":"cấng","m":"telex"} +{"i":"cangaj","e":"cậng","m":"telex"} +{"i":"cangar","e":"cẩng","m":"telex"} +{"i":"cangax","e":"cẫng","m":"telex"} +{"i":"cangaw","e":"căng","m":"telex"} +{"i":"cangaa","e":"câng","m":"telex"} +{"i":"cengee","e":"cêng","m":"telex"} +{"i":"congoo","e":"công","m":"telex"} +{"i":"congow","e":"cơng","m":"telex"} +{"i":"cunguw","e":"cungư","m":"telex"} +{"i":"canhaf","e":"cầnh","m":"telex"} +{"i":"canhas","e":"cấnh","m":"telex"} +{"i":"canhaj","e":"cậnh","m":"telex"} +{"i":"canhar","e":"cẩnh","m":"telex"} +{"i":"canhax","e":"cẫnh","m":"telex"} +{"i":"canhaw","e":"cănh","m":"telex"} +{"i":"canhaa","e":"cânh","m":"telex"} +{"i":"cenhee","e":"cênh","m":"telex"} +{"i":"conhoo","e":"cônh","m":"telex"} +{"i":"conhow","e":"cơnh","m":"telex"} +{"i":"cunhuw","e":"cunhư","m":"telex"} +{"i":"capaf","e":"cầp","m":"telex"} +{"i":"capas","e":"cấp","m":"telex"} +{"i":"capaj","e":"cập","m":"telex"} +{"i":"capar","e":"cẩp","m":"telex"} +{"i":"capax","e":"cẫp","m":"telex"} +{"i":"capaw","e":"căp","m":"telex"} +{"i":"capaa","e":"câp","m":"telex"} +{"i":"cepee","e":"cêp","m":"telex"} +{"i":"copoo","e":"côp","m":"telex"} +{"i":"copow","e":"cơp","m":"telex"} +{"i":"cupuw","e":"cupư","m":"telex"} +{"i":"cataf","e":"cầt","m":"telex"} +{"i":"catas","e":"cất","m":"telex"} +{"i":"cataj","e":"cật","m":"telex"} +{"i":"catar","e":"cẩt","m":"telex"} +{"i":"catax","e":"cẫt","m":"telex"} +{"i":"cataw","e":"căt","m":"telex"} +{"i":"cataa","e":"cât","m":"telex"} +{"i":"cetee","e":"cêt","m":"telex"} +{"i":"cotoo","e":"côt","m":"telex"} +{"i":"cotow","e":"cơt","m":"telex"} +{"i":"cutuw","e":"cutư","m":"telex"} +{"i":"chaaf","e":"chầ","m":"telex"} +{"i":"chaas","e":"chấ","m":"telex"} +{"i":"chaaj","e":"chậ","m":"telex"} +{"i":"chaar","e":"chẩ","m":"telex"} +{"i":"chaax","e":"chẫ","m":"telex"} +{"i":"chaaw","e":"chă","m":"telex"} +{"i":"chaaa","e":"châ","m":"telex"} +{"i":"cheee","e":"chê","m":"telex"} +{"i":"chooo","e":"chô","m":"telex"} +{"i":"choow","e":"chơ","m":"telex"} +{"i":"chuuw","e":"chuư","m":"telex"} +{"i":"chacaf","e":"chầc","m":"telex"} +{"i":"chacas","e":"chấc","m":"telex"} +{"i":"chacaj","e":"chậc","m":"telex"} +{"i":"chacar","e":"chẩc","m":"telex"} +{"i":"chacax","e":"chẫc","m":"telex"} +{"i":"chacaw","e":"chăc","m":"telex"} +{"i":"chacaa","e":"châc","m":"telex"} +{"i":"checee","e":"chêc","m":"telex"} +{"i":"chocoo","e":"chôc","m":"telex"} +{"i":"chocow","e":"chơc","m":"telex"} +{"i":"chucuw","e":"chucư","m":"telex"} +{"i":"chachaf","e":"chầch","m":"telex"} +{"i":"chachas","e":"chấch","m":"telex"} +{"i":"chachaj","e":"chậch","m":"telex"} +{"i":"chachar","e":"chẩch","m":"telex"} +{"i":"chachax","e":"chẫch","m":"telex"} +{"i":"chachaw","e":"chăch","m":"telex"} +{"i":"chachaa","e":"châch","m":"telex"} +{"i":"chechee","e":"chêch","m":"telex"} +{"i":"chochoo","e":"chôch","m":"telex"} +{"i":"chochow","e":"chơch","m":"telex"} +{"i":"chuchuw","e":"chuchư","m":"telex"} +{"i":"chamaf","e":"chầm","m":"telex"} +{"i":"chamas","e":"chấm","m":"telex"} +{"i":"chamaj","e":"chậm","m":"telex"} +{"i":"chamar","e":"chẩm","m":"telex"} +{"i":"chamax","e":"chẫm","m":"telex"} +{"i":"chamaw","e":"chăm","m":"telex"} +{"i":"chamaa","e":"châm","m":"telex"} +{"i":"chemee","e":"chêm","m":"telex"} +{"i":"chomoo","e":"chôm","m":"telex"} +{"i":"chomow","e":"chơm","m":"telex"} +{"i":"chumuw","e":"chumư","m":"telex"} +{"i":"chanaf","e":"chần","m":"telex"} +{"i":"chanas","e":"chấn","m":"telex"} +{"i":"chanaj","e":"chận","m":"telex"} +{"i":"chanar","e":"chẩn","m":"telex"} +{"i":"chanax","e":"chẫn","m":"telex"} +{"i":"chanaw","e":"chăn","m":"telex"} +{"i":"chanaa","e":"chân","m":"telex"} +{"i":"chenee","e":"chên","m":"telex"} +{"i":"chonoo","e":"chôn","m":"telex"} +{"i":"chonow","e":"chơn","m":"telex"} +{"i":"chunuw","e":"chunư","m":"telex"} +{"i":"changaf","e":"chầng","m":"telex"} +{"i":"changas","e":"chấng","m":"telex"} +{"i":"changaj","e":"chậng","m":"telex"} +{"i":"changar","e":"chẩng","m":"telex"} +{"i":"changax","e":"chẫng","m":"telex"} +{"i":"changaw","e":"chăng","m":"telex"} +{"i":"changaa","e":"châng","m":"telex"} +{"i":"chengee","e":"chêng","m":"telex"} +{"i":"chongoo","e":"chông","m":"telex"} +{"i":"chongow","e":"chơng","m":"telex"} +{"i":"chunguw","e":"chungư","m":"telex"} +{"i":"chanhaf","e":"chầnh","m":"telex"} +{"i":"chanhas","e":"chấnh","m":"telex"} +{"i":"chanhaj","e":"chậnh","m":"telex"} +{"i":"chanhar","e":"chẩnh","m":"telex"} +{"i":"chanhax","e":"chẫnh","m":"telex"} +{"i":"chanhaw","e":"chănh","m":"telex"} +{"i":"chanhaa","e":"chânh","m":"telex"} +{"i":"chenhee","e":"chênh","m":"telex"} +{"i":"chonhoo","e":"chônh","m":"telex"} +{"i":"chonhow","e":"chơnh","m":"telex"} +{"i":"chunhuw","e":"chunhư","m":"telex"} +{"i":"chapaf","e":"chầp","m":"telex"} +{"i":"chapas","e":"chấp","m":"telex"} +{"i":"chapaj","e":"chập","m":"telex"} +{"i":"chapar","e":"chẩp","m":"telex"} +{"i":"chapax","e":"chẫp","m":"telex"} +{"i":"chapaw","e":"chăp","m":"telex"} +{"i":"chapaa","e":"châp","m":"telex"} +{"i":"chepee","e":"chêp","m":"telex"} +{"i":"chopoo","e":"chôp","m":"telex"} +{"i":"chopow","e":"chơp","m":"telex"} +{"i":"chupuw","e":"chupư","m":"telex"} +{"i":"chataf","e":"chầt","m":"telex"} +{"i":"chatas","e":"chất","m":"telex"} +{"i":"chataj","e":"chật","m":"telex"} +{"i":"chatar","e":"chẩt","m":"telex"} +{"i":"chatax","e":"chẫt","m":"telex"} +{"i":"chataw","e":"chăt","m":"telex"} +{"i":"chataa","e":"chât","m":"telex"} +{"i":"chetee","e":"chêt","m":"telex"} +{"i":"chotoo","e":"chôt","m":"telex"} +{"i":"chotow","e":"chơt","m":"telex"} +{"i":"chutuw","e":"chutư","m":"telex"} +{"i":"daaf","e":"dầ","m":"telex"} +{"i":"daas","e":"dấ","m":"telex"} +{"i":"daaj","e":"dậ","m":"telex"} +{"i":"daar","e":"dẩ","m":"telex"} +{"i":"daax","e":"dẫ","m":"telex"} +{"i":"daaw","e":"dă","m":"telex"} +{"i":"daaa","e":"dâ","m":"telex"} +{"i":"deee","e":"dê","m":"telex"} +{"i":"dooo","e":"dô","m":"telex"} +{"i":"doow","e":"dơ","m":"telex"} +{"i":"duuw","e":"duư","m":"telex"} +{"i":"dacaf","e":"dầc","m":"telex"} +{"i":"dacas","e":"dấc","m":"telex"} +{"i":"dacaj","e":"dậc","m":"telex"} +{"i":"dacar","e":"dẩc","m":"telex"} +{"i":"dacax","e":"dẫc","m":"telex"} +{"i":"dacaw","e":"dăc","m":"telex"} +{"i":"dacaa","e":"dâc","m":"telex"} +{"i":"decee","e":"dêc","m":"telex"} +{"i":"docoo","e":"dôc","m":"telex"} +{"i":"docow","e":"dơc","m":"telex"} +{"i":"ducuw","e":"ducư","m":"telex"} +{"i":"dachaf","e":"dầch","m":"telex"} +{"i":"dachas","e":"dấch","m":"telex"} +{"i":"dachaj","e":"dậch","m":"telex"} +{"i":"dachar","e":"dẩch","m":"telex"} +{"i":"dachax","e":"dẫch","m":"telex"} +{"i":"dachaw","e":"dăch","m":"telex"} +{"i":"dachaa","e":"dâch","m":"telex"} +{"i":"dechee","e":"dêch","m":"telex"} +{"i":"dochoo","e":"dôch","m":"telex"} +{"i":"dochow","e":"dơch","m":"telex"} +{"i":"duchuw","e":"duchư","m":"telex"} +{"i":"damaf","e":"dầm","m":"telex"} +{"i":"damas","e":"dấm","m":"telex"} +{"i":"damaj","e":"dậm","m":"telex"} +{"i":"damar","e":"dẩm","m":"telex"} +{"i":"damax","e":"dẫm","m":"telex"} +{"i":"damaw","e":"dăm","m":"telex"} +{"i":"damaa","e":"dâm","m":"telex"} +{"i":"demee","e":"dêm","m":"telex"} +{"i":"domoo","e":"dôm","m":"telex"} +{"i":"domow","e":"dơm","m":"telex"} +{"i":"dumuw","e":"dumư","m":"telex"} +{"i":"danaf","e":"dần","m":"telex"} +{"i":"danas","e":"dấn","m":"telex"} +{"i":"danaj","e":"dận","m":"telex"} +{"i":"danar","e":"dẩn","m":"telex"} +{"i":"danax","e":"dẫn","m":"telex"} +{"i":"danaw","e":"dăn","m":"telex"} +{"i":"danaa","e":"dân","m":"telex"} +{"i":"denee","e":"dên","m":"telex"} +{"i":"donoo","e":"dôn","m":"telex"} +{"i":"donow","e":"dơn","m":"telex"} +{"i":"dunuw","e":"dunư","m":"telex"} +{"i":"dangaf","e":"dầng","m":"telex"} +{"i":"dangas","e":"dấng","m":"telex"} +{"i":"dangaj","e":"dậng","m":"telex"} +{"i":"dangar","e":"dẩng","m":"telex"} +{"i":"dangax","e":"dẫng","m":"telex"} +{"i":"dangaw","e":"dăng","m":"telex"} +{"i":"dangaa","e":"dâng","m":"telex"} +{"i":"dengee","e":"dêng","m":"telex"} +{"i":"dongoo","e":"dông","m":"telex"} +{"i":"dongow","e":"dơng","m":"telex"} +{"i":"dunguw","e":"dungư","m":"telex"} +{"i":"danhaf","e":"dầnh","m":"telex"} +{"i":"danhas","e":"dấnh","m":"telex"} +{"i":"danhaj","e":"dậnh","m":"telex"} +{"i":"danhar","e":"dẩnh","m":"telex"} +{"i":"danhax","e":"dẫnh","m":"telex"} +{"i":"danhaw","e":"dănh","m":"telex"} +{"i":"danhaa","e":"dânh","m":"telex"} +{"i":"denhee","e":"dênh","m":"telex"} +{"i":"donhoo","e":"dônh","m":"telex"} +{"i":"donhow","e":"dơnh","m":"telex"} +{"i":"dunhuw","e":"dunhư","m":"telex"} +{"i":"dapaf","e":"dầp","m":"telex"} +{"i":"dapas","e":"dấp","m":"telex"} +{"i":"dapaj","e":"dập","m":"telex"} +{"i":"dapar","e":"dẩp","m":"telex"} +{"i":"dapax","e":"dẫp","m":"telex"} +{"i":"dapaw","e":"dăp","m":"telex"} +{"i":"dapaa","e":"dâp","m":"telex"} +{"i":"depee","e":"dêp","m":"telex"} +{"i":"dopoo","e":"dôp","m":"telex"} +{"i":"dopow","e":"dơp","m":"telex"} +{"i":"dupuw","e":"dupư","m":"telex"} +{"i":"dataf","e":"dầt","m":"telex"} +{"i":"datas","e":"dất","m":"telex"} +{"i":"dataj","e":"dật","m":"telex"} +{"i":"datar","e":"dẩt","m":"telex"} +{"i":"datax","e":"dẫt","m":"telex"} +{"i":"dataw","e":"dăt","m":"telex"} +{"i":"dataa","e":"dât","m":"telex"} +{"i":"detee","e":"dêt","m":"telex"} +{"i":"dotoo","e":"dôt","m":"telex"} +{"i":"dotow","e":"dơt","m":"telex"} +{"i":"dutuw","e":"dutư","m":"telex"} +{"i":"gaaf","e":"gầ","m":"telex"} +{"i":"gaas","e":"gấ","m":"telex"} +{"i":"gaaj","e":"gậ","m":"telex"} +{"i":"gaar","e":"gẩ","m":"telex"} +{"i":"gaax","e":"gẫ","m":"telex"} +{"i":"gaaw","e":"gă","m":"telex"} +{"i":"gaaa","e":"gâ","m":"telex"} +{"i":"geee","e":"gê","m":"telex"} +{"i":"gooo","e":"gô","m":"telex"} +{"i":"goow","e":"gơ","m":"telex"} +{"i":"guuw","e":"guư","m":"telex"} +{"i":"ganaf","e":"gần","m":"telex"} +{"i":"ganas","e":"gấn","m":"telex"} +{"i":"ganaj","e":"gận","m":"telex"} +{"i":"ganar","e":"gẩn","m":"telex"} +{"i":"ganax","e":"gẫn","m":"telex"} +{"i":"ganaw","e":"găn","m":"telex"} +{"i":"ganaa","e":"gân","m":"telex"} +{"i":"genee","e":"gên","m":"telex"} +{"i":"gonoo","e":"gôn","m":"telex"} +{"i":"gonow","e":"gơn","m":"telex"} +{"i":"gunuw","e":"gunư","m":"telex"} +{"i":"gangaf","e":"gầng","m":"telex"} +{"i":"gangas","e":"gấng","m":"telex"} +{"i":"gangaj","e":"gậng","m":"telex"} +{"i":"gangar","e":"gẩng","m":"telex"} +{"i":"gangax","e":"gẫng","m":"telex"} +{"i":"gangaw","e":"găng","m":"telex"} +{"i":"gangaa","e":"gâng","m":"telex"} +{"i":"gengee","e":"gêng","m":"telex"} +{"i":"gongoo","e":"gông","m":"telex"} +{"i":"gongow","e":"gơng","m":"telex"} +{"i":"gunguw","e":"gungư","m":"telex"} +{"i":"ghaaf","e":"ghầ","m":"telex"} +{"i":"ghaas","e":"ghấ","m":"telex"} +{"i":"ghaaj","e":"ghậ","m":"telex"} +{"i":"ghaar","e":"ghẩ","m":"telex"} +{"i":"ghaax","e":"ghẫ","m":"telex"} +{"i":"ghaaw","e":"ghă","m":"telex"} +{"i":"ghaaa","e":"ghâ","m":"telex"} +{"i":"gheee","e":"ghê","m":"telex"} +{"i":"ghooo","e":"ghô","m":"telex"} +{"i":"ghoow","e":"ghơ","m":"telex"} +{"i":"ghuuw","e":"ghuư","m":"telex"} +{"i":"haaf","e":"hầ","m":"telex"} +{"i":"haas","e":"hấ","m":"telex"} +{"i":"haaj","e":"hậ","m":"telex"} +{"i":"haar","e":"hẩ","m":"telex"} +{"i":"haax","e":"hẫ","m":"telex"} +{"i":"haaw","e":"hă","m":"telex"} +{"i":"haaa","e":"hâ","m":"telex"} +{"i":"heee","e":"hê","m":"telex"} +{"i":"hooo","e":"hô","m":"telex"} +{"i":"hoow","e":"hơ","m":"telex"} +{"i":"huuw","e":"huư","m":"telex"} +{"i":"hacaf","e":"hầc","m":"telex"} +{"i":"hacas","e":"hấc","m":"telex"} +{"i":"hacaj","e":"hậc","m":"telex"} +{"i":"hacar","e":"hẩc","m":"telex"} +{"i":"hacax","e":"hẫc","m":"telex"} +{"i":"hacaw","e":"hăc","m":"telex"} +{"i":"hacaa","e":"hâc","m":"telex"} +{"i":"hecee","e":"hêc","m":"telex"} +{"i":"hocoo","e":"hôc","m":"telex"} +{"i":"hocow","e":"hơc","m":"telex"} +{"i":"hucuw","e":"hucư","m":"telex"} +{"i":"hachaf","e":"hầch","m":"telex"} +{"i":"hachas","e":"hấch","m":"telex"} +{"i":"hachaj","e":"hậch","m":"telex"} +{"i":"hachar","e":"hẩch","m":"telex"} +{"i":"hachax","e":"hẫch","m":"telex"} +{"i":"hachaw","e":"hăch","m":"telex"} +{"i":"hachaa","e":"hâch","m":"telex"} +{"i":"hechee","e":"hêch","m":"telex"} +{"i":"hochoo","e":"hôch","m":"telex"} +{"i":"hochow","e":"hơch","m":"telex"} +{"i":"huchuw","e":"huchư","m":"telex"} +{"i":"hamaf","e":"hầm","m":"telex"} +{"i":"hamas","e":"hấm","m":"telex"} +{"i":"hamaj","e":"hậm","m":"telex"} +{"i":"hamar","e":"hẩm","m":"telex"} +{"i":"hamax","e":"hẫm","m":"telex"} +{"i":"hamaw","e":"hăm","m":"telex"} +{"i":"hamaa","e":"hâm","m":"telex"} +{"i":"hemee","e":"hêm","m":"telex"} +{"i":"homoo","e":"hôm","m":"telex"} +{"i":"homow","e":"hơm","m":"telex"} +{"i":"humuw","e":"humư","m":"telex"} +{"i":"hanaf","e":"hần","m":"telex"} +{"i":"hanas","e":"hấn","m":"telex"} +{"i":"hanaj","e":"hận","m":"telex"} +{"i":"hanar","e":"hẩn","m":"telex"} +{"i":"hanax","e":"hẫn","m":"telex"} +{"i":"hanaw","e":"hăn","m":"telex"} +{"i":"hanaa","e":"hân","m":"telex"} +{"i":"henee","e":"hên","m":"telex"} +{"i":"honoo","e":"hôn","m":"telex"} +{"i":"honow","e":"hơn","m":"telex"} +{"i":"hunuw","e":"hunư","m":"telex"} +{"i":"hangaf","e":"hầng","m":"telex"} +{"i":"hangas","e":"hấng","m":"telex"} +{"i":"hangaj","e":"hậng","m":"telex"} +{"i":"hangar","e":"hẩng","m":"telex"} +{"i":"hangax","e":"hẫng","m":"telex"} +{"i":"hangaw","e":"hăng","m":"telex"} +{"i":"hangaa","e":"hâng","m":"telex"} +{"i":"hengee","e":"hêng","m":"telex"} +{"i":"hongoo","e":"hông","m":"telex"} +{"i":"hongow","e":"hơng","m":"telex"} +{"i":"hunguw","e":"hungư","m":"telex"} +{"i":"hanhaf","e":"hầnh","m":"telex"} +{"i":"hanhas","e":"hấnh","m":"telex"} +{"i":"hanhaj","e":"hậnh","m":"telex"} +{"i":"hanhar","e":"hẩnh","m":"telex"} +{"i":"hanhax","e":"hẫnh","m":"telex"} +{"i":"hanhaw","e":"hănh","m":"telex"} +{"i":"hanhaa","e":"hânh","m":"telex"} +{"i":"henhee","e":"hênh","m":"telex"} +{"i":"honhoo","e":"hônh","m":"telex"} +{"i":"honhow","e":"hơnh","m":"telex"} +{"i":"hunhuw","e":"hunhư","m":"telex"} +{"i":"hapaf","e":"hầp","m":"telex"} +{"i":"hapas","e":"hấp","m":"telex"} +{"i":"hapaj","e":"hập","m":"telex"} +{"i":"hapar","e":"hẩp","m":"telex"} +{"i":"hapax","e":"hẫp","m":"telex"} +{"i":"hapaw","e":"hăp","m":"telex"} +{"i":"hapaa","e":"hâp","m":"telex"} +{"i":"hepee","e":"hêp","m":"telex"} +{"i":"hopoo","e":"hôp","m":"telex"} +{"i":"hopow","e":"hơp","m":"telex"} +{"i":"hupuw","e":"hupư","m":"telex"} +{"i":"hataf","e":"hầt","m":"telex"} +{"i":"hatas","e":"hất","m":"telex"} +{"i":"hataj","e":"hật","m":"telex"} +{"i":"hatar","e":"hẩt","m":"telex"} +{"i":"hatax","e":"hẫt","m":"telex"} +{"i":"hataw","e":"hăt","m":"telex"} +{"i":"hataa","e":"hât","m":"telex"} +{"i":"hetee","e":"hêt","m":"telex"} +{"i":"hotoo","e":"hôt","m":"telex"} +{"i":"hotow","e":"hơt","m":"telex"} +{"i":"hutuw","e":"hutư","m":"telex"} +{"i":"kaaf","e":"kầ","m":"telex"} +{"i":"kaas","e":"kấ","m":"telex"} +{"i":"kaaj","e":"kậ","m":"telex"} +{"i":"kaar","e":"kẩ","m":"telex"} +{"i":"kaax","e":"kẫ","m":"telex"} +{"i":"kaaw","e":"kă","m":"telex"} +{"i":"kaaa","e":"kâ","m":"telex"} +{"i":"keee","e":"kê","m":"telex"} +{"i":"kooo","e":"kô","m":"telex"} +{"i":"koow","e":"kơ","m":"telex"} +{"i":"kuuw","e":"kuư","m":"telex"} +{"i":"kacaf","e":"kầc","m":"telex"} +{"i":"kacas","e":"kấc","m":"telex"} +{"i":"kacaj","e":"kậc","m":"telex"} +{"i":"kacar","e":"kẩc","m":"telex"} +{"i":"kacax","e":"kẫc","m":"telex"} +{"i":"kacaw","e":"kăc","m":"telex"} +{"i":"kacaa","e":"kâc","m":"telex"} +{"i":"kecee","e":"kêc","m":"telex"} +{"i":"kocoo","e":"kôc","m":"telex"} +{"i":"kocow","e":"kơc","m":"telex"} +{"i":"kucuw","e":"kucư","m":"telex"} +{"i":"kachaf","e":"kầch","m":"telex"} +{"i":"kachas","e":"kấch","m":"telex"} +{"i":"kachaj","e":"kậch","m":"telex"} +{"i":"kachar","e":"kẩch","m":"telex"} +{"i":"kachax","e":"kẫch","m":"telex"} +{"i":"kachaw","e":"kăch","m":"telex"} +{"i":"kachaa","e":"kâch","m":"telex"} +{"i":"kechee","e":"kêch","m":"telex"} +{"i":"kochoo","e":"kôch","m":"telex"} +{"i":"kochow","e":"kơch","m":"telex"} +{"i":"kuchuw","e":"kuchư","m":"telex"} +{"i":"kamaf","e":"kầm","m":"telex"} +{"i":"kamas","e":"kấm","m":"telex"} +{"i":"kamaj","e":"kậm","m":"telex"} +{"i":"kamar","e":"kẩm","m":"telex"} +{"i":"kamax","e":"kẫm","m":"telex"} +{"i":"kamaw","e":"kăm","m":"telex"} +{"i":"kamaa","e":"kâm","m":"telex"} +{"i":"kemee","e":"kêm","m":"telex"} +{"i":"komoo","e":"kôm","m":"telex"} +{"i":"komow","e":"kơm","m":"telex"} +{"i":"kumuw","e":"kumư","m":"telex"} +{"i":"kanaf","e":"kần","m":"telex"} +{"i":"kanas","e":"kấn","m":"telex"} +{"i":"kanaj","e":"kận","m":"telex"} +{"i":"kanar","e":"kẩn","m":"telex"} +{"i":"kanax","e":"kẫn","m":"telex"} +{"i":"kanaw","e":"kăn","m":"telex"} +{"i":"kanaa","e":"kân","m":"telex"} +{"i":"kenee","e":"kên","m":"telex"} +{"i":"konoo","e":"kôn","m":"telex"} +{"i":"konow","e":"kơn","m":"telex"} +{"i":"kunuw","e":"kunư","m":"telex"} +{"i":"kangaf","e":"kầng","m":"telex"} +{"i":"kangas","e":"kấng","m":"telex"} +{"i":"kangaj","e":"kậng","m":"telex"} +{"i":"kangar","e":"kẩng","m":"telex"} +{"i":"kangax","e":"kẫng","m":"telex"} +{"i":"kangaw","e":"kăng","m":"telex"} +{"i":"kangaa","e":"kâng","m":"telex"} +{"i":"kengee","e":"kêng","m":"telex"} +{"i":"kongoo","e":"kông","m":"telex"} +{"i":"kongow","e":"kơng","m":"telex"} +{"i":"kunguw","e":"kungư","m":"telex"} +{"i":"kanhaf","e":"kầnh","m":"telex"} +{"i":"kanhas","e":"kấnh","m":"telex"} +{"i":"kanhaj","e":"kậnh","m":"telex"} +{"i":"kanhar","e":"kẩnh","m":"telex"} +{"i":"kanhax","e":"kẫnh","m":"telex"} +{"i":"kanhaw","e":"kănh","m":"telex"} +{"i":"kanhaa","e":"kânh","m":"telex"} +{"i":"kenhee","e":"kênh","m":"telex"} +{"i":"konhoo","e":"kônh","m":"telex"} +{"i":"konhow","e":"kơnh","m":"telex"} +{"i":"kunhuw","e":"kunhư","m":"telex"} +{"i":"kapaf","e":"kầp","m":"telex"} +{"i":"kapas","e":"kấp","m":"telex"} +{"i":"kapaj","e":"kập","m":"telex"} +{"i":"kapar","e":"kẩp","m":"telex"} +{"i":"kapax","e":"kẫp","m":"telex"} +{"i":"kapaw","e":"kăp","m":"telex"} +{"i":"kapaa","e":"kâp","m":"telex"} +{"i":"kepee","e":"kêp","m":"telex"} +{"i":"kopoo","e":"kôp","m":"telex"} +{"i":"kopow","e":"kơp","m":"telex"} +{"i":"kupuw","e":"kupư","m":"telex"} +{"i":"kataf","e":"kầt","m":"telex"} +{"i":"katas","e":"kất","m":"telex"} +{"i":"kataj","e":"kật","m":"telex"} +{"i":"katar","e":"kẩt","m":"telex"} +{"i":"katax","e":"kẫt","m":"telex"} +{"i":"kataw","e":"kăt","m":"telex"} +{"i":"kataa","e":"kât","m":"telex"} +{"i":"ketee","e":"kêt","m":"telex"} +{"i":"kotoo","e":"kôt","m":"telex"} +{"i":"kotow","e":"kơt","m":"telex"} +{"i":"kutuw","e":"kutư","m":"telex"} +{"i":"khaaf","e":"khầ","m":"telex"} +{"i":"khaas","e":"khấ","m":"telex"} +{"i":"khaaj","e":"khậ","m":"telex"} +{"i":"khaar","e":"khẩ","m":"telex"} +{"i":"khaax","e":"khẫ","m":"telex"} +{"i":"khaaw","e":"khă","m":"telex"} +{"i":"khaaa","e":"khâ","m":"telex"} +{"i":"kheee","e":"khê","m":"telex"} +{"i":"khooo","e":"khô","m":"telex"} +{"i":"khoow","e":"khơ","m":"telex"} +{"i":"khuuw","e":"khuư","m":"telex"} +{"i":"khacaf","e":"khầc","m":"telex"} +{"i":"khacas","e":"khấc","m":"telex"} +{"i":"khacaj","e":"khậc","m":"telex"} +{"i":"khacar","e":"khẩc","m":"telex"} +{"i":"khacax","e":"khẫc","m":"telex"} +{"i":"khacaw","e":"khăc","m":"telex"} +{"i":"khacaa","e":"khâc","m":"telex"} +{"i":"khecee","e":"khêc","m":"telex"} +{"i":"khocoo","e":"khôc","m":"telex"} +{"i":"khocow","e":"khơc","m":"telex"} +{"i":"khucuw","e":"khucư","m":"telex"} +{"i":"khachaf","e":"khầch","m":"telex"} +{"i":"khachas","e":"khấch","m":"telex"} +{"i":"khachaj","e":"khậch","m":"telex"} +{"i":"khachar","e":"khẩch","m":"telex"} +{"i":"khachax","e":"khẫch","m":"telex"} +{"i":"khachaw","e":"khăch","m":"telex"} +{"i":"khachaa","e":"khâch","m":"telex"} +{"i":"khechee","e":"khêch","m":"telex"} +{"i":"khochoo","e":"khôch","m":"telex"} +{"i":"khochow","e":"khơch","m":"telex"} +{"i":"khuchuw","e":"khuchư","m":"telex"} +{"i":"khamaf","e":"khầm","m":"telex"} +{"i":"khamas","e":"khấm","m":"telex"} +{"i":"khamaj","e":"khậm","m":"telex"} +{"i":"khamar","e":"khẩm","m":"telex"} +{"i":"khamax","e":"khẫm","m":"telex"} +{"i":"khamaw","e":"khăm","m":"telex"} +{"i":"khamaa","e":"khâm","m":"telex"} +{"i":"khemee","e":"khêm","m":"telex"} +{"i":"khomoo","e":"khôm","m":"telex"} +{"i":"khomow","e":"khơm","m":"telex"} +{"i":"khumuw","e":"khumư","m":"telex"} +{"i":"khanaf","e":"khần","m":"telex"} +{"i":"khanas","e":"khấn","m":"telex"} +{"i":"khanaj","e":"khận","m":"telex"} +{"i":"khanar","e":"khẩn","m":"telex"} +{"i":"khanax","e":"khẫn","m":"telex"} +{"i":"khanaw","e":"khăn","m":"telex"} +{"i":"khanaa","e":"khân","m":"telex"} +{"i":"khenee","e":"khên","m":"telex"} +{"i":"khonoo","e":"khôn","m":"telex"} +{"i":"khonow","e":"khơn","m":"telex"} +{"i":"khunuw","e":"khunư","m":"telex"} +{"i":"khangaf","e":"khầng","m":"telex"} +{"i":"khangas","e":"khấng","m":"telex"} +{"i":"khangaj","e":"khậng","m":"telex"} +{"i":"khangar","e":"khẩng","m":"telex"} +{"i":"khangax","e":"khẫng","m":"telex"} +{"i":"khangaw","e":"khăng","m":"telex"} +{"i":"khangaa","e":"khâng","m":"telex"} +{"i":"khengee","e":"khêng","m":"telex"} +{"i":"khongoo","e":"không","m":"telex"} +{"i":"khongow","e":"khơng","m":"telex"} +{"i":"khunguw","e":"khungư","m":"telex"} +{"i":"khanhaf","e":"khầnh","m":"telex"} +{"i":"khanhas","e":"khấnh","m":"telex"} +{"i":"khanhaj","e":"khậnh","m":"telex"} +{"i":"khanhar","e":"khẩnh","m":"telex"} +{"i":"khanhax","e":"khẫnh","m":"telex"} +{"i":"khanhaw","e":"khănh","m":"telex"} +{"i":"khanhaa","e":"khânh","m":"telex"} +{"i":"khenhee","e":"khênh","m":"telex"} +{"i":"khonhoo","e":"khônh","m":"telex"} +{"i":"khonhow","e":"khơnh","m":"telex"} +{"i":"khunhuw","e":"khunhư","m":"telex"} +{"i":"khapaf","e":"khầp","m":"telex"} +{"i":"khapas","e":"khấp","m":"telex"} +{"i":"khapaj","e":"khập","m":"telex"} +{"i":"khapar","e":"khẩp","m":"telex"} +{"i":"khapax","e":"khẫp","m":"telex"} +{"i":"khapaw","e":"khăp","m":"telex"} +{"i":"khapaa","e":"khâp","m":"telex"} +{"i":"khepee","e":"khêp","m":"telex"} +{"i":"khopoo","e":"khôp","m":"telex"} +{"i":"khopow","e":"khơp","m":"telex"} +{"i":"khupuw","e":"khupư","m":"telex"} +{"i":"khataf","e":"khầt","m":"telex"} +{"i":"khatas","e":"khất","m":"telex"} +{"i":"khataj","e":"khật","m":"telex"} +{"i":"khatar","e":"khẩt","m":"telex"} +{"i":"khatax","e":"khẫt","m":"telex"} +{"i":"khataw","e":"khăt","m":"telex"} +{"i":"khataa","e":"khât","m":"telex"} +{"i":"khetee","e":"khêt","m":"telex"} +{"i":"khotoo","e":"khôt","m":"telex"} +{"i":"khotow","e":"khơt","m":"telex"} +{"i":"khutuw","e":"khutư","m":"telex"} +{"i":"laaf","e":"lầ","m":"telex"} +{"i":"laas","e":"lấ","m":"telex"} +{"i":"laaj","e":"lậ","m":"telex"} +{"i":"laar","e":"lẩ","m":"telex"} +{"i":"laax","e":"lẫ","m":"telex"} +{"i":"laaw","e":"lă","m":"telex"} +{"i":"laaa","e":"lâ","m":"telex"} +{"i":"leee","e":"lê","m":"telex"} +{"i":"looo","e":"lô","m":"telex"} +{"i":"loow","e":"lơ","m":"telex"} +{"i":"luuw","e":"luư","m":"telex"} +{"i":"lacaf","e":"lầc","m":"telex"} +{"i":"lacas","e":"lấc","m":"telex"} +{"i":"lacaj","e":"lậc","m":"telex"} +{"i":"lacar","e":"lẩc","m":"telex"} +{"i":"lacax","e":"lẫc","m":"telex"} +{"i":"lacaw","e":"lăc","m":"telex"} +{"i":"lacaa","e":"lâc","m":"telex"} +{"i":"lecee","e":"lêc","m":"telex"} +{"i":"locoo","e":"lôc","m":"telex"} +{"i":"locow","e":"lơc","m":"telex"} +{"i":"lucuw","e":"lucư","m":"telex"} +{"i":"lachaf","e":"lầch","m":"telex"} +{"i":"lachas","e":"lấch","m":"telex"} +{"i":"lachaj","e":"lậch","m":"telex"} +{"i":"lachar","e":"lẩch","m":"telex"} +{"i":"lachax","e":"lẫch","m":"telex"} +{"i":"lachaw","e":"lăch","m":"telex"} +{"i":"lachaa","e":"lâch","m":"telex"} +{"i":"lechee","e":"lêch","m":"telex"} +{"i":"lochoo","e":"lôch","m":"telex"} +{"i":"lochow","e":"lơch","m":"telex"} +{"i":"luchuw","e":"luchư","m":"telex"} +{"i":"lamaf","e":"lầm","m":"telex"} +{"i":"lamas","e":"lấm","m":"telex"} +{"i":"lamaj","e":"lậm","m":"telex"} +{"i":"lamar","e":"lẩm","m":"telex"} +{"i":"lamax","e":"lẫm","m":"telex"} +{"i":"lamaw","e":"lăm","m":"telex"} +{"i":"lamaa","e":"lâm","m":"telex"} +{"i":"lemee","e":"lêm","m":"telex"} +{"i":"lomoo","e":"lôm","m":"telex"} +{"i":"lomow","e":"lơm","m":"telex"} +{"i":"lumuw","e":"lumư","m":"telex"} +{"i":"lanaf","e":"lần","m":"telex"} +{"i":"lanas","e":"lấn","m":"telex"} +{"i":"lanaj","e":"lận","m":"telex"} +{"i":"lanar","e":"lẩn","m":"telex"} +{"i":"lanax","e":"lẫn","m":"telex"} +{"i":"lanaw","e":"lăn","m":"telex"} +{"i":"lanaa","e":"lân","m":"telex"} +{"i":"lenee","e":"lên","m":"telex"} +{"i":"lonoo","e":"lôn","m":"telex"} +{"i":"lonow","e":"lơn","m":"telex"} +{"i":"lunuw","e":"lunư","m":"telex"} +{"i":"langaf","e":"lầng","m":"telex"} +{"i":"langas","e":"lấng","m":"telex"} +{"i":"langaj","e":"lậng","m":"telex"} +{"i":"langar","e":"lẩng","m":"telex"} +{"i":"langax","e":"lẫng","m":"telex"} +{"i":"langaw","e":"lăng","m":"telex"} +{"i":"langaa","e":"lâng","m":"telex"} +{"i":"lengee","e":"lêng","m":"telex"} +{"i":"longoo","e":"lông","m":"telex"} +{"i":"longow","e":"lơng","m":"telex"} +{"i":"lunguw","e":"lungư","m":"telex"} +{"i":"lanhaf","e":"lầnh","m":"telex"} +{"i":"lanhas","e":"lấnh","m":"telex"} +{"i":"lanhaj","e":"lậnh","m":"telex"} +{"i":"lanhar","e":"lẩnh","m":"telex"} +{"i":"lanhax","e":"lẫnh","m":"telex"} +{"i":"lanhaw","e":"lănh","m":"telex"} +{"i":"lanhaa","e":"lânh","m":"telex"} +{"i":"lenhee","e":"lênh","m":"telex"} +{"i":"lonhoo","e":"lônh","m":"telex"} +{"i":"lonhow","e":"lơnh","m":"telex"} +{"i":"lunhuw","e":"lunhư","m":"telex"} +{"i":"lapaf","e":"lầp","m":"telex"} +{"i":"lapas","e":"lấp","m":"telex"} +{"i":"lapaj","e":"lập","m":"telex"} +{"i":"lapar","e":"lẩp","m":"telex"} +{"i":"lapax","e":"lẫp","m":"telex"} +{"i":"lapaw","e":"lăp","m":"telex"} +{"i":"lapaa","e":"lâp","m":"telex"} +{"i":"lepee","e":"lêp","m":"telex"} +{"i":"lopoo","e":"lôp","m":"telex"} +{"i":"lopow","e":"lơp","m":"telex"} +{"i":"lupuw","e":"lupư","m":"telex"} +{"i":"lataf","e":"lầt","m":"telex"} +{"i":"latas","e":"lất","m":"telex"} +{"i":"lataj","e":"lật","m":"telex"} +{"i":"latar","e":"lẩt","m":"telex"} +{"i":"latax","e":"lẫt","m":"telex"} +{"i":"lataw","e":"lăt","m":"telex"} +{"i":"lataa","e":"lât","m":"telex"} +{"i":"letee","e":"lêt","m":"telex"} +{"i":"lotoo","e":"lôt","m":"telex"} +{"i":"lotow","e":"lơt","m":"telex"} +{"i":"lutuw","e":"lutư","m":"telex"} +{"i":"maaf","e":"mầ","m":"telex"} +{"i":"maas","e":"mấ","m":"telex"} +{"i":"maaj","e":"mậ","m":"telex"} +{"i":"maar","e":"mẩ","m":"telex"} +{"i":"maax","e":"mẫ","m":"telex"} +{"i":"maaw","e":"mă","m":"telex"} +{"i":"maaa","e":"mâ","m":"telex"} +{"i":"meee","e":"mê","m":"telex"} +{"i":"mooo","e":"mô","m":"telex"} +{"i":"moow","e":"mơ","m":"telex"} +{"i":"muuw","e":"muư","m":"telex"} +{"i":"macaf","e":"mầc","m":"telex"} +{"i":"macas","e":"mấc","m":"telex"} +{"i":"macaj","e":"mậc","m":"telex"} +{"i":"macar","e":"mẩc","m":"telex"} +{"i":"macax","e":"mẫc","m":"telex"} +{"i":"macaw","e":"măc","m":"telex"} +{"i":"macaa","e":"mâc","m":"telex"} +{"i":"mecee","e":"mêc","m":"telex"} +{"i":"mocoo","e":"môc","m":"telex"} +{"i":"mocow","e":"mơc","m":"telex"} +{"i":"mucuw","e":"mucư","m":"telex"} +{"i":"machaf","e":"mầch","m":"telex"} +{"i":"machas","e":"mấch","m":"telex"} +{"i":"machaj","e":"mậch","m":"telex"} +{"i":"machar","e":"mẩch","m":"telex"} +{"i":"machax","e":"mẫch","m":"telex"} +{"i":"machaw","e":"măch","m":"telex"} +{"i":"machaa","e":"mâch","m":"telex"} +{"i":"mechee","e":"mêch","m":"telex"} +{"i":"mochoo","e":"môch","m":"telex"} +{"i":"mochow","e":"mơch","m":"telex"} +{"i":"muchuw","e":"muchư","m":"telex"} +{"i":"mamaf","e":"mầm","m":"telex"} +{"i":"mamas","e":"mấm","m":"telex"} +{"i":"mamaj","e":"mậm","m":"telex"} +{"i":"mamar","e":"mẩm","m":"telex"} +{"i":"mamax","e":"mẫm","m":"telex"} +{"i":"mamaw","e":"măm","m":"telex"} +{"i":"mamaa","e":"mâm","m":"telex"} +{"i":"memee","e":"mêm","m":"telex"} +{"i":"momoo","e":"môm","m":"telex"} +{"i":"momow","e":"mơm","m":"telex"} +{"i":"mumuw","e":"mumư","m":"telex"} +{"i":"manaf","e":"mần","m":"telex"} +{"i":"manas","e":"mấn","m":"telex"} +{"i":"manaj","e":"mận","m":"telex"} +{"i":"manar","e":"mẩn","m":"telex"} +{"i":"manax","e":"mẫn","m":"telex"} +{"i":"manaw","e":"măn","m":"telex"} +{"i":"manaa","e":"mân","m":"telex"} +{"i":"menee","e":"mên","m":"telex"} +{"i":"monoo","e":"môn","m":"telex"} +{"i":"monow","e":"mơn","m":"telex"} +{"i":"munuw","e":"munư","m":"telex"} +{"i":"mangaf","e":"mầng","m":"telex"} +{"i":"mangas","e":"mấng","m":"telex"} +{"i":"mangaj","e":"mậng","m":"telex"} +{"i":"mangar","e":"mẩng","m":"telex"} +{"i":"mangax","e":"mẫng","m":"telex"} +{"i":"mangaw","e":"măng","m":"telex"} +{"i":"mangaa","e":"mâng","m":"telex"} +{"i":"mengee","e":"mêng","m":"telex"} +{"i":"mongoo","e":"mông","m":"telex"} +{"i":"mongow","e":"mơng","m":"telex"} +{"i":"munguw","e":"mungư","m":"telex"} +{"i":"manhaf","e":"mầnh","m":"telex"} +{"i":"manhas","e":"mấnh","m":"telex"} +{"i":"manhaj","e":"mậnh","m":"telex"} +{"i":"manhar","e":"mẩnh","m":"telex"} +{"i":"manhax","e":"mẫnh","m":"telex"} +{"i":"manhaw","e":"mănh","m":"telex"} +{"i":"manhaa","e":"mânh","m":"telex"} +{"i":"menhee","e":"mênh","m":"telex"} +{"i":"monhoo","e":"mônh","m":"telex"} +{"i":"monhow","e":"mơnh","m":"telex"} +{"i":"munhuw","e":"munhư","m":"telex"} +{"i":"mapaf","e":"mầp","m":"telex"} +{"i":"mapas","e":"mấp","m":"telex"} +{"i":"mapaj","e":"mập","m":"telex"} +{"i":"mapar","e":"mẩp","m":"telex"} +{"i":"mapax","e":"mẫp","m":"telex"} +{"i":"mapaw","e":"măp","m":"telex"} +{"i":"mapaa","e":"mâp","m":"telex"} +{"i":"mepee","e":"mêp","m":"telex"} +{"i":"mopoo","e":"môp","m":"telex"} +{"i":"mopow","e":"mơp","m":"telex"} diff --git a/engine/src/telex.rs b/engine/src/telex.rs index 36e298a..754f63c 100644 --- a/engine/src/telex.rs +++ b/engine/src/telex.rs @@ -1,6 +1,24 @@ use crate::engine::EngineEvent; -const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư']; +const VOWELS: &[char] = &[ + 'a', 'e', 'i', 'o', 'u', 'y', + 'ă', 'â', 'ê', 'ô', 'ơ', 'ư', +]; + +const VOWEL_ACCENTED: &[char] = &[ + 'a', 'á', 'à', 'ả', 'ã', 'ạ', + 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', + 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', + 'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', + 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', + 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', + 'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ', + 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', + 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', + 'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ', + 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', + 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', +]; /// Maximum number of characters to scan backward during flexible placement. /// Vietnamese vowel clusters are at most 3 characters; limiting the scan @@ -9,7 +27,39 @@ const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', const MAX_FLEXIBLE_BACKTRACK: usize = 3; fn is_vowel(c: char) -> bool { - VOWELS.contains(&c) + VOWEL_ACCENTED.contains(&c) +} + +/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_char_or_none) +/// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ'). +fn strip_tone(c: char) -> (char, Option) { + match c { + 'a' => ('a', None), 'á' => ('a', Some('s')), 'à' => ('a', Some('f')), + 'ả' => ('a', Some('r')), 'ã' => ('a', Some('x')), 'ạ' => ('a', Some('j')), + 'ă' => ('ă', None), 'ắ' => ('ă', Some('s')), 'ằ' => ('ă', Some('f')), + 'ẳ' => ('ă', Some('r')), 'ẵ' => ('ă', Some('x')), 'ặ' => ('ă', Some('j')), + 'â' => ('â', None), 'ấ' => ('â', Some('s')), 'ầ' => ('â', Some('f')), + 'ẩ' => ('â', Some('r')), 'ẫ' => ('â', Some('x')), 'ậ' => ('â', Some('j')), + 'e' => ('e', None), 'é' => ('e', Some('s')), 'è' => ('e', Some('f')), + 'ẻ' => ('e', Some('r')), 'ẽ' => ('e', Some('x')), 'ẹ' => ('e', Some('j')), + 'ê' => ('ê', None), 'ế' => ('ê', Some('s')), 'ề' => ('ê', Some('f')), + 'ể' => ('ê', Some('r')), 'ễ' => ('ê', Some('x')), 'ệ' => ('ê', Some('j')), + 'i' => ('i', None), 'í' => ('i', Some('s')), 'ì' => ('i', Some('f')), + 'ỉ' => ('i', Some('r')), 'ĩ' => ('i', Some('x')), 'ị' => ('i', Some('j')), + 'o' => ('o', None), 'ó' => ('o', Some('s')), 'ò' => ('o', Some('f')), + 'ỏ' => ('o', Some('r')), 'õ' => ('o', Some('x')), 'ọ' => ('o', Some('j')), + 'ô' => ('ô', None), 'ố' => ('ô', Some('s')), 'ồ' => ('ô', Some('f')), + 'ổ' => ('ô', Some('r')), 'ỗ' => ('ô', Some('x')), 'ộ' => ('ô', Some('j')), + 'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('s')), 'ờ' => ('ơ', Some('f')), + 'ở' => ('ơ', Some('r')), 'ỡ' => ('ơ', Some('x')), 'ợ' => ('ơ', Some('j')), + 'u' => ('u', None), 'ú' => ('u', Some('s')), 'ù' => ('u', Some('f')), + 'ủ' => ('u', Some('r')), 'ũ' => ('u', Some('x')), 'ụ' => ('u', Some('j')), + 'ư' => ('ư', None), 'ứ' => ('ư', Some('s')), 'ừ' => ('ư', Some('f')), + 'ử' => ('ư', Some('r')), 'ữ' => ('ư', Some('x')), 'ự' => ('ư', Some('j')), + 'y' => ('y', None), 'ý' => ('y', Some('s')), 'ỳ' => ('y', Some('f')), + 'ỷ' => ('y', Some('r')), 'ỹ' => ('y', Some('x')), 'ỵ' => ('y', Some('j')), + _ => (c, None), + } } fn apply_tone_to_vowel(vowel: char, tone: char) -> Option { @@ -34,9 +84,38 @@ fn apply_tone_to_vowel(vowel: char, tone: char) -> Option { return Some(result); } } + + // Tone overriding: vowel already has a tone → strip it and apply the new one + let (base, _) = strip_tone(vowel); + if base != vowel { + for &(v, t, result) in table { + if v == base && t == tone { + return Some(result); + } + } + } + None } +/// Override the shape modifier on a vowel with a different one. +/// Preserves any existing tone. +/// Telex mappings: â↔ă via w/a, ô↔ơ via w/o +fn override_telex_modifier(vowel: char, key: char) -> Option { + let (base, tone) = strip_tone(vowel); + let new_base = match (base, key) { + ('â', 'w') => Some('ă'), + ('ă', 'a') => Some('â'), + ('ô', 'w') => Some('ơ'), + ('ơ', 'o') => Some('ô'), + _ => None, + }?; + match tone { + None => Some(new_base), + Some(t) => apply_tone_to_vowel(new_base, t), + } +} + fn apply_w_to_vowel(vowel: char) -> Option { // Telex: aw=ă, ow=ơ, ew=ê, uw=ư @@ -50,6 +129,58 @@ fn apply_w_to_vowel(vowel: char) -> Option { } } +// Smart cluster helpers: detect "uo" → "ươ" and transfer tones + +fn is_u_vowel(c: char) -> bool { + matches!(c, 'u' | 'ú' | 'ù' | 'ủ' | 'ũ' | 'ụ') +} + +fn is_o_vowel(c: char) -> bool { + matches!(c, 'o' | 'ó' | 'ò' | 'ỏ' | 'õ' | 'ọ') +} + +/// Determine the tone character (Telex) from a toned vowel. +/// 'u' variants → Some('tone_char'), plain vowels → None. +fn tone_of_vowel(c: char) -> Option { + match c { + 'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None, + 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('f'), + 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('s'), + 'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('r'), + 'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('x'), + 'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('j'), + _ => None, + } +} + +/// Apply a Telex tone to the vowel 'ơ', returning the toned variant. +fn apply_tone_to_ơ_char(tone: Option) -> char { + match tone { + None => 'ơ', + Some('f') => 'ờ', + Some('s') => 'ớ', + Some('r') => 'ở', + Some('x') => 'ỡ', + Some('j') => 'ợ', + _ => 'ơ', + } +} + +/// Convert a "uo" cluster (with possible tones) into "ươ" with correct tone placement. +/// The tone ends up on 'ơ' (second vowel of ươ) regardless of which vowel carried it. +fn uo_to_uơ(u_char: char, o_char: char) -> (char, char) { + let o_tone = tone_of_vowel(o_char); + let u_tone = tone_of_vowel(u_char); + let tone = o_tone.or(u_tone); + ('ư', apply_tone_to_ơ_char(tone)) +} + +/// Check whether a position `i` (pointing at 'o' in a potential "uo" cluster) is +/// preceded by 'q' (making it a "qu" consonant cluster, not a vowel pair). +fn is_q_before_u(chars: &[char], i: usize) -> bool { + i > 1 && chars[i - 2] == 'q' +} + pub struct TelexEngine { buffer: String, @@ -163,6 +294,7 @@ impl TelexEngine { (first, second), ('o', 'a') | ('o', 'e') | ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y') + | ('ư', 'ơ') | ('i', 'ê') | ('y', 'ê') ); if !tone_on_second { @@ -217,10 +349,40 @@ impl TelexEngine { return None; } } + // Smart cluster reverse: "ươ" + o → "uô" + if ch == 'o' && is_vowel(last_ch) { + let strip = strip_tone(last_ch); + if strip.0 == 'ơ' { + let mut chars: Vec = self.buffer.chars().collect(); + if chars.len() >= 2 && chars[chars.len() - 2] == 'ư' { + let ơ_char = chars.pop().unwrap(); + chars.pop().unwrap(); + let tone = tone_of_vowel(ơ_char); + let ô_char = match tone { + None => 'ô', + Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'), + }; + self.buffer = chars.into_iter().collect::(); + self.buffer.push('u'); + self.buffer.push(ô_char); + return None; + } + } + } + // Modifier override: if last vowel has a different modifier that can + // be replaced by this key (e.g., ă+a→â, ơ+o→ô) + if is_vowel(last_ch) && ch != last_ch { + if let Some(modified) = override_telex_modifier(last_ch, ch) { + self.buffer.pop(); + self.buffer.push(modified); + return None; + } + } } // Flexible placement: if last char is not a vowel, scan the last - // N chars for a matching vowel to form a double-vowel pair. + // N chars for a matching vowel to form a double-vowel pair, or for + // a modified vowel that can be overridden by this key. // Limited backtrack prevents modifying vowels in a different syllable. if matches!(ch, 'a' | 'e' | 'o') { if let Some(last_ch) = self.buffer.chars().last() { @@ -228,19 +390,49 @@ impl TelexEngine { let chars: Vec = self.buffer.chars().collect(); let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK); for i in (start..chars.len()).rev() { - if chars[i] == ch { - let replacement = match ch { - 'a' => 'â', - 'e' => 'ê', - 'o' => 'ô', - _ => unreachable!(), - }; - self.buffer = chars[..i].iter().collect::(); - self.buffer.push(replacement); - for &c in &chars[i + 1..] { - self.buffer.push(c); + if is_vowel(chars[i]) { + if chars[i] == ch { + let replacement = match ch { + 'a' => 'â', + 'e' => 'ê', + 'o' => 'ô', + _ => unreachable!(), + }; + self.buffer = chars[..i].iter().collect::(); + self.buffer.push(replacement); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + // Smart cluster reverse: "ươ" + o → "uô" (flexible) + if ch == 'o' { + let strip = strip_tone(chars[i]); + if strip.0 == 'ơ' && i > 0 && chars[i - 1] == 'ư' { + let ơ_char = chars[i]; + let tone = tone_of_vowel(ơ_char); + let ô_char = match tone { + None => 'ô', + Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'), + }; + self.buffer = chars[..i - 1].iter().collect::(); + self.buffer.push('u'); + self.buffer.push(ô_char); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + } + // Modifier override for flexible path + if let Some(modified) = override_telex_modifier(chars[i], ch) { + self.buffer = chars[..i].iter().collect::(); + self.buffer.push(modified); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; } - return None; } } } @@ -254,13 +446,47 @@ impl TelexEngine { fn process_w(&mut self) -> Option { self.apply_pending_to_last_vowel(); + // Direct: last char is a vowel if let Some(last_ch) = self.buffer.chars().last() { + if is_o_vowel(last_ch) { + // Smart cluster "uo" → "ươ" + let mut chars: Vec = self.buffer.chars().collect(); + if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + let o_char = chars.pop().unwrap(); + let u_char = chars.pop().unwrap(); + let (new_first, new_second) = uo_to_uơ(u_char, o_char); + self.buffer = chars.into_iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + return None; + } + } if is_vowel(last_ch) { if let Some(modified) = apply_w_to_vowel(last_ch) { self.buffer.pop(); self.buffer.push(modified); return None; } + // Smart cluster override: "uô" + w → "ươ" + let strip = strip_tone(last_ch); + if strip.0 == 'ô' || strip.0 == 'ơ' { + let mut chars: Vec = self.buffer.chars().collect(); + if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + let o_char = chars.pop().unwrap(); + let u_char = chars.pop().unwrap(); + let (new_first, new_second) = uo_to_uơ(u_char, o_char); + self.buffer = chars.into_iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + return None; + } + } + // Modifier override: if vowel already has a different modifier + if let Some(modified) = override_telex_modifier(last_ch, 'w') { + self.buffer.pop(); + self.buffer.push(modified); + return None; + } } } @@ -272,6 +498,17 @@ impl TelexEngine { let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK); for i in (start..chars.len()).rev() { if is_vowel(chars[i]) { + // Smart cluster "uo" → "ươ" (flexible) + if is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { + let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]); + self.buffer = chars[..i - 1].iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } if let Some(modified) = apply_w_to_vowel(chars[i]) { self.buffer = chars[..i].iter().collect::(); self.buffer.push(modified); @@ -280,6 +517,29 @@ impl TelexEngine { } return None; } + // Smart cluster override: "uô" + w → "ươ" (flexible) + if i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { + let strip = strip_tone(chars[i]); + if strip.0 == 'ô' || strip.0 == 'ơ' { + let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]); + self.buffer = chars[..i - 1].iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + } + // Modifier override: vowel already has a different modifier + if let Some(modified) = override_telex_modifier(chars[i], 'w') { + self.buffer = chars[..i].iter().collect::(); + self.buffer.push(modified); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } } } } diff --git a/engine/src/tests.rs b/engine/src/tests.rs index 7a8f4f8..60e3c8e 100644 --- a/engine/src/tests.rs +++ b/engine/src/tests.rs @@ -644,6 +644,52 @@ mod tests { assert_eq!(get_display(&process_input(&mut e, "xungw")), "xưng"); } + // ================================================================ + // Telex: Smart "uo" → "ươ" cluster + // ================================================================ + + #[test] + fn telex_smart_uo_to_uơ_shortcut() { + let mut e = Engine::new(InputMethod::Telex); + // Single w at end converts "uo" → "ươ" through trailing "ng" + assert_eq!(get_display(&process_input(&mut e, "chuongw")), "chương"); + } + + #[test] + fn telex_smart_uo_to_uơ_traditional() { + let mut e = Engine::new(InputMethod::Telex); + // Traditional uw+ow still works + assert_eq!(get_display(&process_input(&mut e, "chuwowng")), "chương"); + } + + #[test] + fn telex_smart_uo_to_uơ_with_tone_after_w() { + let mut e = Engine::new(InputMethod::Telex); + // "chuongws" → w first (cluster→ươ), then s (tone on ơ) + assert_eq!(get_display(&process_input(&mut e, "chuongws")), "chướng"); + } + + #[test] + fn telex_smart_uo_to_uơ_with_tone_before_w() { + let mut e = Engine::new(InputMethod::Telex); + // "chuongsw" → s first (tone on u), then w (cluster→ươ, tone→ơ) + assert_eq!(get_display(&process_input(&mut e, "chuongsw")), "chướng"); + } + + #[test] + fn telex_smart_uo_to_uơ_thuong_after_w() { + let mut e = Engine::new(InputMethod::Telex); + // "thuowngf" → w first (cluster→ươ), then f (huyền on ơ) + assert_eq!(get_display(&process_input(&mut e, "thuowngf")), "thường"); + } + + #[test] + fn telex_smart_uo_to_uơ_thuong_before_w() { + let mut e = Engine::new(InputMethod::Telex); + // "thuongfw" → f first (tone on u), then w (cluster→ươ, tone→ơ) + assert_eq!(get_display(&process_input(&mut e, "thuongfw")), "thường"); + } + // ================================================================ // VNI: Flexible diacritic placement // ================================================================ @@ -697,6 +743,38 @@ mod tests { assert_eq!(get_display(&process_input(&mut e, "tran6")), "trân"); } + // ================================================================ + // VNI: Smart "uo" → "ươ" cluster + // ================================================================ + + #[test] + fn vni_smart_uo_to_uơ_shortcut() { + let mut e = Engine::new(InputMethod::Vni); + // Single 7 at end converts "uo" → "ươ" through trailing "ng" + assert_eq!(get_display(&process_input(&mut e, "chuong7")), "chương"); + } + + #[test] + fn vni_smart_uo_to_uơ_traditional() { + let mut e = Engine::new(InputMethod::Vni); + // Traditional u7+o7 still works + assert_eq!(get_display(&process_input(&mut e, "chu7o7ng")), "chương"); + } + + #[test] + fn vni_smart_uo_to_uơ_with_tone_after_7() { + let mut e = Engine::new(InputMethod::Vni); + // "chuong71" → 7 first (cluster→ươ), then 1 (sắc on ơ) → "chướng" + assert_eq!(get_display(&process_input(&mut e, "chuong71")), "chướng"); + } + + #[test] + fn vni_smart_uo_to_uơ_with_tone_before_7() { + let mut e = Engine::new(InputMethod::Vni); + // "chuong17" → 1 first (tone on o), then 7 (cluster→ươ, tone→ơ) → "chướng" + assert_eq!(get_display(&process_input(&mut e, "chuong17")), "chướng"); + } + // ================================================================ // VNI: Tones // ================================================================ @@ -941,15 +1019,15 @@ mod tests { #[test] fn esc_undo_after_multiple_tones() { let mut e = Engine::new(InputMethod::Telex); - // "as" → á, then "f" has no tone mapping for á, so f is appended - // Buffer becomes "áf", ESC strips diacritics → "af" + // "as" → á, then "f" overrides tone: sắc → huyền → "à" + // ESC strips diacritics → "a" e.process_key('a'); e.process_key('s'); e.process_key('f'); let event = e.process_escape(); match event { Some(EngineEvent::UndoTones { restored, .. }) => { - assert_eq!(restored, "af"); + assert_eq!(restored, "a"); } _ => panic!("Expected UndoTones, got {:?}", event), } @@ -1737,19 +1815,145 @@ mod tests { #[test] fn backspace_count_then_second_tone_replaces_previous() { - // Type "as" → á, then "f" → f goes to 'á': but 'á' is not in VOWELS - // So 'f' is just appended: "áf" + // Type "as" → á, then "f" → f overrides sắc with huyền → "à" let mut e = Engine::new(InputMethod::Telex); let events = process_input(&mut e, "asf"); let replace_events: Vec<_> = events.iter().filter_map(|ev| match ev { EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), _ => None, }).collect(); - // "as" → Replace {2, "á"}, "f" → buffer = "áf" (no vowel change) → no event - assert_eq!(replace_events.len(), 1, "Expected 1 Replace: {:?}", replace_events); + // "as" → Replace {2, "á"}, "f" → Replace {2, "à"} + assert_eq!(replace_events.len(), 2, "Expected 2 Replace: {:?}", replace_events); assert_eq!(replace_events[0].0, 2); assert_eq!(replace_events[0].1, "á"); - assert_eq!(get_display(&events), "áf"); + assert_eq!(replace_events[1].0, 2); + assert_eq!(replace_events[1].1, "à"); + assert_eq!(get_display(&events), "à"); + } + + // ================================================================ + // Smart Modifier Overriding (Diacritic Replacement) + // ================================================================ + + // Category 1: The 'A' Vowel Group (a, â, ă) + + #[test] + fn telex_override_a_aa_then_w() { + let mut e = Engine::new(InputMethod::Telex); + // "traan" → aa makes â → "trân", then w overrides â→ă → "trăn" + assert_eq!(get_display(&process_input(&mut e, "traanw")), "trăn"); + } + + #[test] + fn telex_override_a_aw_then_a() { + let mut e = Engine::new(InputMethod::Telex); + // "tranw" → w modifies a→ă → "trăn", then a overrides ă→â → "trân" + assert_eq!(get_display(&process_input(&mut e, "tranwa")), "trân"); + } + + #[test] + fn vni_override_a_6_then_8() { + let mut e = Engine::new(InputMethod::Vni); + // "tran6" → 6 makes â → "trân", then 8 overrides â→ă → "trăn" + assert_eq!(get_display(&process_input(&mut e, "tran68")), "trăn"); + } + + #[test] + fn vni_override_a_8_then_6() { + let mut e = Engine::new(InputMethod::Vni); + // "tran8" → 8 makes ă → "trăn", then 6 overrides ă→â → "trân" + assert_eq!(get_display(&process_input(&mut e, "tran86")), "trân"); + } + + // Category 2: The 'O' Vowel Group (o, ô, ơ) + + #[test] + fn telex_override_o_oo_then_w() { + let mut e = Engine::new(InputMethod::Telex); + // "coon" → oo makes ô → "côn", then w overrides ô→ơ → "cơn" + assert_eq!(get_display(&process_input(&mut e, "coonw")), "cơn"); + } + + #[test] + fn telex_override_o_ow_then_o() { + let mut e = Engine::new(InputMethod::Telex); + // "conw" → w modifies o→ơ → "cơn", then o overrides ơ→ô → "côn" + assert_eq!(get_display(&process_input(&mut e, "conwo")), "côn"); + } + + #[test] + fn vni_override_o_6_then_7() { + let mut e = Engine::new(InputMethod::Vni); + // "con6" → 6 makes ô → "côn", then 7 overrides ô→ơ → "cơn" + assert_eq!(get_display(&process_input(&mut e, "con67")), "cơn"); + } + + #[test] + fn vni_override_o_7_then_6() { + let mut e = Engine::new(InputMethod::Vni); + // "con7" → 7 makes ơ → "cơn", then 6 overrides ơ→ô → "côn" + assert_eq!(get_display(&process_input(&mut e, "con76")), "côn"); + } + + // Category 3: Complex Double Vowels (uo → uô / ươ) + + #[test] + fn telex_override_uo_oo_then_w() { + let mut e = Engine::new(InputMethod::Telex); + // "chuoon" → oo makes ô → "chuôn", then w overrides ô→ơ → "chươn" + assert_eq!(get_display(&process_input(&mut e, "chuoonw")), "chươn"); + } + + #[test] + fn telex_override_uo_ow_then_o() { + let mut e = Engine::new(InputMethod::Telex); + // "chuonw" → w modifies o→ơ → "chươn", then o overrides ơ→ô → "chuôn" + assert_eq!(get_display(&process_input(&mut e, "chuonwo")), "chuôn"); + } + + #[test] + fn vni_override_uo_6_then_7() { + let mut e = Engine::new(InputMethod::Vni); + // "chuon6" → 6 makes ô → "chuôn", then 7 overrides ô→ơ → "chươn" + assert_eq!(get_display(&process_input(&mut e, "chuon67")), "chươn"); + } + + #[test] + fn vni_override_uo_7_then_6() { + let mut e = Engine::new(InputMethod::Vni); + // "chuon7" → 7 makes ơ → "chươn", then 6 overrides ơ→ô → "chuôn" + assert_eq!(get_display(&process_input(&mut e, "chuon76")), "chuôn"); + } + + // Category 4: Modifier Overriding while Preserving Tones + + #[test] + fn telex_override_with_tone_preserved_aa_s_w() { + let mut e = Engine::new(InputMethod::Telex); + // "traans" → aa→â, s→sắc → "trấn", then w overrides â→ă, sắc preserved → "trắn" + assert_eq!(get_display(&process_input(&mut e, "traansw")), "trắn"); + } + + #[test] + fn telex_override_with_tone_preserved_oo_f_w() { + let mut e = Engine::new(InputMethod::Telex); + // "coonsf" → oo→ô, s→sắc then f overrides sắc→huyền → "cồn", then w overrides ô→ơ, huyền preserved → "cờn" + assert_eq!(get_display(&process_input(&mut e, "coonsfw")), "cờn"); + } + + #[test] + fn vni_override_with_tone_preserved_6_1_then_8() { + let mut e = Engine::new(InputMethod::Vni); + // "tran61" → 6→â, 1→sắc → "trấn", then 8 overrides â→ă, sắc preserved → "trắn" + assert_eq!(get_display(&process_input(&mut e, "tran618")), "trắn"); + } + + #[test] + fn vni_override_with_tone_preserved_6_2_then_7() { + let mut e = Engine::new(InputMethod::Vni); + // "con62" → 6→ô, 2→huyền → "cồn", then 7 overrides ô→ơ, huyền preserved → "cờn" + // Note: input is "con62" then "7", but the tone 2 comes first, then modifier 7 + assert_eq!(get_display(&process_input(&mut e, "con627")), "cờn"); } // ================================================================ @@ -1775,4 +1979,127 @@ mod tests { assert_eq!(replace_events[1], 4, "banj→bạn should be 4"); assert_eq!(get_display(&events), "xin chào bạn"); } + + // ================================================================ + // Core Edge Case Test Suite (from specification) + // ================================================================ + + // Standard + #[test] + fn core_test_traafn() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "traafn")), "trần"); + } + #[test] + fn core_test_tranaf() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "tranaf")), "trần"); + } + #[test] + fn core_test_tran62() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "tran62")), "trần"); + } + + // Double vowel / smart cluster + #[test] + fn core_test_chuwowng() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "chuwowng")), "chương"); + } + #[test] + fn core_test_chuongw() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "chuongw")), "chương"); + } + #[test] + fn core_test_chuong7() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "chuong7")), "chương"); + } + + // Shape override + #[test] + fn core_test_traanw() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "traanw")), "trăn"); + } + #[test] + fn core_test_trawa() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "trawa")), "trâ"); + } + #[test] + fn core_test_trawan() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "trawan")), "trân"); + } + #[test] + fn core_test_tran68() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "tran68")), "trăn"); + } + + // Tone override + #[test] + fn core_test_traansf() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "traansf")), "trần"); + } + #[test] + fn core_test_tran612() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "tran612")), "trần"); + } + + // Complex consonant + flexible + #[test] + fn core_test_nghieeng() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "nghieeng")), "nghiêng"); + } + #[test] + fn core_test_nghieengf() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "nghieengf")), "nghiềng"); + } + #[test] + fn core_test_nghiengf() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "nghiengf")), "nghìeng"); + } + #[test] + fn core_test_nghieng62() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "nghieng62")), "nghiềng"); + } + + // Tone placement + #[test] + fn core_test_hoangf() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "hoangf")), "hoàng"); + } + #[test] + fn core_test_thuyr() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "thuyr")), "thuỷ"); + } + #[test] + fn core_test_thuy3() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "thuy3")), "thuỷ"); + } + + // Initial đ (dd) + #[test] + fn core_test_ddang() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "ddang")), "đang"); + } + #[test] + fn core_test_dang9() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "dang9")), "đang"); + } } diff --git a/engine/src/vni.rs b/engine/src/vni.rs index 9ca14b1..711c32f 100644 --- a/engine/src/vni.rs +++ b/engine/src/vni.rs @@ -1,13 +1,62 @@ use crate::engine::EngineEvent; -const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u', 'y', 'ă', 'â', 'ê', 'ô', 'ơ', 'ư']; +const VOWELS: &[char] = &[ + 'a', 'e', 'i', 'o', 'u', 'y', + 'ă', 'â', 'ê', 'ô', 'ơ', 'ư', +]; + +const VOWEL_ACCENTED: &[char] = &[ + 'a', 'á', 'à', 'ả', 'ã', 'ạ', + 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', + 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', + 'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', + 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', + 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', + 'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ', + 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', + 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', + 'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ', + 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', + 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', +]; fn is_vowel(c: char) -> bool { - VOWELS.contains(&c) + VOWEL_ACCENTED.contains(&c) } const MAX_FLEXIBLE_BACKTRACK: usize = 3; +/// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none) +fn strip_tone_vni(c: char) -> (char, Option) { + match c { + 'a' => ('a', None), 'á' => ('a', Some('1')), 'à' => ('a', Some('2')), + 'ả' => ('a', Some('3')), 'ã' => ('a', Some('4')), 'ạ' => ('a', Some('5')), + 'ă' => ('ă', None), 'ắ' => ('ă', Some('1')), 'ằ' => ('ă', Some('2')), + 'ẳ' => ('ă', Some('3')), 'ẵ' => ('ă', Some('4')), 'ặ' => ('ă', Some('5')), + 'â' => ('â', None), 'ấ' => ('â', Some('1')), 'ầ' => ('â', Some('2')), + 'ẩ' => ('â', Some('3')), 'ẫ' => ('â', Some('4')), 'ậ' => ('â', Some('5')), + 'e' => ('e', None), 'é' => ('e', Some('1')), 'è' => ('e', Some('2')), + 'ẻ' => ('e', Some('3')), 'ẽ' => ('e', Some('4')), 'ẹ' => ('e', Some('5')), + 'ê' => ('ê', None), 'ế' => ('ê', Some('1')), 'ề' => ('ê', Some('2')), + 'ể' => ('ê', Some('3')), 'ễ' => ('ê', Some('4')), 'ệ' => ('ê', Some('5')), + 'i' => ('i', None), 'í' => ('i', Some('1')), 'ì' => ('i', Some('2')), + 'ỉ' => ('i', Some('3')), 'ĩ' => ('i', Some('4')), 'ị' => ('i', Some('5')), + 'o' => ('o', None), 'ó' => ('o', Some('1')), 'ò' => ('o', Some('2')), + 'ỏ' => ('o', Some('3')), 'õ' => ('o', Some('4')), 'ọ' => ('o', Some('5')), + 'ô' => ('ô', None), 'ố' => ('ô', Some('1')), 'ồ' => ('ô', Some('2')), + 'ổ' => ('ô', Some('3')), 'ỗ' => ('ô', Some('4')), 'ộ' => ('ô', Some('5')), + 'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('1')), 'ờ' => ('ơ', Some('2')), + 'ở' => ('ơ', Some('3')), 'ỡ' => ('ơ', Some('4')), 'ợ' => ('ơ', Some('5')), + 'u' => ('u', None), 'ú' => ('u', Some('1')), 'ù' => ('u', Some('2')), + 'ủ' => ('u', Some('3')), 'ũ' => ('u', Some('4')), 'ụ' => ('u', Some('5')), + 'ư' => ('ư', None), 'ứ' => ('ư', Some('1')), 'ừ' => ('ư', Some('2')), + 'ử' => ('ư', Some('3')), 'ữ' => ('ư', Some('4')), 'ự' => ('ư', Some('5')), + 'y' => ('y', None), 'ý' => ('y', Some('1')), 'ỳ' => ('y', Some('2')), + 'ỷ' => ('y', Some('3')), 'ỹ' => ('y', Some('4')), 'ỵ' => ('y', Some('5')), + _ => (c, None), + } +} + fn apply_tone_to_vowel(vowel: char, digit: char) -> Option { // VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng let table: &[(char, char, char)] = &[ @@ -30,9 +79,38 @@ fn apply_tone_to_vowel(vowel: char, digit: char) -> Option { return Some(result); } } + + // Tone overriding: vowel already has a tone → strip it and apply the new one + let (base, _) = strip_tone_vni(vowel); + if base != vowel { + for &(v, t, result) in table { + if v == base && t == digit { + return Some(result); + } + } + } + None } +/// Override the shape modifier on a vowel with a different one. +/// Preserves any existing tone. +/// VNI mappings: â↔ă via 6↔8, ô↔ơ via 6↔7 +fn override_vni_modifier(vowel: char, digit: char) -> Option { + let (base, tone) = strip_tone_vni(vowel); + let new_base = match (base, digit) { + ('â', '8') => Some('ă'), + ('ă', '6') => Some('â'), + ('ô', '7') => Some('ơ'), + ('ơ', '6') => Some('ô'), + _ => None, + }?; + match tone { + None => Some(new_base), + Some(t) => apply_tone_to_vowel(new_base, t), + } +} + fn apply_digit_to_vowel(vowel: char, digit: char) -> Option { // VNI: 6=â, 7=ơ+ư, 8=ă+ê, 9=ô, 0=ơ+ư // Standard VNI: a6=â, a8=ă, e6=ê, o6=ô, o7=ơ, u7=ư @@ -56,6 +134,49 @@ fn apply_digit_to_vowel(vowel: char, digit: char) -> Option { } } +fn is_u_vowel(c: char) -> bool { + matches!(c, 'u' | 'ú' | 'ù' | 'ủ' | 'ũ' | 'ụ') +} + +fn is_o_vowel(c: char) -> bool { + matches!(c, 'o' | 'ó' | 'ò' | 'ỏ' | 'õ' | 'ọ') +} + +fn tone_of_vowel_vni(c: char) -> Option { + match c { + 'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None, + 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('2'), + 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('1'), + 'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('3'), + 'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('4'), + 'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('5'), + _ => None, + } +} + +fn apply_tone_to_ơ_vni(tone: Option) -> char { + match tone { + None => 'ơ', + Some('2') => 'ờ', + Some('1') => 'ớ', + Some('3') => 'ở', + Some('4') => 'ỡ', + Some('5') => 'ợ', + _ => 'ơ', + } +} + +fn uo_to_uơ_vni(u_char: char, o_char: char) -> (char, char) { + let o_tone = tone_of_vowel_vni(o_char); + let u_tone = tone_of_vowel_vni(u_char); + let tone = o_tone.or(u_tone); + ('ư', apply_tone_to_ơ_vni(tone)) +} + +fn is_q_before_u(chars: &[char], i: usize) -> bool { + i > 1 && chars[i - 2] == 'q' +} + pub struct VniEngine { buffer: String, pending_modifier: Option, @@ -103,6 +224,26 @@ impl VniEngine { if self.pending_modifier.is_some() { self.apply_pending(); } + // dd → đ digraph + if ch == 'd' { + if let Some(last_ch) = self.buffer.chars().last() { + if last_ch == 'd' { + let chars: Vec = self.buffer.chars().collect(); + if chars.len() == 1 { + self.buffer.pop(); + self.buffer.push('đ'); + return None; + } else if chars.len() >= 2 { + let prev = chars[chars.len() - 2]; + if !is_vowel(prev) { + self.buffer.pop(); + self.buffer.push('đ'); + return None; + } + } + } + } + } self.buffer.push(ch); None } @@ -118,6 +259,19 @@ impl VniEngine { // Find last vowel (standard behavior) if let Some(last_ch) = self.buffer.chars().last() { if is_vowel(last_ch) { + // Smart cluster "uo" → "ươ" (digit '7') + if digit == '7' && is_o_vowel(last_ch) { + let mut chars: Vec = self.buffer.chars().collect(); + if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + let o_char = chars.pop().unwrap(); + let u_char = chars.pop().unwrap(); + let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char); + self.buffer = chars.into_iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + return None; + } + } // Try tone first (1-5) if let Some(modified) = apply_tone_to_vowel(last_ch, digit) { self.buffer.pop(); @@ -131,6 +285,55 @@ impl VniEngine { self.buffer.push(modified); return None; } + + // Smart cluster forward (override): "uô" + 7 → "ươ" + if digit == '7' { + let strip = strip_tone_vni(last_ch); + if strip.0 == 'ô' { + let mut chars: Vec = self.buffer.chars().collect(); + if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + let o_char = chars.pop().unwrap(); + let u_char = chars.pop().unwrap(); + let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char); + self.buffer = chars.into_iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + return None; + } + } + } + // Smart cluster reverse (override): "ươ" + 6 → "uô" + if digit == '6' { + let strip = strip_tone_vni(last_ch); + if strip.0 == 'ơ' { + let mut chars: Vec = self.buffer.chars().collect(); + if chars.len() >= 2 && chars[chars.len() - 2] == 'ư' { + let ơ_char = chars.pop().unwrap(); + chars.pop().unwrap(); + let tone = tone_of_vowel_vni(ơ_char); + let ô_char = match tone { + None => 'ô', + Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'), + }; + self.buffer = chars.into_iter().collect::(); + self.buffer.push('u'); + self.buffer.push(ô_char); + return None; + } + } + } + // VNI digit 9: 'd' → 'đ' + if digit == '9' && last_ch == 'd' { + self.buffer.pop(); + self.buffer.push('đ'); + return None; + } + // Modifier override: vowel already has a different modifier + if let Some(modified) = override_vni_modifier(last_ch, digit) { + self.buffer.pop(); + self.buffer.push(modified); + return None; + } } } @@ -141,6 +344,17 @@ impl VniEngine { let start = chars.len().saturating_sub(MAX_FLEXIBLE_BACKTRACK); for i in (start..chars.len()).rev() { if is_vowel(chars[i]) { + // Smart cluster "uo" → "ươ" (digit '7', flexible) + if digit == '7' && is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { + let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]); + self.buffer = chars[..i - 1].iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } // Try tone first (1-5) if let Some(modified) = apply_tone_to_vowel(chars[i], digit) { self.buffer = chars[..i].iter().collect::(); @@ -159,11 +373,77 @@ impl VniEngine { } return None; } + // Smart cluster forward (override): "uô" + 7 → "ươ" (flexible) + if digit == '7' { + let strip = strip_tone_vni(chars[i]); + if strip.0 == 'ô' && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { + let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]); + self.buffer = chars[..i - 1].iter().collect::(); + self.buffer.push(new_first); + self.buffer.push(new_second); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + } + // Smart cluster reverse (override): "ươ" + 6 → "uô" (flexible) + if digit == '6' { + let strip = strip_tone_vni(chars[i]); + if strip.0 == 'ơ' && i > 0 && chars[i - 1] == 'ư' { + let ơ_char = chars[i]; + let tone = tone_of_vowel_vni(ơ_char); + let ô_char = match tone { + None => 'ô', + Some(t) => apply_tone_to_vowel('ô', t).unwrap_or('ô'), + }; + self.buffer = chars[..i - 1].iter().collect::(); + self.buffer.push('u'); + self.buffer.push(ô_char); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + } + // VNI digit 9: 'd' → 'đ' (flexible) + if digit == '9' && chars[i] == 'd' { + self.buffer = chars[..i].iter().collect::(); + self.buffer.push('đ'); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + // Modifier override: vowel already has a different modifier + if let Some(modified) = override_vni_modifier(chars[i], digit) { + self.buffer = chars[..i].iter().collect::(); + self.buffer.push(modified); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } } } } } + // Digit '9' in flexible context: scan backwards for 'd' → 'đ' + if digit == '9' { + let chars: Vec = self.buffer.chars().collect(); + for i in (0..chars.len()).rev() { + if chars[i] == 'd' { + self.buffer = chars[..i].iter().collect::(); + self.buffer.push('đ'); + for &c in &chars[i + 1..] { + self.buffer.push(c); + } + return None; + } + } + } + // Digit not applicable - just append self.buffer.push(digit); None diff --git a/engine/tests/generated_bulk.rs b/engine/tests/generated_bulk.rs new file mode 100644 index 0000000..65aae66 --- /dev/null +++ b/engine/tests/generated_bulk.rs @@ -0,0 +1,1065 @@ +/// Auto-generated from gen_tests example +/// Generated: 2026-06-24T19:55:36.520158 +/// Total cases: 1000 + +use vietc_engine::{Engine, EngineEvent, InputMethod}; + +fn get_display(events: &[EngineEvent]) -> String { + let mut display = String::new(); + for ev in events { + match ev { + EngineEvent::Flush(text) => { + if !display.ends_with(text) { display.push_str(text); } + } + EngineEvent::Insert(text) => display.push_str(text), + EngineEvent::Replace { backspaces, insert } => { + for _ in 0..*backspaces { display.pop(); } + display.push_str(insert); + } + EngineEvent::AutoRestore(word) => { + for _ in 0..word.len() { display.pop(); } + display.push_str(word); + } + EngineEvent::UndoTones { backspaces, restored } => { + for _ in 0..*backspaces { display.pop(); } + display.push_str(restored); + } + } + } + display +} + +fn process_input(e: &mut Engine, input: &str) -> Vec { + let mut events = Vec::new(); + for ch in input.chars() { + if let Some(ev) = e.process_key(ch) { events.push(ev); } + } + events +} + +const TEST_CASES: &[(&str, &str, &str)] = &[ + ("aaf", "ầ", "telex"), + ("aas", "ấ", "telex"), + ("aaj", "ậ", "telex"), + ("aar", "ẩ", "telex"), + ("aax", "ẫ", "telex"), + ("aaw", "ă", "telex"), + ("aaa", "â", "telex"), + ("eee", "ê", "telex"), + ("ooo", "ô", "telex"), + ("oow", "ơ", "telex"), + ("uuw", "uư", "telex"), + ("acaf", "ầc", "telex"), + ("acas", "ấc", "telex"), + ("acaj", "ậc", "telex"), + ("acar", "ẩc", "telex"), + ("acax", "ẫc", "telex"), + ("acaw", "ăc", "telex"), + ("acaa", "âc", "telex"), + ("ecee", "êc", "telex"), + ("ocoo", "ôc", "telex"), + ("ocow", "ơc", "telex"), + ("ucuw", "ucư", "telex"), + ("amaf", "ầm", "telex"), + ("amas", "ấm", "telex"), + ("amaj", "ậm", "telex"), + ("amar", "ẩm", "telex"), + ("amax", "ẫm", "telex"), + ("amaw", "ăm", "telex"), + ("amaa", "âm", "telex"), + ("emee", "êm", "telex"), + ("omoo", "ôm", "telex"), + ("omow", "ơm", "telex"), + ("umuw", "umư", "telex"), + ("anaf", "ần", "telex"), + ("anas", "ấn", "telex"), + ("anaj", "ận", "telex"), + ("anar", "ẩn", "telex"), + ("anax", "ẫn", "telex"), + ("anaw", "ăn", "telex"), + ("anaa", "ân", "telex"), + ("enee", "ên", "telex"), + ("onoo", "ôn", "telex"), + ("onow", "ơn", "telex"), + ("unuw", "unư", "telex"), + ("angaf", "ầng", "telex"), + ("angas", "ấng", "telex"), + ("angaj", "ậng", "telex"), + ("angar", "ẩng", "telex"), + ("angax", "ẫng", "telex"), + ("angaw", "ăng", "telex"), + ("angaa", "âng", "telex"), + ("engee", "êng", "telex"), + ("ongoo", "ông", "telex"), + ("ongow", "ơng", "telex"), + ("unguw", "ungư", "telex"), + ("apaf", "ầp", "telex"), + ("apas", "ấp", "telex"), + ("apaj", "ập", "telex"), + ("apar", "ẩp", "telex"), + ("apax", "ẫp", "telex"), + ("apaw", "ăp", "telex"), + ("apaa", "âp", "telex"), + ("epee", "êp", "telex"), + ("opoo", "ôp", "telex"), + ("opow", "ơp", "telex"), + ("upuw", "upư", "telex"), + ("ataf", "ầt", "telex"), + ("atas", "ất", "telex"), + ("ataj", "ật", "telex"), + ("atar", "ẩt", "telex"), + ("atax", "ẫt", "telex"), + ("ataw", "ăt", "telex"), + ("ataa", "ât", "telex"), + ("etee", "êt", "telex"), + ("otoo", "ôt", "telex"), + ("otow", "ơt", "telex"), + ("utuw", "utư", "telex"), + ("baaf", "bầ", "telex"), + ("baas", "bấ", "telex"), + ("baaj", "bậ", "telex"), + ("baar", "bẩ", "telex"), + ("baax", "bẫ", "telex"), + ("baaw", "bă", "telex"), + ("baaa", "bâ", "telex"), + ("beee", "bê", "telex"), + ("booo", "bô", "telex"), + ("boow", "bơ", "telex"), + ("buuw", "buư", "telex"), + ("bacaf", "bầc", "telex"), + ("bacas", "bấc", "telex"), + ("bacaj", "bậc", "telex"), + ("bacar", "bẩc", "telex"), + ("bacax", "bẫc", "telex"), + ("bacaw", "băc", "telex"), + ("bacaa", "bâc", "telex"), + ("becee", "bêc", "telex"), + ("bocoo", "bôc", "telex"), + ("bocow", "bơc", "telex"), + ("bucuw", "bucư", "telex"), + ("bachaf", "bầch", "telex"), + ("bachas", "bấch", "telex"), + ("bachaj", "bậch", "telex"), + ("bachar", "bẩch", "telex"), + ("bachax", "bẫch", "telex"), + ("bachaw", "băch", "telex"), + ("bachaa", "bâch", "telex"), + ("bechee", "bêch", "telex"), + ("bochoo", "bôch", "telex"), + ("bochow", "bơch", "telex"), + ("buchuw", "buchư", "telex"), + ("bamaf", "bầm", "telex"), + ("bamas", "bấm", "telex"), + ("bamaj", "bậm", "telex"), + ("bamar", "bẩm", "telex"), + ("bamax", "bẫm", "telex"), + ("bamaw", "băm", "telex"), + ("bamaa", "bâm", "telex"), + ("bemee", "bêm", "telex"), + ("bomoo", "bôm", "telex"), + ("bomow", "bơm", "telex"), + ("bumuw", "bumư", "telex"), + ("banaf", "bần", "telex"), + ("banas", "bấn", "telex"), + ("banaj", "bận", "telex"), + ("banar", "bẩn", "telex"), + ("banax", "bẫn", "telex"), + ("banaw", "băn", "telex"), + ("banaa", "bân", "telex"), + ("benee", "bên", "telex"), + ("bonoo", "bôn", "telex"), + ("bonow", "bơn", "telex"), + ("bunuw", "bunư", "telex"), + ("bangaf", "bầng", "telex"), + ("bangas", "bấng", "telex"), + ("bangaj", "bậng", "telex"), + ("bangar", "bẩng", "telex"), + ("bangax", "bẫng", "telex"), + ("bangaw", "băng", "telex"), + ("bangaa", "bâng", "telex"), + ("bengee", "bêng", "telex"), + ("bongoo", "bông", "telex"), + ("bongow", "bơng", "telex"), + ("bunguw", "bungư", "telex"), + ("banhaf", "bầnh", "telex"), + ("banhas", "bấnh", "telex"), + ("banhaj", "bậnh", "telex"), + ("banhar", "bẩnh", "telex"), + ("banhax", "bẫnh", "telex"), + ("banhaw", "bănh", "telex"), + ("banhaa", "bânh", "telex"), + ("benhee", "bênh", "telex"), + ("bonhoo", "bônh", "telex"), + ("bonhow", "bơnh", "telex"), + ("bunhuw", "bunhư", "telex"), + ("bapaf", "bầp", "telex"), + ("bapas", "bấp", "telex"), + ("bapaj", "bập", "telex"), + ("bapar", "bẩp", "telex"), + ("bapax", "bẫp", "telex"), + ("bapaw", "băp", "telex"), + ("bapaa", "bâp", "telex"), + ("bepee", "bêp", "telex"), + ("bopoo", "bôp", "telex"), + ("bopow", "bơp", "telex"), + ("bupuw", "bupư", "telex"), + ("bataf", "bầt", "telex"), + ("batas", "bất", "telex"), + ("bataj", "bật", "telex"), + ("batar", "bẩt", "telex"), + ("batax", "bẫt", "telex"), + ("bataw", "băt", "telex"), + ("bataa", "bât", "telex"), + ("betee", "bêt", "telex"), + ("botoo", "bôt", "telex"), + ("botow", "bơt", "telex"), + ("butuw", "butư", "telex"), + ("caaf", "cầ", "telex"), + ("caas", "cấ", "telex"), + ("caaj", "cậ", "telex"), + ("caar", "cẩ", "telex"), + ("caax", "cẫ", "telex"), + ("caaw", "că", "telex"), + ("caaa", "câ", "telex"), + ("ceee", "cê", "telex"), + ("cooo", "cô", "telex"), + ("coow", "cơ", "telex"), + ("cuuw", "cuư", "telex"), + ("cacaf", "cầc", "telex"), + ("cacas", "cấc", "telex"), + ("cacaj", "cậc", "telex"), + ("cacar", "cẩc", "telex"), + ("cacax", "cẫc", "telex"), + ("cacaw", "căc", "telex"), + ("cacaa", "câc", "telex"), + ("cecee", "cêc", "telex"), + ("cocoo", "côc", "telex"), + ("cocow", "cơc", "telex"), + ("cucuw", "cucư", "telex"), + ("cachaf", "cầch", "telex"), + ("cachas", "cấch", "telex"), + ("cachaj", "cậch", "telex"), + ("cachar", "cẩch", "telex"), + ("cachax", "cẫch", "telex"), + ("cachaw", "căch", "telex"), + ("cachaa", "câch", "telex"), + ("cechee", "cêch", "telex"), + ("cochoo", "côch", "telex"), + ("cochow", "cơch", "telex"), + ("cuchuw", "cuchư", "telex"), + ("camaf", "cầm", "telex"), + ("camas", "cấm", "telex"), + ("camaj", "cậm", "telex"), + ("camar", "cẩm", "telex"), + ("camax", "cẫm", "telex"), + ("camaw", "căm", "telex"), + ("camaa", "câm", "telex"), + ("cemee", "cêm", "telex"), + ("comoo", "côm", "telex"), + ("comow", "cơm", "telex"), + ("cumuw", "cumư", "telex"), + ("canaf", "cần", "telex"), + ("canas", "cấn", "telex"), + ("canaj", "cận", "telex"), + ("canar", "cẩn", "telex"), + ("canax", "cẫn", "telex"), + ("canaw", "căn", "telex"), + ("canaa", "cân", "telex"), + ("cenee", "cên", "telex"), + ("conoo", "côn", "telex"), + ("conow", "cơn", "telex"), + ("cunuw", "cunư", "telex"), + ("cangaf", "cầng", "telex"), + ("cangas", "cấng", "telex"), + ("cangaj", "cậng", "telex"), + ("cangar", "cẩng", "telex"), + ("cangax", "cẫng", "telex"), + ("cangaw", "căng", "telex"), + ("cangaa", "câng", "telex"), + ("cengee", "cêng", "telex"), + ("congoo", "công", "telex"), + ("congow", "cơng", "telex"), + ("cunguw", "cungư", "telex"), + ("canhaf", "cầnh", "telex"), + ("canhas", "cấnh", "telex"), + ("canhaj", "cậnh", "telex"), + ("canhar", "cẩnh", "telex"), + ("canhax", "cẫnh", "telex"), + ("canhaw", "cănh", "telex"), + ("canhaa", "cânh", "telex"), + ("cenhee", "cênh", "telex"), + ("conhoo", "cônh", "telex"), + ("conhow", "cơnh", "telex"), + ("cunhuw", "cunhư", "telex"), + ("capaf", "cầp", "telex"), + ("capas", "cấp", "telex"), + ("capaj", "cập", "telex"), + ("capar", "cẩp", "telex"), + ("capax", "cẫp", "telex"), + ("capaw", "căp", "telex"), + ("capaa", "câp", "telex"), + ("cepee", "cêp", "telex"), + ("copoo", "côp", "telex"), + ("copow", "cơp", "telex"), + ("cupuw", "cupư", "telex"), + ("cataf", "cầt", "telex"), + ("catas", "cất", "telex"), + ("cataj", "cật", "telex"), + ("catar", "cẩt", "telex"), + ("catax", "cẫt", "telex"), + ("cataw", "căt", "telex"), + ("cataa", "cât", "telex"), + ("cetee", "cêt", "telex"), + ("cotoo", "côt", "telex"), + ("cotow", "cơt", "telex"), + ("cutuw", "cutư", "telex"), + ("chaaf", "chầ", "telex"), + ("chaas", "chấ", "telex"), + ("chaaj", "chậ", "telex"), + ("chaar", "chẩ", "telex"), + ("chaax", "chẫ", "telex"), + ("chaaw", "chă", "telex"), + ("chaaa", "châ", "telex"), + ("cheee", "chê", "telex"), + ("chooo", "chô", "telex"), + ("choow", "chơ", "telex"), + ("chuuw", "chuư", "telex"), + ("chacaf", "chầc", "telex"), + ("chacas", "chấc", "telex"), + ("chacaj", "chậc", "telex"), + ("chacar", "chẩc", "telex"), + ("chacax", "chẫc", "telex"), + ("chacaw", "chăc", "telex"), + ("chacaa", "châc", "telex"), + ("checee", "chêc", "telex"), + ("chocoo", "chôc", "telex"), + ("chocow", "chơc", "telex"), + ("chucuw", "chucư", "telex"), + ("chachaf", "chầch", "telex"), + ("chachas", "chấch", "telex"), + ("chachaj", "chậch", "telex"), + ("chachar", "chẩch", "telex"), + ("chachax", "chẫch", "telex"), + ("chachaw", "chăch", "telex"), + ("chachaa", "châch", "telex"), + ("chechee", "chêch", "telex"), + ("chochoo", "chôch", "telex"), + ("chochow", "chơch", "telex"), + ("chuchuw", "chuchư", "telex"), + ("chamaf", "chầm", "telex"), + ("chamas", "chấm", "telex"), + ("chamaj", "chậm", "telex"), + ("chamar", "chẩm", "telex"), + ("chamax", "chẫm", "telex"), + ("chamaw", "chăm", "telex"), + ("chamaa", "châm", "telex"), + ("chemee", "chêm", "telex"), + ("chomoo", "chôm", "telex"), + ("chomow", "chơm", "telex"), + ("chumuw", "chumư", "telex"), + ("chanaf", "chần", "telex"), + ("chanas", "chấn", "telex"), + ("chanaj", "chận", "telex"), + ("chanar", "chẩn", "telex"), + ("chanax", "chẫn", "telex"), + ("chanaw", "chăn", "telex"), + ("chanaa", "chân", "telex"), + ("chenee", "chên", "telex"), + ("chonoo", "chôn", "telex"), + ("chonow", "chơn", "telex"), + ("chunuw", "chunư", "telex"), + ("changaf", "chầng", "telex"), + ("changas", "chấng", "telex"), + ("changaj", "chậng", "telex"), + ("changar", "chẩng", "telex"), + ("changax", "chẫng", "telex"), + ("changaw", "chăng", "telex"), + ("changaa", "châng", "telex"), + ("chengee", "chêng", "telex"), + ("chongoo", "chông", "telex"), + ("chongow", "chơng", "telex"), + ("chunguw", "chungư", "telex"), + ("chanhaf", "chầnh", "telex"), + ("chanhas", "chấnh", "telex"), + ("chanhaj", "chậnh", "telex"), + ("chanhar", "chẩnh", "telex"), + ("chanhax", "chẫnh", "telex"), + ("chanhaw", "chănh", "telex"), + ("chanhaa", "chânh", "telex"), + ("chenhee", "chênh", "telex"), + ("chonhoo", "chônh", "telex"), + ("chonhow", "chơnh", "telex"), + ("chunhuw", "chunhư", "telex"), + ("chapaf", "chầp", "telex"), + ("chapas", "chấp", "telex"), + ("chapaj", "chập", "telex"), + ("chapar", "chẩp", "telex"), + ("chapax", "chẫp", "telex"), + ("chapaw", "chăp", "telex"), + ("chapaa", "châp", "telex"), + ("chepee", "chêp", "telex"), + ("chopoo", "chôp", "telex"), + ("chopow", "chơp", "telex"), + ("chupuw", "chupư", "telex"), + ("chataf", "chầt", "telex"), + ("chatas", "chất", "telex"), + ("chataj", "chật", "telex"), + ("chatar", "chẩt", "telex"), + ("chatax", "chẫt", "telex"), + ("chataw", "chăt", "telex"), + ("chataa", "chât", "telex"), + ("chetee", "chêt", "telex"), + ("chotoo", "chôt", "telex"), + ("chotow", "chơt", "telex"), + ("chutuw", "chutư", "telex"), + ("daaf", "dầ", "telex"), + ("daas", "dấ", "telex"), + ("daaj", "dậ", "telex"), + ("daar", "dẩ", "telex"), + ("daax", "dẫ", "telex"), + ("daaw", "dă", "telex"), + ("daaa", "dâ", "telex"), + ("deee", "dê", "telex"), + ("dooo", "dô", "telex"), + ("doow", "dơ", "telex"), + ("duuw", "duư", "telex"), + ("dacaf", "dầc", "telex"), + ("dacas", "dấc", "telex"), + ("dacaj", "dậc", "telex"), + ("dacar", "dẩc", "telex"), + ("dacax", "dẫc", "telex"), + ("dacaw", "dăc", "telex"), + ("dacaa", "dâc", "telex"), + ("decee", "dêc", "telex"), + ("docoo", "dôc", "telex"), + ("docow", "dơc", "telex"), + ("ducuw", "ducư", "telex"), + ("dachaf", "dầch", "telex"), + ("dachas", "dấch", "telex"), + ("dachaj", "dậch", "telex"), + ("dachar", "dẩch", "telex"), + ("dachax", "dẫch", "telex"), + ("dachaw", "dăch", "telex"), + ("dachaa", "dâch", "telex"), + ("dechee", "dêch", "telex"), + ("dochoo", "dôch", "telex"), + ("dochow", "dơch", "telex"), + ("duchuw", "duchư", "telex"), + ("damaf", "dầm", "telex"), + ("damas", "dấm", "telex"), + ("damaj", "dậm", "telex"), + ("damar", "dẩm", "telex"), + ("damax", "dẫm", "telex"), + ("damaw", "dăm", "telex"), + ("damaa", "dâm", "telex"), + ("demee", "dêm", "telex"), + ("domoo", "dôm", "telex"), + ("domow", "dơm", "telex"), + ("dumuw", "dumư", "telex"), + ("danaf", "dần", "telex"), + ("danas", "dấn", "telex"), + ("danaj", "dận", "telex"), + ("danar", "dẩn", "telex"), + ("danax", "dẫn", "telex"), + ("danaw", "dăn", "telex"), + ("danaa", "dân", "telex"), + ("denee", "dên", "telex"), + ("donoo", "dôn", "telex"), + ("donow", "dơn", "telex"), + ("dunuw", "dunư", "telex"), + ("dangaf", "dầng", "telex"), + ("dangas", "dấng", "telex"), + ("dangaj", "dậng", "telex"), + ("dangar", "dẩng", "telex"), + ("dangax", "dẫng", "telex"), + ("dangaw", "dăng", "telex"), + ("dangaa", "dâng", "telex"), + ("dengee", "dêng", "telex"), + ("dongoo", "dông", "telex"), + ("dongow", "dơng", "telex"), + ("dunguw", "dungư", "telex"), + ("danhaf", "dầnh", "telex"), + ("danhas", "dấnh", "telex"), + ("danhaj", "dậnh", "telex"), + ("danhar", "dẩnh", "telex"), + ("danhax", "dẫnh", "telex"), + ("danhaw", "dănh", "telex"), + ("danhaa", "dânh", "telex"), + ("denhee", "dênh", "telex"), + ("donhoo", "dônh", "telex"), + ("donhow", "dơnh", "telex"), + ("dunhuw", "dunhư", "telex"), + ("dapaf", "dầp", "telex"), + ("dapas", "dấp", "telex"), + ("dapaj", "dập", "telex"), + ("dapar", "dẩp", "telex"), + ("dapax", "dẫp", "telex"), + ("dapaw", "dăp", "telex"), + ("dapaa", "dâp", "telex"), + ("depee", "dêp", "telex"), + ("dopoo", "dôp", "telex"), + ("dopow", "dơp", "telex"), + ("dupuw", "dupư", "telex"), + ("dataf", "dầt", "telex"), + ("datas", "dất", "telex"), + ("dataj", "dật", "telex"), + ("datar", "dẩt", "telex"), + ("datax", "dẫt", "telex"), + ("dataw", "dăt", "telex"), + ("dataa", "dât", "telex"), + ("detee", "dêt", "telex"), + ("dotoo", "dôt", "telex"), + ("dotow", "dơt", "telex"), + ("dutuw", "dutư", "telex"), + ("gaaf", "gầ", "telex"), + ("gaas", "gấ", "telex"), + ("gaaj", "gậ", "telex"), + ("gaar", "gẩ", "telex"), + ("gaax", "gẫ", "telex"), + ("gaaw", "gă", "telex"), + ("gaaa", "gâ", "telex"), + ("geee", "gê", "telex"), + ("gooo", "gô", "telex"), + ("goow", "gơ", "telex"), + ("guuw", "guư", "telex"), + ("ganaf", "gần", "telex"), + ("ganas", "gấn", "telex"), + ("ganaj", "gận", "telex"), + ("ganar", "gẩn", "telex"), + ("ganax", "gẫn", "telex"), + ("ganaw", "găn", "telex"), + ("ganaa", "gân", "telex"), + ("genee", "gên", "telex"), + ("gonoo", "gôn", "telex"), + ("gonow", "gơn", "telex"), + ("gunuw", "gunư", "telex"), + ("gangaf", "gầng", "telex"), + ("gangas", "gấng", "telex"), + ("gangaj", "gậng", "telex"), + ("gangar", "gẩng", "telex"), + ("gangax", "gẫng", "telex"), + ("gangaw", "găng", "telex"), + ("gangaa", "gâng", "telex"), + ("gengee", "gêng", "telex"), + ("gongoo", "gông", "telex"), + ("gongow", "gơng", "telex"), + ("gunguw", "gungư", "telex"), + ("ghaaf", "ghầ", "telex"), + ("ghaas", "ghấ", "telex"), + ("ghaaj", "ghậ", "telex"), + ("ghaar", "ghẩ", "telex"), + ("ghaax", "ghẫ", "telex"), + ("ghaaw", "ghă", "telex"), + ("ghaaa", "ghâ", "telex"), + ("gheee", "ghê", "telex"), + ("ghooo", "ghô", "telex"), + ("ghoow", "ghơ", "telex"), + ("ghuuw", "ghuư", "telex"), + ("haaf", "hầ", "telex"), + ("haas", "hấ", "telex"), + ("haaj", "hậ", "telex"), + ("haar", "hẩ", "telex"), + ("haax", "hẫ", "telex"), + ("haaw", "hă", "telex"), + ("haaa", "hâ", "telex"), + ("heee", "hê", "telex"), + ("hooo", "hô", "telex"), + ("hoow", "hơ", "telex"), + ("huuw", "huư", "telex"), + ("hacaf", "hầc", "telex"), + ("hacas", "hấc", "telex"), + ("hacaj", "hậc", "telex"), + ("hacar", "hẩc", "telex"), + ("hacax", "hẫc", "telex"), + ("hacaw", "hăc", "telex"), + ("hacaa", "hâc", "telex"), + ("hecee", "hêc", "telex"), + ("hocoo", "hôc", "telex"), + ("hocow", "hơc", "telex"), + ("hucuw", "hucư", "telex"), + ("hachaf", "hầch", "telex"), + ("hachas", "hấch", "telex"), + ("hachaj", "hậch", "telex"), + ("hachar", "hẩch", "telex"), + ("hachax", "hẫch", "telex"), + ("hachaw", "hăch", "telex"), + ("hachaa", "hâch", "telex"), + ("hechee", "hêch", "telex"), + ("hochoo", "hôch", "telex"), + ("hochow", "hơch", "telex"), + ("huchuw", "huchư", "telex"), + ("hamaf", "hầm", "telex"), + ("hamas", "hấm", "telex"), + ("hamaj", "hậm", "telex"), + ("hamar", "hẩm", "telex"), + ("hamax", "hẫm", "telex"), + ("hamaw", "hăm", "telex"), + ("hamaa", "hâm", "telex"), + ("hemee", "hêm", "telex"), + ("homoo", "hôm", "telex"), + ("homow", "hơm", "telex"), + ("humuw", "humư", "telex"), + ("hanaf", "hần", "telex"), + ("hanas", "hấn", "telex"), + ("hanaj", "hận", "telex"), + ("hanar", "hẩn", "telex"), + ("hanax", "hẫn", "telex"), + ("hanaw", "hăn", "telex"), + ("hanaa", "hân", "telex"), + ("henee", "hên", "telex"), + ("honoo", "hôn", "telex"), + ("honow", "hơn", "telex"), + ("hunuw", "hunư", "telex"), + ("hangaf", "hầng", "telex"), + ("hangas", "hấng", "telex"), + ("hangaj", "hậng", "telex"), + ("hangar", "hẩng", "telex"), + ("hangax", "hẫng", "telex"), + ("hangaw", "hăng", "telex"), + ("hangaa", "hâng", "telex"), + ("hengee", "hêng", "telex"), + ("hongoo", "hông", "telex"), + ("hongow", "hơng", "telex"), + ("hunguw", "hungư", "telex"), + ("hanhaf", "hầnh", "telex"), + ("hanhas", "hấnh", "telex"), + ("hanhaj", "hậnh", "telex"), + ("hanhar", "hẩnh", "telex"), + ("hanhax", "hẫnh", "telex"), + ("hanhaw", "hănh", "telex"), + ("hanhaa", "hânh", "telex"), + ("henhee", "hênh", "telex"), + ("honhoo", "hônh", "telex"), + ("honhow", "hơnh", "telex"), + ("hunhuw", "hunhư", "telex"), + ("hapaf", "hầp", "telex"), + ("hapas", "hấp", "telex"), + ("hapaj", "hập", "telex"), + ("hapar", "hẩp", "telex"), + ("hapax", "hẫp", "telex"), + ("hapaw", "hăp", "telex"), + ("hapaa", "hâp", "telex"), + ("hepee", "hêp", "telex"), + ("hopoo", "hôp", "telex"), + ("hopow", "hơp", "telex"), + ("hupuw", "hupư", "telex"), + ("hataf", "hầt", "telex"), + ("hatas", "hất", "telex"), + ("hataj", "hật", "telex"), + ("hatar", "hẩt", "telex"), + ("hatax", "hẫt", "telex"), + ("hataw", "hăt", "telex"), + ("hataa", "hât", "telex"), + ("hetee", "hêt", "telex"), + ("hotoo", "hôt", "telex"), + ("hotow", "hơt", "telex"), + ("hutuw", "hutư", "telex"), + ("kaaf", "kầ", "telex"), + ("kaas", "kấ", "telex"), + ("kaaj", "kậ", "telex"), + ("kaar", "kẩ", "telex"), + ("kaax", "kẫ", "telex"), + ("kaaw", "kă", "telex"), + ("kaaa", "kâ", "telex"), + ("keee", "kê", "telex"), + ("kooo", "kô", "telex"), + ("koow", "kơ", "telex"), + ("kuuw", "kuư", "telex"), + ("kacaf", "kầc", "telex"), + ("kacas", "kấc", "telex"), + ("kacaj", "kậc", "telex"), + ("kacar", "kẩc", "telex"), + ("kacax", "kẫc", "telex"), + ("kacaw", "kăc", "telex"), + ("kacaa", "kâc", "telex"), + ("kecee", "kêc", "telex"), + ("kocoo", "kôc", "telex"), + ("kocow", "kơc", "telex"), + ("kucuw", "kucư", "telex"), + ("kachaf", "kầch", "telex"), + ("kachas", "kấch", "telex"), + ("kachaj", "kậch", "telex"), + ("kachar", "kẩch", "telex"), + ("kachax", "kẫch", "telex"), + ("kachaw", "kăch", "telex"), + ("kachaa", "kâch", "telex"), + ("kechee", "kêch", "telex"), + ("kochoo", "kôch", "telex"), + ("kochow", "kơch", "telex"), + ("kuchuw", "kuchư", "telex"), + ("kamaf", "kầm", "telex"), + ("kamas", "kấm", "telex"), + ("kamaj", "kậm", "telex"), + ("kamar", "kẩm", "telex"), + ("kamax", "kẫm", "telex"), + ("kamaw", "kăm", "telex"), + ("kamaa", "kâm", "telex"), + ("kemee", "kêm", "telex"), + ("komoo", "kôm", "telex"), + ("komow", "kơm", "telex"), + ("kumuw", "kumư", "telex"), + ("kanaf", "kần", "telex"), + ("kanas", "kấn", "telex"), + ("kanaj", "kận", "telex"), + ("kanar", "kẩn", "telex"), + ("kanax", "kẫn", "telex"), + ("kanaw", "kăn", "telex"), + ("kanaa", "kân", "telex"), + ("kenee", "kên", "telex"), + ("konoo", "kôn", "telex"), + ("konow", "kơn", "telex"), + ("kunuw", "kunư", "telex"), + ("kangaf", "kầng", "telex"), + ("kangas", "kấng", "telex"), + ("kangaj", "kậng", "telex"), + ("kangar", "kẩng", "telex"), + ("kangax", "kẫng", "telex"), + ("kangaw", "kăng", "telex"), + ("kangaa", "kâng", "telex"), + ("kengee", "kêng", "telex"), + ("kongoo", "kông", "telex"), + ("kongow", "kơng", "telex"), + ("kunguw", "kungư", "telex"), + ("kanhaf", "kầnh", "telex"), + ("kanhas", "kấnh", "telex"), + ("kanhaj", "kậnh", "telex"), + ("kanhar", "kẩnh", "telex"), + ("kanhax", "kẫnh", "telex"), + ("kanhaw", "kănh", "telex"), + ("kanhaa", "kânh", "telex"), + ("kenhee", "kênh", "telex"), + ("konhoo", "kônh", "telex"), + ("konhow", "kơnh", "telex"), + ("kunhuw", "kunhư", "telex"), + ("kapaf", "kầp", "telex"), + ("kapas", "kấp", "telex"), + ("kapaj", "kập", "telex"), + ("kapar", "kẩp", "telex"), + ("kapax", "kẫp", "telex"), + ("kapaw", "kăp", "telex"), + ("kapaa", "kâp", "telex"), + ("kepee", "kêp", "telex"), + ("kopoo", "kôp", "telex"), + ("kopow", "kơp", "telex"), + ("kupuw", "kupư", "telex"), + ("kataf", "kầt", "telex"), + ("katas", "kất", "telex"), + ("kataj", "kật", "telex"), + ("katar", "kẩt", "telex"), + ("katax", "kẫt", "telex"), + ("kataw", "kăt", "telex"), + ("kataa", "kât", "telex"), + ("ketee", "kêt", "telex"), + ("kotoo", "kôt", "telex"), + ("kotow", "kơt", "telex"), + ("kutuw", "kutư", "telex"), + ("khaaf", "khầ", "telex"), + ("khaas", "khấ", "telex"), + ("khaaj", "khậ", "telex"), + ("khaar", "khẩ", "telex"), + ("khaax", "khẫ", "telex"), + ("khaaw", "khă", "telex"), + ("khaaa", "khâ", "telex"), + ("kheee", "khê", "telex"), + ("khooo", "khô", "telex"), + ("khoow", "khơ", "telex"), + ("khuuw", "khuư", "telex"), + ("khacaf", "khầc", "telex"), + ("khacas", "khấc", "telex"), + ("khacaj", "khậc", "telex"), + ("khacar", "khẩc", "telex"), + ("khacax", "khẫc", "telex"), + ("khacaw", "khăc", "telex"), + ("khacaa", "khâc", "telex"), + ("khecee", "khêc", "telex"), + ("khocoo", "khôc", "telex"), + ("khocow", "khơc", "telex"), + ("khucuw", "khucư", "telex"), + ("khachaf", "khầch", "telex"), + ("khachas", "khấch", "telex"), + ("khachaj", "khậch", "telex"), + ("khachar", "khẩch", "telex"), + ("khachax", "khẫch", "telex"), + ("khachaw", "khăch", "telex"), + ("khachaa", "khâch", "telex"), + ("khechee", "khêch", "telex"), + ("khochoo", "khôch", "telex"), + ("khochow", "khơch", "telex"), + ("khuchuw", "khuchư", "telex"), + ("khamaf", "khầm", "telex"), + ("khamas", "khấm", "telex"), + ("khamaj", "khậm", "telex"), + ("khamar", "khẩm", "telex"), + ("khamax", "khẫm", "telex"), + ("khamaw", "khăm", "telex"), + ("khamaa", "khâm", "telex"), + ("khemee", "khêm", "telex"), + ("khomoo", "khôm", "telex"), + ("khomow", "khơm", "telex"), + ("khumuw", "khumư", "telex"), + ("khanaf", "khần", "telex"), + ("khanas", "khấn", "telex"), + ("khanaj", "khận", "telex"), + ("khanar", "khẩn", "telex"), + ("khanax", "khẫn", "telex"), + ("khanaw", "khăn", "telex"), + ("khanaa", "khân", "telex"), + ("khenee", "khên", "telex"), + ("khonoo", "khôn", "telex"), + ("khonow", "khơn", "telex"), + ("khunuw", "khunư", "telex"), + ("khangaf", "khầng", "telex"), + ("khangas", "khấng", "telex"), + ("khangaj", "khậng", "telex"), + ("khangar", "khẩng", "telex"), + ("khangax", "khẫng", "telex"), + ("khangaw", "khăng", "telex"), + ("khangaa", "khâng", "telex"), + ("khengee", "khêng", "telex"), + ("khongoo", "không", "telex"), + ("khongow", "khơng", "telex"), + ("khunguw", "khungư", "telex"), + ("khanhaf", "khầnh", "telex"), + ("khanhas", "khấnh", "telex"), + ("khanhaj", "khậnh", "telex"), + ("khanhar", "khẩnh", "telex"), + ("khanhax", "khẫnh", "telex"), + ("khanhaw", "khănh", "telex"), + ("khanhaa", "khânh", "telex"), + ("khenhee", "khênh", "telex"), + ("khonhoo", "khônh", "telex"), + ("khonhow", "khơnh", "telex"), + ("khunhuw", "khunhư", "telex"), + ("khapaf", "khầp", "telex"), + ("khapas", "khấp", "telex"), + ("khapaj", "khập", "telex"), + ("khapar", "khẩp", "telex"), + ("khapax", "khẫp", "telex"), + ("khapaw", "khăp", "telex"), + ("khapaa", "khâp", "telex"), + ("khepee", "khêp", "telex"), + ("khopoo", "khôp", "telex"), + ("khopow", "khơp", "telex"), + ("khupuw", "khupư", "telex"), + ("khataf", "khầt", "telex"), + ("khatas", "khất", "telex"), + ("khataj", "khật", "telex"), + ("khatar", "khẩt", "telex"), + ("khatax", "khẫt", "telex"), + ("khataw", "khăt", "telex"), + ("khataa", "khât", "telex"), + ("khetee", "khêt", "telex"), + ("khotoo", "khôt", "telex"), + ("khotow", "khơt", "telex"), + ("khutuw", "khutư", "telex"), + ("laaf", "lầ", "telex"), + ("laas", "lấ", "telex"), + ("laaj", "lậ", "telex"), + ("laar", "lẩ", "telex"), + ("laax", "lẫ", "telex"), + ("laaw", "lă", "telex"), + ("laaa", "lâ", "telex"), + ("leee", "lê", "telex"), + ("looo", "lô", "telex"), + ("loow", "lơ", "telex"), + ("luuw", "luư", "telex"), + ("lacaf", "lầc", "telex"), + ("lacas", "lấc", "telex"), + ("lacaj", "lậc", "telex"), + ("lacar", "lẩc", "telex"), + ("lacax", "lẫc", "telex"), + ("lacaw", "lăc", "telex"), + ("lacaa", "lâc", "telex"), + ("lecee", "lêc", "telex"), + ("locoo", "lôc", "telex"), + ("locow", "lơc", "telex"), + ("lucuw", "lucư", "telex"), + ("lachaf", "lầch", "telex"), + ("lachas", "lấch", "telex"), + ("lachaj", "lậch", "telex"), + ("lachar", "lẩch", "telex"), + ("lachax", "lẫch", "telex"), + ("lachaw", "lăch", "telex"), + ("lachaa", "lâch", "telex"), + ("lechee", "lêch", "telex"), + ("lochoo", "lôch", "telex"), + ("lochow", "lơch", "telex"), + ("luchuw", "luchư", "telex"), + ("lamaf", "lầm", "telex"), + ("lamas", "lấm", "telex"), + ("lamaj", "lậm", "telex"), + ("lamar", "lẩm", "telex"), + ("lamax", "lẫm", "telex"), + ("lamaw", "lăm", "telex"), + ("lamaa", "lâm", "telex"), + ("lemee", "lêm", "telex"), + ("lomoo", "lôm", "telex"), + ("lomow", "lơm", "telex"), + ("lumuw", "lumư", "telex"), + ("lanaf", "lần", "telex"), + ("lanas", "lấn", "telex"), + ("lanaj", "lận", "telex"), + ("lanar", "lẩn", "telex"), + ("lanax", "lẫn", "telex"), + ("lanaw", "lăn", "telex"), + ("lanaa", "lân", "telex"), + ("lenee", "lên", "telex"), + ("lonoo", "lôn", "telex"), + ("lonow", "lơn", "telex"), + ("lunuw", "lunư", "telex"), + ("langaf", "lầng", "telex"), + ("langas", "lấng", "telex"), + ("langaj", "lậng", "telex"), + ("langar", "lẩng", "telex"), + ("langax", "lẫng", "telex"), + ("langaw", "lăng", "telex"), + ("langaa", "lâng", "telex"), + ("lengee", "lêng", "telex"), + ("longoo", "lông", "telex"), + ("longow", "lơng", "telex"), + ("lunguw", "lungư", "telex"), + ("lanhaf", "lầnh", "telex"), + ("lanhas", "lấnh", "telex"), + ("lanhaj", "lậnh", "telex"), + ("lanhar", "lẩnh", "telex"), + ("lanhax", "lẫnh", "telex"), + ("lanhaw", "lănh", "telex"), + ("lanhaa", "lânh", "telex"), + ("lenhee", "lênh", "telex"), + ("lonhoo", "lônh", "telex"), + ("lonhow", "lơnh", "telex"), + ("lunhuw", "lunhư", "telex"), + ("lapaf", "lầp", "telex"), + ("lapas", "lấp", "telex"), + ("lapaj", "lập", "telex"), + ("lapar", "lẩp", "telex"), + ("lapax", "lẫp", "telex"), + ("lapaw", "lăp", "telex"), + ("lapaa", "lâp", "telex"), + ("lepee", "lêp", "telex"), + ("lopoo", "lôp", "telex"), + ("lopow", "lơp", "telex"), + ("lupuw", "lupư", "telex"), + ("lataf", "lầt", "telex"), + ("latas", "lất", "telex"), + ("lataj", "lật", "telex"), + ("latar", "lẩt", "telex"), + ("latax", "lẫt", "telex"), + ("lataw", "lăt", "telex"), + ("lataa", "lât", "telex"), + ("letee", "lêt", "telex"), + ("lotoo", "lôt", "telex"), + ("lotow", "lơt", "telex"), + ("lutuw", "lutư", "telex"), + ("maaf", "mầ", "telex"), + ("maas", "mấ", "telex"), + ("maaj", "mậ", "telex"), + ("maar", "mẩ", "telex"), + ("maax", "mẫ", "telex"), + ("maaw", "mă", "telex"), + ("maaa", "mâ", "telex"), + ("meee", "mê", "telex"), + ("mooo", "mô", "telex"), + ("moow", "mơ", "telex"), + ("muuw", "muư", "telex"), + ("macaf", "mầc", "telex"), + ("macas", "mấc", "telex"), + ("macaj", "mậc", "telex"), + ("macar", "mẩc", "telex"), + ("macax", "mẫc", "telex"), + ("macaw", "măc", "telex"), + ("macaa", "mâc", "telex"), + ("mecee", "mêc", "telex"), + ("mocoo", "môc", "telex"), + ("mocow", "mơc", "telex"), + ("mucuw", "mucư", "telex"), + ("machaf", "mầch", "telex"), + ("machas", "mấch", "telex"), + ("machaj", "mậch", "telex"), + ("machar", "mẩch", "telex"), + ("machax", "mẫch", "telex"), + ("machaw", "măch", "telex"), + ("machaa", "mâch", "telex"), + ("mechee", "mêch", "telex"), + ("mochoo", "môch", "telex"), + ("mochow", "mơch", "telex"), + ("muchuw", "muchư", "telex"), + ("mamaf", "mầm", "telex"), + ("mamas", "mấm", "telex"), + ("mamaj", "mậm", "telex"), + ("mamar", "mẩm", "telex"), + ("mamax", "mẫm", "telex"), + ("mamaw", "măm", "telex"), + ("mamaa", "mâm", "telex"), + ("memee", "mêm", "telex"), + ("momoo", "môm", "telex"), + ("momow", "mơm", "telex"), + ("mumuw", "mumư", "telex"), + ("manaf", "mần", "telex"), + ("manas", "mấn", "telex"), + ("manaj", "mận", "telex"), + ("manar", "mẩn", "telex"), + ("manax", "mẫn", "telex"), + ("manaw", "măn", "telex"), + ("manaa", "mân", "telex"), + ("menee", "mên", "telex"), + ("monoo", "môn", "telex"), + ("monow", "mơn", "telex"), + ("munuw", "munư", "telex"), + ("mangaf", "mầng", "telex"), + ("mangas", "mấng", "telex"), + ("mangaj", "mậng", "telex"), + ("mangar", "mẩng", "telex"), + ("mangax", "mẫng", "telex"), + ("mangaw", "măng", "telex"), + ("mangaa", "mâng", "telex"), + ("mengee", "mêng", "telex"), + ("mongoo", "mông", "telex"), + ("mongow", "mơng", "telex"), + ("munguw", "mungư", "telex"), + ("manhaf", "mầnh", "telex"), + ("manhas", "mấnh", "telex"), + ("manhaj", "mậnh", "telex"), + ("manhar", "mẩnh", "telex"), + ("manhax", "mẫnh", "telex"), + ("manhaw", "mănh", "telex"), + ("manhaa", "mânh", "telex"), + ("menhee", "mênh", "telex"), + ("monhoo", "mônh", "telex"), + ("monhow", "mơnh", "telex"), + ("munhuw", "munhư", "telex"), + ("mapaf", "mầp", "telex"), + ("mapas", "mấp", "telex"), + ("mapaj", "mập", "telex"), + ("mapar", "mẩp", "telex"), + ("mapax", "mẫp", "telex"), + ("mapaw", "măp", "telex"), + ("mapaa", "mâp", "telex"), + ("mepee", "mêp", "telex"), + ("mopoo", "môp", "telex"), + ("mopow", "mơp", "telex"), +]; + +#[test] +fn test_generated_bulk() { + let mut failures = Vec::new(); + for (i, &(input, expected, mode)) in TEST_CASES.iter().enumerate() { + let im = match mode { + "telex" => InputMethod::Telex, + "vni" => InputMethod::Vni, + _ => unreachable!(), + }; + let mut e = Engine::new(im); + let actual = get_display(&process_input(&mut e, input)); + if actual != expected { + failures.push(format!("[{}] '{}' -> '{}' (expected '{}')", i, input, actual, expected)); + } + } + if !failures.is_empty() { + for f in &failures[..10.min(failures.len())] { + eprintln!("{}", f); + } + panic!("{}/{} tests FAILED", failures.len(), TEST_CASES.len()); + } + eprintln!("All {} generated tests PASSED", TEST_CASES.len()); +} diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh index d331ada..452d66d 100644 --- a/packaging/appimage/build-appimage.sh +++ b/packaging/appimage/build-appimage.sh @@ -30,14 +30,13 @@ echo " Built with x11 + wayland" cd "$SCRIPT_DIR" -cd "$PROJECT_ROOT/ui" && cargo build --release 2>/dev/null && cd "$SCRIPT_DIR" || echo " UI build skipped (missing GTK4 libs)" +cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$SCRIPT_DIR" cd "$PROJECT_ROOT" # Copy binaries echo "[2/5] Installing binaries..." cp target/release/vietc "$APPDIR/usr/bin/" cp target/release/vietc-cli "$APPDIR/usr/bin/" -[ -f ui/target/release/vietc-settings ] && cp ui/target/release/vietc-settings "$APPDIR/usr/bin/" [ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" # Desktop integration @@ -200,8 +199,6 @@ trap cleanup_daemon EXIT INT TERM if [ -f "$HERE/usr/bin/vietc-tray" ]; then "$HERE/usr/bin/vietc-tray" "$@" -elif [ -f "$HERE/usr/bin/vietc-settings" ]; then - "$HERE/usr/bin/vietc-settings" "$@" else echo "[vietc] Daemon running in foreground. Press Ctrl+C to stop." wait "$DAEMON_PID" diff --git a/protocol/src/uinput_monitor.rs b/protocol/src/uinput_monitor.rs index 82e5809..ba87b3f 100644 --- a/protocol/src/uinput_monitor.rs +++ b/protocol/src/uinput_monitor.rs @@ -139,6 +139,63 @@ impl KeyInjector for UinputInjector { } impl UinputInjector { + /// Get the original non-root username when running as root. + /// Checks SUDO_USER (sudo), PKEXEC_UID (pkexec), /proc/self/loginuid, + /// and falls back to `logname`. + fn get_original_username() -> Option { + let is_root = unsafe { libc::getuid() == 0 }; + if !is_root { + return None; + } + + if let Ok(user) = std::env::var("SUDO_USER") { + if !user.is_empty() { + return Some(user); + } + } + + if let Ok(uid_str) = std::env::var("PKEXEC_UID") { + if let Ok(uid) = uid_str.parse::() { + unsafe { + let pw = libc::getpwuid(uid); + if !pw.is_null() { + let name = std::ffi::CStr::from_ptr((*pw).pw_name) + .to_string_lossy().into_owned(); + if !name.is_empty() { + return Some(name); + } + } + } + } + } + + if let Ok(content) = std::fs::read_to_string("/proc/self/loginuid") { + if let Ok(uid) = content.trim().parse::() { + unsafe { + let pw = libc::getpwuid(uid); + if !pw.is_null() { + let name = std::ffi::CStr::from_ptr((*pw).pw_name) + .to_string_lossy().into_owned(); + if !name.is_empty() { + return Some(name); + } + } + } + } + } + + if let Ok(output) = std::process::Command::new("logname").output() { + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() { + return Some(name); + } + } + } + + None + } + /// Run an external command as the original user if we're root. /// Wayland tools (wtype, wl-copy) need the user's session, not root. /// Uses explicit `env VAR=val` instead of `--preserve-env` for @@ -146,12 +203,12 @@ impl UinputInjector { fn run_as_user(program: &str, args: &[&str]) -> std::process::Output { let is_root = unsafe { libc::getuid() == 0 }; if is_root { - if let Ok(sudo_user) = std::env::var("SUDO_USER") { + if let Some(original_user) = Self::get_original_username() { let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default(); let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(); let display = std::env::var("DISPLAY").unwrap_or_default(); let mut cmd = std::process::Command::new("sudo"); - cmd.args(["-u", &sudo_user, "env"]); + cmd.args(["-u", &original_user, "env"]); if !wayland_display.is_empty() { cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display)); } @@ -166,7 +223,7 @@ impl UinputInjector { match cmd.output() { Ok(output) => return output, Err(e) => { - eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", sudo_user, program, args.join(" "), e); + eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", original_user, program, args.join(" "), e); return std::process::Output { status: std::process::ExitStatus::default(), stdout: vec![], @@ -174,6 +231,8 @@ impl UinputInjector { }; } } + } else { + eprintln!("[vietc] Running as root but could not determine original user"); } } match std::process::Command::new(program).args(args).output() { @@ -195,18 +254,98 @@ impl UinputInjector { /// best available method: ydotool (uinput) for ASCII, xdotool (X11) or /// clipboard for Unicode. fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult { - // Backspaces via uinput — reliable, no display server needed - if backspaces > 0 { - eprintln!("[vietc] uinput backspace x{}", backspaces); - for _ in 0..backspaces { - let _ = self.send_backspace(); + let is_ascii = text.chars().all(|c| char_to_linux_keycode(c).is_some()); + + if is_ascii { + if backspaces > 0 { + for _ in 0..backspaces { + let _ = self.send_backspace(); + } + } + for ch in text.chars() { + let _ = self.send_char(ch); + } + return InjectResult::Success; + } + + // It is Unicode. We must use a single unified channel. + let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); + + if is_wayland { + // Under Wayland, we try to use `wtype` for both backspaces and text. + let has_wtype = std::process::Command::new("which") + .arg("wtype") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if has_wtype { + let mut args = Vec::new(); + for _ in 0..backspaces { + args.push("-k"); + args.push("BackSpace"); + } + args.push("--"); + args.push(text); + + let output = Self::run_as_user("wtype", &args); + if output.status.success() { + return InjectResult::Success; + } + eprintln!("[vietc] wtype inject failed: {}", String::from_utf8_lossy(&output.stderr).trim()); + } + } else { + // Under X11, we try to use `xdotool` for both backspaces and text. + let has_xdotool = std::process::Command::new("which") + .arg("xdotool") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if has_xdotool { + let mut args = Vec::new(); + if backspaces > 0 { + args.push("key"); + for _ in 0..backspaces { + args.push("BackSpace"); + } + } + if !text.is_empty() { + args.push("type"); + args.push("--clearmodifiers"); + args.push(text); + } + + let output = Self::run_as_user("xdotool", &args); + if output.status.success() { + return InjectResult::Success; + } + eprintln!("[vietc] xdotool inject failed: {}", String::from_utf8_lossy(&output.stderr).trim()); } } - if !text.is_empty() { - eprintln!("[vietc] text injection: \"{}\"", text); + + // Fallback: Clipboard copy + paste. + // This is safe because both backspaces and Ctrl+V are injected into the SAME uinput device. + let copied = self.copy_to_clipboard(text); + if copied { + if backspaces > 0 { + for _ in 0..backspaces { + let _ = self.send_backspace(); + } + } + self.send_ctrl_v(); + InjectResult::Success + } else { + eprintln!("[vietc] clipboard copy failed during fallback"); + // Absolute last resort: try uinput backspaces followed by individual unicode paste_string + if backspaces > 0 { + for _ in 0..backspaces { + let _ = self.send_backspace(); + } + } self.paste_string(text); + InjectResult::Success } - InjectResult::Success } /// Copy text to clipboard and paste via Ctrl+V through our uinput device. @@ -214,27 +353,21 @@ impl UinputInjector { /// unavailable. Prefers ydotool (uinput, works everywhere) to avoid /// clipboard pollution. fn paste_string(&self, s: &str) { - let has_unicode = s.chars().any(|c| c > '\x7f'); - - if !has_unicode { - // Pure ASCII: ydotool works reliably (no keycode mapping issues). - let output = std::process::Command::new("ydotool") - .args(["type", s]) - .output(); - if let Ok(output) = output { - if output.status.success() { - eprintln!("[vietc] ydotool OK"); - return; - } - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.is_empty() { - eprintln!("[vietc] ydotool failed: {}", stderr.trim()); - } - eprintln!("[vietc] ydotool failed, trying xdotool..."); + // Try ydotool first (uinput-based, no display server needed). + let ydotool_result = std::process::Command::new("ydotool") + .args(["type", s]) + .output(); + if let Ok(output) = ydotool_result { + if output.status.success() { + eprintln!("[vietc] ydotool OK"); + return; + } + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + eprintln!("[vietc] ydotool failed: {}", stderr.trim()); } - } else { - eprintln!("[vietc] contains Unicode, skipping ydotool"); } + eprintln!("[vietc] ydotool failed, trying xdotool..."); // Try xdotool (X11): needs DISPLAY, run through run_as_user eprintln!("[vietc] trying xdotool..."); @@ -272,18 +405,16 @@ impl UinputInjector { eprintln!("[vietc] WARNING: No injection method works for '{}'!", s); } - /// Copy text to clipboard using wl-copy (Wayland) or xclip (X11). - fn copy_to_clipboard(&self, s: &str) -> bool { + /// Build a command to run as the original user with display environment. + fn user_cmd(program: &str) -> std::process::Command { let is_root = unsafe { libc::getuid() == 0 }; if is_root { - if let Ok(sudo_user) = std::env::var("SUDO_USER") { + if let Some(original_user) = Self::get_original_username() { let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default(); let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(); let display = std::env::var("DISPLAY").unwrap_or_default(); - eprintln!("[vietc] clipboard: is_root, SUDO_USER={} DISPLAY={} WAYLAND={} XDG_RUNTIME_DIR={}", - sudo_user, display, wayland_display, xdg_runtime_dir); let mut cmd = std::process::Command::new("sudo"); - cmd.args(["-u", &sudo_user, "env"]); + cmd.args(["-u", &original_user, "env"]); if !wayland_display.is_empty() { cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display)); } @@ -293,31 +424,23 @@ impl UinputInjector { if !display.is_empty() { cmd.arg(format!("DISPLAY={}", display)); } - cmd.arg("wl-copy"); - eprintln!("[vietc] clipboard: trying wl-copy via {:?}", cmd); - let result = cmd - .stdin(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child.stdin.take().unwrap().write_all(s.as_bytes())?; - child.wait() - }); - if let Ok(status) = result { - if status.success() { - eprintln!("[vietc] clipboard: wl-copy OK"); - return true; - } - eprintln!("[vietc] clipboard: wl-copy failed (exit={:?})", status.code()); - } else if let Err(ref e) = result { - eprintln!("[vietc] clipboard: wl-copy error: {}", e); - } - } else { - eprintln!("[vietc] clipboard: is_root but no SUDO_USER"); + cmd.arg(program); + return cmd; } - } else { - eprintln!("[vietc] clipboard: not root, trying wl-copy directly"); - let result = std::process::Command::new("wl-copy") + } + std::process::Command::new(program) + } + + /// Copy text to clipboard using wl-copy (Wayland) or xclip (X11). + fn copy_to_clipboard(&self, s: &str) -> bool { + let is_root = unsafe { libc::getuid() == 0 }; + eprintln!("[vietc] clipboard: is_root={}", is_root); + + // Try wl-copy (Wayland) via user_cmd + { + let mut cmd = Self::user_cmd("wl-copy"); + eprintln!("[vietc] clipboard: trying wl-copy via {:?}", cmd); + let result = cmd .stdin(std::process::Stdio::piped()) .spawn() .and_then(|mut child| { @@ -335,46 +458,14 @@ impl UinputInjector { eprintln!("[vietc] clipboard: wl-copy error: {}", e); } } - // Try xclip (X11). When root, run as SUDO_USER so it can connect to X. + + // Try xclip (X11) via user_cmd eprintln!("[vietc] clipboard: trying xclip..."); - let xclip_result = if is_root { - if let Ok(sudo_user) = std::env::var("SUDO_USER") { - let display = std::env::var("DISPLAY").unwrap_or_default(); - let mut cmd = std::process::Command::new("sudo"); - cmd.args(["-u", &sudo_user, "env"]); - if !display.is_empty() { - cmd.arg(format!("DISPLAY={}", display)); - } - cmd.arg("xclip"); - cmd.args(["-selection", "clipboard"]); - eprintln!("[vietc] clipboard: xclip via {:?}", cmd); - cmd.stdin(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child.stdin.take().unwrap().write_all(s.as_bytes())?; - child.wait() - }) - .map(|status| { - if status.success() { - eprintln!("[vietc] clipboard: xclip OK"); - } else { - eprintln!("[vietc] clipboard: xclip failed (exit={:?})", status.code()); - } - status.success() - }) - .unwrap_or_else(|e| { - eprintln!("[vietc] clipboard: xclip error: {}", e); - false - }) - } else { - eprintln!("[vietc] clipboard: is_root but no SUDO_USER in xclip path"); - false - } - } else { - eprintln!("[vietc] clipboard: not root, trying xclip directly"); - std::process::Command::new("xclip") - .args(["-selection", "clipboard"]) + { + let mut cmd = Self::user_cmd("xclip"); + cmd.args(["-selection", "clipboard"]); + eprintln!("[vietc] clipboard: xclip via {:?}", cmd); + let result = cmd .stdin(std::process::Stdio::piped()) .spawn() .and_then(|mut child| { @@ -393,10 +484,13 @@ impl UinputInjector { .unwrap_or_else(|e| { eprintln!("[vietc] clipboard: xclip error: {}", e); false - }) - }; + }); + if result { + return true; + } + } - xclip_result + false } /// Send Ctrl+V through our uinput device. diff --git a/protocol/src/x11_inject.rs b/protocol/src/x11_inject.rs index 48785bd..3ad049b 100644 --- a/protocol/src/x11_inject.rs +++ b/protocol/src/x11_inject.rs @@ -167,9 +167,35 @@ impl X11Injector { if ydotool_ok { return; } - let _ = std::process::Command::new("xdotool") + let xdotool_ok = std::process::Command::new("xdotool") .args(["type", "--clearmodifiers", &s]) - .output(); + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if xdotool_ok { + return; + } + // Clipboard fallback: xclip + Ctrl+V via XTEST + let copied = std::process::Command::new("xclip") + .args(["-selection", "clipboard"]) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child.stdin.take().unwrap().write_all(s.as_bytes())?; + child.wait() + }) + .map(|status| status.success()) + .unwrap_or(false); + if copied { + unsafe { + (self.lib.x_test_fake_key_event)(self.display, 29, 1, 0); // Ctrl press + (self.lib.x_test_fake_key_event)(self.display, 47, 1, 0); // V press + (self.lib.x_test_fake_key_event)(self.display, 47, 0, 0); // V release + (self.lib.x_test_fake_key_event)(self.display, 29, 0, 0); // Ctrl release + (self.lib.x_flush)(self.display); + } + } } } @@ -196,6 +222,97 @@ impl KeyInjector for X11Injector { InjectResult::Success } + fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult { + let is_ascii = text.chars().all(|c| char_to_keycode(c).is_some()); + + if is_ascii { + if backspaces > 0 { + for _ in 0..backspaces { + self.send_keycode(14, false); // KEY_BACKSPACE + } + } + for ch in text.chars() { + if let Some((keycode, shift)) = char_to_keycode(ch) { + self.send_keycode(keycode, shift); + } + } + return InjectResult::Success; + } + + // Contains Unicode: try xdotool with both backspaces and text in a single command + let has_xdotool = std::process::Command::new("which") + .arg("xdotool") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if has_xdotool { + let mut args = Vec::new(); + if backspaces > 0 { + args.push("key".to_string()); + for _ in 0..backspaces { + args.push("BackSpace".to_string()); + } + } + if !text.is_empty() { + args.push("type".to_string()); + args.push("--clearmodifiers".to_string()); + args.push(text.to_string()); + } + + let ok = std::process::Command::new("xdotool") + .args(&args) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if ok { + return InjectResult::Success; + } + } + + // Fallback: Clipboard copy + paste. + // Send backspaces via XTEST, then copy to clipboard, then paste (Ctrl+V) via XTEST. + // Since all XTEST key events go through the same display connection, their ordering is guaranteed. + let mut clipboard_cmd = std::process::Command::new("xclip"); + clipboard_cmd.args(["-selection", "clipboard"]); + clipboard_cmd.stdin(std::process::Stdio::piped()); + let copied = clipboard_cmd.spawn() + .and_then(|mut child| { + use std::io::Write; + child.stdin.take().unwrap().write_all(text.as_bytes())?; + child.wait() + }) + .map(|status| status.success()) + .unwrap_or(false); + + if copied { + if backspaces > 0 { + for _ in 0..backspaces { + self.send_keycode(14, false); // KEY_BACKSPACE + } + } + unsafe { + (self.lib.x_test_fake_key_event)(self.display, 29, 1, 0); // Ctrl press + (self.lib.x_test_fake_key_event)(self.display, 47, 1, 0); // V press + (self.lib.x_test_fake_key_event)(self.display, 47, 0, 0); // V release + (self.lib.x_test_fake_key_event)(self.display, 29, 0, 0); // Ctrl release + (self.lib.x_flush)(self.display); + } + InjectResult::Success + } else { + // Absolute last resort: backspaces via XTEST followed by individual unicode send_unicode_via_xdotool + if backspaces > 0 { + for _ in 0..backspaces { + self.send_keycode(14, false); // KEY_BACKSPACE + } + } + for ch in text.chars() { + self.send_char(ch); + } + InjectResult::Success + } + } + fn flush(&self) -> InjectResult { unsafe { (self.lib.x_flush)(self.display); } InjectResult::Success diff --git a/scripts/gen_test_cases.py b/scripts/gen_test_cases.py new file mode 100644 index 0000000..dc8c79c --- /dev/null +++ b/scripts/gen_test_cases.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Generate 1000+ Vietnamese IME test cases and produce a Rust test file.""" +import json +import subprocess +import sys +import os + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..")) +EXAMPLE_PATH = os.path.join(PROJECT_DIR, "target", "release", "examples", "gen_tests") + +def build_generator(): + """Build the Rust test case generator.""" + subprocess.run( + ["cargo", "run", "--example", "gen_tests", "--release"], + cwd=PROJECT_DIR, + capture_output=True, + check=True, + ) + +def run_generator(): + """Run the generator and return JSON lines.""" + result = subprocess.run( + [EXAMPLE_PATH], + capture_output=True, + text=True, + timeout=30, + ) + cases = [] + for line in result.stdout.strip().split("\n"): + line = line.strip() + if not line or line.startswith("Generated"): + continue + try: + cases.append(json.loads(line)) + except json.JSONDecodeError: + continue + return cases + +def generate_rust_test(cases, output_path): + """Generate a Rust test file with all cases.""" + from datetime import datetime + lines = [] + lines.append("/// Auto-generated from gen_tests example") + lines.append(f"/// Generated: {datetime.now().isoformat()}") + lines.append(f"/// Total cases: {len(cases)}") + lines.append("") + lines.append("use vietc_engine::{Engine, EngineEvent, InputMethod};") + lines.append("") + lines.append("fn get_display(events: &[EngineEvent]) -> String {") + lines.append(" let mut display = String::new();") + lines.append(" for ev in events {") + lines.append(" match ev {") + lines.append(" EngineEvent::Flush(text) => {") + lines.append(" if !display.ends_with(text) { display.push_str(text); }") + lines.append(" }") + lines.append(" EngineEvent::Insert(text) => display.push_str(text),") + lines.append(" EngineEvent::Replace { backspaces, insert } => {") + lines.append(" for _ in 0..*backspaces { display.pop(); }") + lines.append(" display.push_str(insert);") + lines.append(" }") + lines.append(" EngineEvent::AutoRestore(word) => {") + lines.append(" for _ in 0..word.len() { display.pop(); }") + lines.append(" display.push_str(word);") + lines.append(" }") + lines.append(" EngineEvent::UndoTones { backspaces, restored } => {") + lines.append(" for _ in 0..*backspaces { display.pop(); }") + lines.append(" display.push_str(restored);") + lines.append(" }") + lines.append(" }") + lines.append(" }") + lines.append(" display") + lines.append("}") + lines.append("") + lines.append("fn process_input(e: &mut Engine, input: &str) -> Vec {") + lines.append(" let mut events = Vec::new();") + lines.append(" for ch in input.chars() {") + lines.append(" if let Some(ev) = e.process_key(ch) { events.push(ev); }") + lines.append(" }") + lines.append(" events") + lines.append("}") + lines.append("") + + lines.append(f"const TEST_CASES: &[(&str, &str, &str)] = &[") + for c in cases: + input_escaped = c["i"].replace("\\", "\\\\").replace("\"", "\\\"") + expected_escaped = c["e"].replace("\\", "\\\\").replace("\"", "\\\"") + mode = c["m"] + lines.append(f' ("{input_escaped}", "{expected_escaped}", "{mode}"),') + lines.append("];") + lines.append("") + + lines.append("#[test]") + lines.append("fn test_generated_bulk() {") + lines.append(" let mut failures = Vec::new();") + lines.append(" for (i, &(input, expected, mode)) in TEST_CASES.iter().enumerate() {") + lines.append(" let im = match mode {") + lines.append(' "telex" => InputMethod::Telex,') + lines.append(' "vni" => InputMethod::Vni,') + lines.append(" _ => unreachable!(),") + lines.append(" };") + lines.append(" let mut e = Engine::new(im);") + lines.append(" let actual = get_display(&process_input(&mut e, input));") + lines.append(" if actual != expected {") + lines.append(" failures.push(format!(\"[{}] '{}' -> '{}' (expected '{}')\", i, input, actual, expected));") + lines.append(" }") + lines.append(" }") + lines.append(" if !failures.is_empty() {") + lines.append(" for f in &failures[..10.min(failures.len())] {") + lines.append(' eprintln!("{}", f);') + lines.append(" }") + lines.append(' panic!("{}/{} tests FAILED", failures.len(), TEST_CASES.len());') + lines.append(" }") + lines.append(' eprintln!("All {} generated tests PASSED", TEST_CASES.len());') + lines.append("}") + lines.append("") + + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + print(f"Generated {output_path} with {len(cases)} test cases") + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--output", default="engine/tests/generated_bulk.rs", + help="Output Rust test file path") + args = parser.parse_args() + + output_path = os.path.join(PROJECT_DIR, args.output) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Run generator & capture cases + try: + result = subprocess.run( + ["cargo", "run", "--example", "gen_tests", "--release"], + cwd=PROJECT_DIR, + capture_output=True, + text=True, + timeout=120, + ) + cases = [] + for line in result.stdout.strip().split("\n"): + line = line.strip() + if not line or line.startswith("Generated"): + continue + try: + cases.append(json.loads(line)) + except json.JSONDecodeError: + continue + except subprocess.TimeoutExpired: + print("ERROR: Generator timed out", file=sys.stderr) + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"ERROR: Generator failed: {e.stderr}", file=sys.stderr) + sys.exit(1) + + print(f"Captured {len(cases)} test cases from generator") + generate_rust_test(cases, output_path) diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 3f15ea8..a6a8753 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -1,26 +1,15 @@ [package] -name = "vietc-ui" +name = "vietc-tray" version = "0.1.0" edition = "2021" -description = "Viet+ settings UI and tray icon (GTK4/Libadwaita)" - -[[bin]] -name = "vietc-settings" -path = "src/main.rs" +description = "Viet+ system tray icon" [[bin]] name = "vietc-tray" -path = "src/tray.rs" +path = "src/main.rs" [dependencies] -vietc-engine = { path = "../engine" } -gtk = { package = "gtk4", version = "0.9", features = ["v4_12"], optional = true } -adw = { package = "libadwaita", version = "0.7", features = ["v1_4"], optional = true } ksni = "0.2" toml = "0.8" serde = { version = "1", features = ["derive"] } dirs = "5" - -[features] -default = ["ui"] -ui = ["dep:gtk", "dep:adw"] diff --git a/ui/data/window.ui b/ui/data/window.ui deleted file mode 100644 index 0eb6fda..0000000 --- a/ui/data/window.ui +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - diff --git a/ui/src/config.rs b/ui/src/config.rs index be590c7..d7fb65a 100644 --- a/ui/src/config.rs +++ b/ui/src/config.rs @@ -1,8 +1,47 @@ use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fs; use std::path::PathBuf; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutoRestoreConfig { + #[serde(default = "default_true")] + pub enabled: bool, + + #[serde(default = "default_restore_keys")] + pub trigger_keys: Vec, +} + +impl Default for AutoRestoreConfig { + fn default() -> Self { + Self { + enabled: true, + trigger_keys: default_restore_keys(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppStateConfig { + #[serde(default = "default_true")] + pub enabled: bool, + + #[serde(default)] + pub english_apps: Vec, + + #[serde(default)] + pub vietnamese_apps: Vec, +} + +impl Default for AppStateConfig { + fn default() -> Self { + Self { + enabled: true, + english_apps: vec![], + vietnamese_apps: vec![], + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { #[serde(default = "default_input_method")] @@ -21,67 +60,39 @@ pub struct Config { pub app_state: AppStateConfig, #[serde(default)] - pub macros: HashMap, -} + pub macros: std::collections::HashMap, -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct AutoRestoreConfig { - #[serde(default = "default_true")] - pub enabled: bool, + #[serde(default = "default_grab")] + pub grab: bool, - #[serde(default = "default_restore_keys")] - pub trigger_keys: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct AppStateConfig { - #[serde(default = "default_true")] - pub enabled: bool, - - #[serde(default)] - pub english_apps: Vec, - - #[serde(default)] - pub vietnamese_apps: Vec, + #[serde(default = "default_false")] + pub debug: bool, } fn default_input_method() -> String { "telex".into() } fn default_toggle_key() -> String { "space".into() } fn default_start_enabled() -> bool { true } +fn default_grab() -> bool { true } fn default_true() -> bool { true } +fn default_false() -> bool { false } fn default_restore_keys() -> Vec { vec!["space".into(), "escape".into()] } impl Default for Config { fn default() -> Self { - let mut macros = HashMap::new(); - macros.insert("ko".into(), "không".into()); - macros.insert("dc".into(), "được".into()); - macros.insert("vs".into(), "với".into()); - macros.insert("lm".into(), "làm".into()); - Self { input_method: default_input_method(), toggle_key: default_toggle_key(), start_enabled: default_start_enabled(), - auto_restore: AutoRestoreConfig { - enabled: true, - trigger_keys: default_restore_keys(), - }, - app_state: AppStateConfig { - enabled: true, - english_apps: vec![ - "code".into(), "vim".into(), "nvim".into(), - "terminal".into(), "kitty".into(), "alacritty".into(), - ], - vietnamese_apps: vec![ - "telegram".into(), "discord".into(), "firefox".into(), - ], - }, - macros, + auto_restore: AutoRestoreConfig::default(), + app_state: AppStateConfig::default(), + macros: std::collections::HashMap::new(), + grab: default_grab(), + debug: default_false(), } } } + impl Config { pub fn load() -> Self { for path in config_paths() { @@ -103,10 +114,6 @@ impl Config { fs::write(&path, content)?; Ok(()) } - - pub fn path() -> PathBuf { - config_path() - } } fn config_path() -> PathBuf { @@ -151,7 +158,7 @@ pub fn uninstall_autostart() { } } -pub fn install_autostart_force() { +pub fn install_autostart() { if let Some(config_dir) = dirs::config_dir() { let autostart_dir = config_dir.join("autostart"); let desktop_file = autostart_dir.join("vietc-tray.desktop"); @@ -169,8 +176,8 @@ pub fn install_autostart_force() { let content = format!( "[Desktop Entry]\n\ Type=Application\n\ - Name=Viet+ Tray\n\ - Comment=Vietnamese Input Method tray icon\n\ + Name=Viet+\n\ + Comment=Vietnamese Input Method\n\ Exec={}\n\ Icon=input-keyboard\n\ Terminal=false\n\ @@ -184,4 +191,3 @@ pub fn install_autostart_force() { eprintln!("[vietc] Installed autostart entry"); } } - diff --git a/ui/src/main.rs b/ui/src/main.rs index edad87b..06152c6 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -1,21 +1,165 @@ -use adw::prelude::*; -use gtk::{gio, glib}; +use std::path::PathBuf; mod config; -mod window; +mod tray; -use window::SettingsWindow; - -fn main() -> glib::ExitCode { - let app = adw::Application::builder() - .application_id("io.github.vietc.Settings") - .flags(gio::ApplicationFlags::FLAGS_NONE) - .build(); - - app.connect_activate(|app| { - let window = SettingsWindow::new(app); - gtk::prelude::GtkWindowExt::present(&window); - }); - - app.run() +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from("/usr/bin")) +} + +fn find_sibling_binary(name: &str) -> String { + let sibling = exe_dir().join(name); + if sibling.exists() { + return sibling.to_string_lossy().into_owned(); + } + name.to_string() +} + +fn is_daemon_running() -> bool { + std::process::Command::new("pgrep") + .arg("-x") + .arg("vietc") + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn needs_root() -> bool { + let cfg = config::Config::load(); + cfg.grab +} + +/// Show a password prompt using available desktop tools. +/// Returns the password, or empty string if cancelled. +fn prompt_password() -> String { + let title = "Viet+"; + let msg = "Viet+ needs root privileges to capture keyboard input.\nPlease enter your password:"; + + // Try zenity (GNOME) + if let Ok(output) = std::process::Command::new("zenity") + .args(["--password", "--title", title, "--text", msg]) + .stderr(std::process::Stdio::null()) + .output() + { + if output.status.success() { + let pw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !pw.is_empty() { + return pw; + } + } + } + + // Try kdialog (KDE) + if let Ok(output) = std::process::Command::new("kdialog") + .args(["--password", msg]) + .stderr(std::process::Stdio::null()) + .output() + { + if output.status.success() { + let pw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !pw.is_empty() { + return pw; + } + } + } + + // Try ssh-askpass (X11 fallback) + if let Ok(output) = std::process::Command::new("ssh-askpass") + .arg(msg) + .stderr(std::process::Stdio::null()) + .output() + { + if output.status.success() { + let pw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !pw.is_empty() { + return pw; + } + } + } + + // Last resort: terminal prompt + eprintln!("{}", msg); + if let Ok(child) = std::process::Command::new("sh") + .arg("-c") + .arg("read -s -p 'Password: ' pw && echo \"$pw\"") + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::piped()) + .spawn() + { + if let Ok(output) = child.wait_with_output() { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + } + } + + String::new() +} + +fn start_daemon() { + let daemon_bin = find_sibling_binary("vietc"); + + if needs_root() && !is_daemon_running() { + // Mark that we've attempted first launch + let flag_path = config_path().join(".first-launch-done"); + + if !flag_path.exists() { + let password = prompt_password(); + if password.is_empty() { + eprintln!("[vietc-tray] No password provided, starting daemon without root"); + let _ = std::process::Command::new(&daemon_bin).spawn(); + return; + } + + // Start daemon with sudo + let mut child = match std::process::Command::new("sudo") + .args(["-S", &daemon_bin]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { + eprintln!("[vietc-tray] Failed to start daemon with sudo: {}", e); + let _ = std::process::Command::new(&daemon_bin).spawn(); + return; + } + }; + + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + let _ = stdin.write_all(format!("{}\n", password).as_bytes()); + } + let _ = child.wait(); + + // Mark first launch as done + let _ = std::fs::write(&flag_path, "1"); + return; + } + } + + if !is_daemon_running() { + eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin); + let _ = std::process::Command::new(&daemon_bin).spawn(); + } +} + +fn config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("vietc") +} + +fn main() { + eprintln!("[vietc-tray] Starting"); + + // Start daemon (with password prompt if first launch) + start_daemon(); + + // Run the tray + tray::run(); } diff --git a/ui/src/tray.rs b/ui/src/tray.rs index 6c9c34b..b2a9a23 100644 --- a/ui/src/tray.rs +++ b/ui/src/tray.rs @@ -1,124 +1,135 @@ use ksni::{Tray, MenuItem, menu::*}; -mod config; -use config::Config; +use crate::config; -/// Get the directory where the current executable lives. -/// This handles AppImage, DEB installs, and dev builds correctly. -fn exe_dir() -> std::path::PathBuf { - std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|d| d.to_path_buf())) - .unwrap_or_else(|| std::path::PathBuf::from("/usr/bin")) +fn write_status(state: &str) { + if let Some(config_dir) = dirs::config_dir() { + let _ = std::fs::write(config_dir.join("vietc").join("status"), state); + } } -/// Find a sibling binary (in the same directory as the current executable). -/// Also searches the workspace target directory for development. -/// Falls back to searching PATH if not found next to the executable. -fn find_sibling_binary(name: &str) -> String { - // 1. Same directory - let sibling = exe_dir().join(name); - if sibling.exists() { - return sibling.to_string_lossy().into_owned(); - } +fn read_status() -> String { + let path = dirs::config_dir() + .map(|d| d.join("vietc").join("status")) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp/vietc-status")); - // 2. Dev target/debug relative path (from ui/target/debug) - let dev_debug = exe_dir().join("..").join("..").join("..").join("target").join("debug").join(name); - if dev_debug.exists() { - return dev_debug.to_string_lossy().into_owned(); - } - - // 3. Dev target/release relative path (from ui/target/release) - let dev_release = exe_dir().join("..").join("..").join("..").join("target").join("release").join(name); - if dev_release.exists() { - return dev_release.to_string_lossy().into_owned(); - } - - name.to_string() + std::fs::read_to_string(&path) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| { + let cfg = config::Config::load(); + if cfg.start_enabled { "vn".into() } else { "en".into() } + }) } -struct VietcTray { - active_mode: String, - autostart_enabled: bool, +fn current_im() -> String { + config::Config::load().input_method } -impl Tray for VietcTray { - fn id(&self) -> String { - "io.github.vietc.Tray".into() - } +fn ensure_icons() { + let Some(config_dir) = dirs::config_dir() else { return }; + let icons_dir = config_dir.join("vietc").join("icons"); + let _ = std::fs::create_dir_all(&icons_dir); - fn title(&self) -> String { - "Viet+".into() - } + let vn_path = icons_dir.join("vietc-vn.svg"); + let en_path = icons_dir.join("vietc-en.svg"); + + let _ = std::fs::write(&vn_path, r##" + + VN +"##); + + let _ = std::fs::write(&en_path, r##" + + EN +"##); +} + +struct VietTray { + mode: String, + im: String, + autostart: bool, +} + +impl Tray for VietTray { + fn id(&self) -> String { "io.github.vietc.Tray".into() } + fn title(&self) -> String { "Viet+".into() } fn icon_name(&self) -> String { - if self.active_mode == "vn" { - "vietc-vn".into() - } else { - "vietc-en".into() - } + if self.mode == "vn" { "vietc-vn".into() } else { "vietc-en".into() } } fn icon_theme_path(&self) -> String { - if let Some(config_dir) = dirs::config_dir() { - config_dir.join("vietc").join("icons").to_string_lossy().into_owned() - } else { - "".into() - } + dirs::config_dir() + .map(|d| d.join("vietc").join("icons").to_string_lossy().into_owned()) + .unwrap_or_default() + } + + fn activate(&mut self, _x: i32, _y: i32) { + let next = if self.mode == "vn" { "en" } else { "vn" }; + write_status(&next); + let mut cfg = config::Config::load(); + cfg.start_enabled = next == "vn"; + let _ = cfg.save(); + self.mode = next.to_string(); } fn menu(&self) -> Vec> { - let is_vn = self.active_mode == "vn"; + let is_vn = self.mode == "vn"; + let im_index = if self.im == "telex" { 0 } else { 1 }; + vec![ CheckmarkItem { label: "Vietnamese Mode".into(), checked: is_vn, - activate: Box::new(|this: &mut VietcTray| { - let next_state = if this.active_mode == "vn" { "en" } else { "vn" }; - if let Some(config_dir) = dirs::config_dir() { - let status_path = config_dir.join("vietc").join("status"); - let _ = std::fs::write(&status_path, next_state); - } - - // Also save start_enabled to config, so it persists across reboots - let mut config = Config::load(); - config.start_enabled = next_state == "vn"; - let _ = config.save(); + activate: Box::new(|this: &mut VietTray| { + let next = if this.mode == "vn" { "en" } else { "vn" }; + write_status(&next); + let mut cfg = config::Config::load(); + cfg.start_enabled = next == "vn"; + let _ = cfg.save(); + this.mode = next.to_string(); }), ..Default::default() }.into(), + MenuItem::Separator, + SubMenu { + label: "Input Method".into(), + submenu: vec![ + RadioGroup { + selected: im_index, + select: Box::new(|this: &mut VietTray, idx: usize| { + let im = if idx == 0 { "telex" } else { "vni" }; + let mut cfg = config::Config::load(); + cfg.input_method = im.into(); + let _ = cfg.save(); + this.im = im.into(); + }), + options: vec![ + RadioItem { label: "Telex".into(), ..Default::default() }, + RadioItem { label: "VNI".into(), ..Default::default() }, + ], + }.into(), + ], + ..Default::default() + }.into(), + MenuItem::Separator, CheckmarkItem { - label: "Autostart on Boot".into(), - checked: self.autostart_enabled, - activate: Box::new(|this: &mut VietcTray| { - if this.autostart_enabled { + label: "Start with System".into(), + checked: self.autostart, + activate: Box::new(|this: &mut VietTray| { + if this.autostart { config::uninstall_autostart(); } else { - config::install_autostart_force(); + config::install_autostart(); } }), ..Default::default() }.into(), MenuItem::Separator, StandardItem { - label: "Settings...".into(), - activate: Box::new(|_| { - let settings_bin = find_sibling_binary("vietc-settings"); - eprintln!("[vietc-tray] Launching settings: {}", settings_bin); - match std::process::Command::new(&settings_bin).spawn() { - Ok(_) => {}, - Err(e) => eprintln!("[vietc-tray] Failed to launch settings: {}", e), - } - }), - ..Default::default() - }.into(), - MenuItem::Separator, - StandardItem { - label: "Quit Viet+".into(), + label: "Quit".into(), activate: Box::new(|_| { let _ = std::process::Command::new("pkill") - .arg("-x") - .arg("vietc") - .status(); + .arg("-x").arg("vietc").status(); std::process::exit(0); }), ..Default::default() @@ -127,99 +138,33 @@ impl Tray for VietcTray { } } -fn is_daemon_running() -> bool { - std::process::Command::new("pgrep") - .arg("-x") - .arg("vietc") - .status() - .map(|s| s.success()) - .unwrap_or(false) -} +pub fn run() { + ensure_icons(); -fn ensure_icons_exist() { - if let Some(config_dir) = dirs::config_dir() { - let icons_dir = config_dir.join("vietc").join("icons"); - let _ = std::fs::create_dir_all(&icons_dir); - - let vn_path = icons_dir.join("vietc-vn.svg"); - let en_path = icons_dir.join("vietc-en.svg"); - - let vn_svg = r##" - - VN -"##; - - let en_svg = r##" - - EN -"##; - - let _ = std::fs::write(&vn_path, vn_svg); - let _ = std::fs::write(&en_path, en_svg); - - let hicolor_apps_dir = icons_dir.join("hicolor").join("scalable").join("apps"); - let _ = std::fs::create_dir_all(&hicolor_apps_dir); - let _ = std::fs::write(hicolor_apps_dir.join("vietc-vn.svg"), vn_svg); - let _ = std::fs::write(hicolor_apps_dir.join("vietc-en.svg"), en_svg); - } -} - -fn main() { - eprintln!("[vietc-tray] Starting tray (exe dir: {:?})", exe_dir()); - - ensure_icons_exist(); - - if !is_daemon_running() { - let daemon_bin = find_sibling_binary("vietc"); - eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin); - match std::process::Command::new(&daemon_bin).spawn() { - Ok(child) => eprintln!("[vietc-tray] Daemon started (PID {})", child.id()), - Err(e) => eprintln!("[vietc-tray] Failed to start daemon: {}", e), - } - } else { - eprintln!("[vietc-tray] Daemon already running"); - } - - let tray = VietcTray { - active_mode: "en".into(), - autostart_enabled: config::is_autostart_installed(), + let tray = VietTray { + mode: read_status(), + im: current_im(), + autostart: config::is_autostart_installed(), }; let service = ksni::TrayService::new(tray); let handle = service.handle(); service.spawn(); - let handle_clone = handle.clone(); + // Poll for changes std::thread::spawn(move || { - let status_path = dirs::config_dir() - .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) - .join("vietc") - .join("status"); - loop { - let active_mode = if let Ok(content) = std::fs::read_to_string(&status_path) { - content.trim().to_string() - } else { - let config = Config::load(); - if config.start_enabled { "vn".to_string() } else { "en".to_string() } - }; - - let autostart_enabled = config::is_autostart_installed(); - - let _ = handle_clone.update(move |t| { - t.active_mode = active_mode; - t.autostart_enabled = autostart_enabled; + std::thread::sleep(std::time::Duration::from_millis(500)); + let mode = read_status(); + let im = current_im(); + let autostart = config::is_autostart_installed(); + let _ = handle.update(move |t| { + t.mode = mode; + t.im = im; + t.autostart = autostart; }); - - std::thread::sleep(std::time::Duration::from_millis(250)); } }); - if config::is_autostart_installed() { - config::install_autostart_force(); - } - - loop { - std::thread::park(); - } + loop { std::thread::park(); } } diff --git a/ui/src/window.rs b/ui/src/window.rs deleted file mode 100644 index 6c92d63..0000000 --- a/ui/src/window.rs +++ /dev/null @@ -1,710 +0,0 @@ -use adw::prelude::*; -use adw::subclass::prelude::*; -use gtk::{gio, glib}; - -use crate::config::Config; - -mod imp { - use super::*; - use std::cell::RefCell; - - #[derive(Default)] - pub struct SettingsWindow { - pub dirty: RefCell, - } - - #[glib::object_subclass] - impl ObjectSubclass for SettingsWindow { - const NAME: &'static str = "SettingsWindow"; - type Type = super::SettingsWindow; - type ParentType = adw::ApplicationWindow; - } - - impl ObjectImpl for SettingsWindow {} - impl WidgetImpl for SettingsWindow {} - impl WindowImpl for SettingsWindow {} - impl ApplicationWindowImpl for SettingsWindow {} - impl AdwApplicationWindowImpl for SettingsWindow {} -} - -glib::wrapper! { - pub struct SettingsWindow(ObjectSubclass) - @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, - @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable; -} - -impl SettingsWindow { - pub fn new(app: &adw::Application) -> Self { - let win: Self = glib::Object::builder() - .property("application", app) - .property("default-width", 580) - .property("default-height", 500) - .property("title", "Viet+ Settings") - .build(); - - win.build_ui(); - win - } - - fn mark_dirty(&self) { - *self.imp().dirty.borrow_mut() = true; - } - - fn build_ui(&self) { - let config = Config::load(); - let trigger_keys = config.auto_restore.trigger_keys.clone(); - - // Toast overlay for notifications - let toast_overlay = adw::ToastOverlay::new(); - - // Main box - let main_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - - // Header bar with view switcher - let header = adw::HeaderBar::new(); - - // View Stack - let stack = adw::ViewStack::builder() - .vexpand(true) - .build(); - - // View Switcher linked to stack - let switcher = adw::ViewSwitcher::builder() - .stack(&stack) - .build(); - header.set_title_widget(Some(&switcher)); - - // Save button (suggested action) - let save_btn = gtk::Button::builder() - .label("Save") - .css_classes(["suggested-action"]) - .tooltip_text("Save settings (Ctrl+S)") - .build(); - header.pack_end(&save_btn); - - // Keyboard shortcut for save - let controller = gtk::EventControllerKey::new(); - let save_ref = save_btn.clone(); - controller.connect_key_pressed(move |_, key, _, modifiers| { - if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK) - && key == gtk::gdk::Key::s - { - save_ref.emit_clicked(); - glib::Propagation::Stop - } else { - glib::Propagation::Proceed - } - }); - self.add_controller(controller); - - main_box.append(&header); - - // ==================== Page 1: Typing ==================== - let typing_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(16) - .margin_bottom(16) - .margin_start(16) - .margin_end(16) - .build(); - - // ========== Input Method Section ========== - let method_group = adw::PreferencesGroup::builder() - .title("Input Method") - .description("Select your preferred Vietnamese typing method") - .build(); - - let method_row = adw::ComboRow::builder() - .title("Keyboard Layout") - .subtitle("Telex uses letters (aa=ă, ee=ê), VNI uses digits (a6=ă, e8=ê)") - .model(>k::StringList::new(&["Telex (Recommended)", "VNI"])) - .selected(if config.input_method == "vni" { 1 } else { 0 }) - .build(); - - let toggle_row = adw::ComboRow::builder() - .title("Toggle Key") - .subtitle("Switch between Vietnamese and English input") - .model(>k::StringList::new(&[ - "Ctrl + Space", - "Ctrl + Shift", - "Caps Lock", - ])) - .selected(match config.toggle_key.as_str() { - "shift" => 1, - "capslock" => 2, - _ => 0, - }) - .build(); - - method_group.add(&method_row); - method_group.add(&toggle_row); - typing_box.append(&method_group); - - // ========== General Section ========== - let general_group = adw::PreferencesGroup::builder() - .title("General") - .build(); - - let start_enabled_row = adw::SwitchRow::builder() - .title("Start Enabled") - .subtitle("Enable Vietnamese input on startup") - .active(config.start_enabled) - .build(); - - let app_memory_row = adw::SwitchRow::builder() - .title("App Memory") - .subtitle("Remember per-app Vietnamese/English state") - .active(config.app_state.enabled) - .build(); - - let auto_restore_row = adw::SwitchRow::builder() - .title("Auto Restore English") - .subtitle("Automatically restore common English words") - .active(config.auto_restore.enabled) - .build(); - - let autostart_row = adw::SwitchRow::builder() - .title("Autostart on Boot") - .subtitle("Start Viet+ automatically when your system starts") - .active(crate::config::is_autostart_installed()) - .build(); - - general_group.add(&start_enabled_row); - general_group.add(&app_memory_row); - general_group.add(&auto_restore_row); - general_group.add(&autostart_row); - typing_box.append(&general_group); - - let typing_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build(); - typing_clamp.set_child(Some(&typing_box)); - let typing_scrolled = gtk::ScrolledWindow::builder() - .vexpand(true) - .hscrollbar_policy(gtk::PolicyType::Never) - .child(&typing_clamp) - .build(); - stack.add_titled(&typing_scrolled, Some("typing"), "Typing"); - - // ==================== Page 2: Apps ==================== - let apps_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(16) - .margin_bottom(16) - .margin_start(16) - .margin_end(16) - .build(); - - let apps_group = adw::PreferencesGroup::builder() - .title("Application Lists") - .description("Override input method for specific applications") - .build(); - - // English apps - let english_list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - - for app in &config.app_state.english_apps { - english_list.append(&Self::make_app_row_static(app, &english_list)); - } - - let english_entry = gtk::SearchEntry::builder() - .placeholder_text("Add application name...") - .hexpand(true) - .build(); - - let english_add = gtk::Button::builder() - .icon_name("list-add-symbolic") - .css_classes(["flat", "accent"]) - .tooltip_text("Add application") - .build(); - - let english_input = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(4) - .build(); - english_input.append(&english_entry); - english_input.append(&english_add); - - let english_header = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .build(); - let english_label = gtk::Label::builder() - .label("English Mode (Telex disabled)") - .halign(gtk::Align::Start) - .css_classes(["heading", "dim-label"]) - .build(); - english_header.append(&english_label); - english_header.append(&english_list); - english_header.append(&english_input); - - let english_row = adw::ActionRow::builder() - .title("English Applications") - .activatable(false) - .build(); - english_row.add_suffix(&english_header); - apps_group.add(&english_row); - - // Vietnamese apps - let viet_list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - - for app in &config.app_state.vietnamese_apps { - viet_list.append(&Self::make_app_row_static(app, &viet_list)); - } - - let viet_entry = gtk::SearchEntry::builder() - .placeholder_text("Add application name...") - .hexpand(true) - .build(); - - let viet_add = gtk::Button::builder() - .icon_name("list-add-symbolic") - .css_classes(["flat", "accent"]) - .tooltip_text("Add application") - .build(); - - let viet_input = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(4) - .build(); - viet_input.append(&viet_entry); - viet_input.append(&viet_add); - - let viet_header = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .build(); - let viet_label = gtk::Label::builder() - .label("Vietnamese Mode (Telex enabled)") - .halign(gtk::Align::Start) - .css_classes(["heading", "dim-label"]) - .build(); - viet_header.append(&viet_label); - viet_header.append(&viet_list); - viet_header.append(&viet_input); - - let viet_row = adw::ActionRow::builder() - .title("Vietnamese Applications") - .activatable(false) - .build(); - viet_row.add_suffix(&viet_header); - apps_group.add(&viet_row); - - apps_box.append(&apps_group); - - let apps_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build(); - apps_clamp.set_child(Some(&apps_box)); - let apps_scrolled = gtk::ScrolledWindow::builder() - .vexpand(true) - .hscrollbar_policy(gtk::PolicyType::Never) - .child(&apps_clamp) - .build(); - stack.add_titled(&apps_scrolled, Some("apps"), "Apps"); - - // ==================== Page 3: Shortcuts ==================== - let shortcuts_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(16) - .margin_bottom(16) - .margin_start(16) - .margin_end(16) - .build(); - - // ========== Macros Section ========== - let macros_group = adw::PreferencesGroup::builder() - .title("Macros") - .description("Type shortcuts that expand to Vietnamese phrases") - .build(); - - let macros_list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - - for (shortcut, expansion) in &config.macros { - macros_list.append(&Self::make_macro_row_static(shortcut, expansion, ¯os_list)); - } - - let macro_shortcut = gtk::SearchEntry::builder() - .placeholder_text("ko") - .width_chars(8) - .build(); - - let macro_expansion = gtk::SearchEntry::builder() - .placeholder_text("không") - .hexpand(true) - .build(); - - let macro_add = gtk::Button::builder() - .icon_name("list-add-symbolic") - .css_classes(["flat", "accent"]) - .tooltip_text("Add macro") - .build(); - - let macro_input = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(4) - .build(); - macro_input.append(¯o_shortcut); - macro_input.append(>k::Label::builder().label("→").css_classes(["dim-label"]).build()); - macro_input.append(¯o_expansion); - macro_input.append(¯o_add); - - macros_group.add(¯os_list); - macros_group.add(¯o_input); - shortcuts_box.append(¯os_group); - - // ========== Reference Card ========== - let ref_group = adw::PreferencesGroup::builder() - .title("Quick Reference") - .build(); - - let ref_row = adw::ActionRow::builder() - .title("Common Shortcuts") - .subtitle("ko→không, dc→được, vs→với, lm→làm") - .activatable(false) - .build(); - - let ref_icon = gtk::Image::builder() - .icon_name("dialog-information-symbolic") - .tooltip_text("Type these shortcuts followed by space") - .build(); - ref_row.add_suffix(&ref_icon); - - ref_group.add(&ref_row); - shortcuts_box.append(&ref_group); - - let shortcuts_clamp = adw::Clamp::builder().maximum_size(540).tightening_threshold(400).build(); - shortcuts_clamp.set_child(Some(&shortcuts_box)); - let shortcuts_scrolled = gtk::ScrolledWindow::builder() - .vexpand(true) - .hscrollbar_policy(gtk::PolicyType::Never) - .child(&shortcuts_clamp) - .build(); - stack.add_titled(&shortcuts_scrolled, Some("shortcuts"), "Shortcuts"); - - // ========== Status Bar ========== - let status_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .margin_top(8) - .build(); - - let status_icon = gtk::Image::builder() - .icon_name("emblem-ok-symbolic") - .build(); - - let status_label = gtk::Label::builder() - .label("Ready") - .hexpand(true) - .halign(gtk::Align::Start) - .css_classes(["dim-label"]) - .build(); - - status_box.append(&status_icon); - status_box.append(&status_label); - - main_box.append(&stack); - main_box.append(&status_box); - - toast_overlay.set_child(Some(&main_box)); - adw::prelude::AdwApplicationWindowExt::set_content(self, Some(&toast_overlay)); - - // ========== Callbacks ========== - - // Mark dirty on any change - { - let win = self.clone(); - method_row.connect_selected_notify(move |_| { win.mark_dirty(); }); - } - { - let win = self.clone(); - toggle_row.connect_selected_notify(move |_| { win.mark_dirty(); }); - } - { - let win = self.clone(); - start_enabled_row.connect_active_notify(move |_| { win.mark_dirty(); }); - } - { - let win = self.clone(); - app_memory_row.connect_active_notify(move |_| { win.mark_dirty(); }); - } - { - let win = self.clone(); - auto_restore_row.connect_active_notify(move |_| { win.mark_dirty(); }); - } - { - let win = self.clone(); - autostart_row.connect_active_notify(move |_| { win.mark_dirty(); }); - } - - // Add English app - self.setup_add_app(&english_entry, &english_add, &english_list, &status_label, &status_icon); - - // Add Vietnamese app - self.setup_add_app(&viet_entry, &viet_add, &viet_list, &status_label, &status_icon); - - // Add macro - self.setup_add_macro(¯o_shortcut, ¯o_expansion, ¯o_add, ¯os_list, &status_label, &status_icon); - - // Save button - { - let method_row = method_row.clone(); - let toggle_row = toggle_row.clone(); - let start_switch = start_enabled_row.clone(); - let app_switch = app_memory_row.clone(); - let auto_switch = auto_restore_row.clone(); - let autostart_switch = autostart_row.clone(); - let english = english_list.clone(); - let viet = viet_list.clone(); - let macros = macros_list.clone(); - let status_label = status_label.clone(); - let status_icon = status_icon.clone(); - let toast_overlay = toast_overlay.clone(); - let win = self.clone(); - let trigger_keys = trigger_keys.clone(); - - save_btn.connect_clicked(move |_| { - let method = match method_row.selected() { - 1 => "vni", - _ => "telex", - }; - let toggle = match toggle_row.selected() { - 1 => "shift", - 2 => "capslock", - _ => "space", - }; - - let english_apps = Self::collect_app_names(&english); - let vietnamese_apps = Self::collect_app_names(&viet); - let macro_map = Self::collect_macros(¯os); - - let config = Config { - input_method: method.into(), - toggle_key: toggle.into(), - start_enabled: start_switch.is_active(), - auto_restore: crate::config::AutoRestoreConfig { - enabled: auto_switch.is_active(), - trigger_keys: trigger_keys.clone(), - }, - app_state: crate::config::AppStateConfig { - enabled: app_switch.is_active(), - english_apps, - vietnamese_apps, - }, - macros: macro_map, - }; - - // Save autostart state - if autostart_switch.is_active() { - crate::config::install_autostart_force(); - } else { - crate::config::uninstall_autostart(); - } - - match config.save() { - Ok(()) => { - status_label.set_text(&format!("Saved to {}", Config::path().display())); - status_icon.set_icon_name(Some("emblem-ok-symbolic")); - status_label.remove_css_class("error"); - status_label.add_css_class("dim-label"); - - *win.imp().dirty.borrow_mut() = false; - - let toast = adw::Toast::new("Settings saved"); - toast.set_timeout(2); - toast_overlay.add_toast(toast); - } - Err(e) => { - status_label.set_text(&format!("Error: {}", e)); - status_icon.set_icon_name(Some("dialog-error-symbolic")); - status_label.remove_css_class("dim-label"); - status_label.add_css_class("error"); - - let toast = adw::Toast::new(&format!("Save failed: {}", e)); - toast.set_timeout(3); - toast_overlay.add_toast(toast); - } - } - }); - } - } - - fn setup_add_app( - &self, - entry: >k::SearchEntry, - add_btn: >k::Button, - list: >k::ListBox, - status_label: >k::Label, - status_icon: >k::Image, - ) { - let add_fn = { - let list = list.clone(); - let entry = entry.clone(); - let status_label = status_label.clone(); - let status_icon = status_icon.clone(); - let win = self.clone(); - move || { - let text = entry.text().to_string(); - if !text.is_empty() { - let row = Self::make_app_row_static(&text, &list); - list.append(&row); - entry.set_text(""); - status_label.set_text("Unsaved changes"); - status_icon.set_icon_name(Some("dialog-information-symbolic")); - win.mark_dirty(); - } - } - }; - - let add_fn2 = add_fn.clone(); - add_btn.connect_clicked(move |_| add_fn2()); - - let add_fn3 = add_fn.clone(); - entry.connect_activate(move |_| add_fn3()); - } - - fn setup_add_macro( - &self, - shortcut: >k::SearchEntry, - expansion: >k::SearchEntry, - add_btn: >k::Button, - list: >k::ListBox, - status_label: >k::Label, - status_icon: >k::Image, - ) { - let add_fn = { - let list = list.clone(); - let shortcut = shortcut.clone(); - let expansion = expansion.clone(); - let status_label = status_label.clone(); - let status_icon = status_icon.clone(); - let win = self.clone(); - move || { - let s = shortcut.text().to_string(); - let e = expansion.text().to_string(); - if !s.is_empty() && !e.is_empty() { - let row = Self::make_macro_row_static(&s, &e, &list); - list.append(&row); - shortcut.set_text(""); - expansion.set_text(""); - status_label.set_text("Unsaved changes"); - status_icon.set_icon_name(Some("dialog-information-symbolic")); - win.mark_dirty(); - } - } - }; - - let add_fn2 = add_fn.clone(); - add_btn.connect_clicked(move |_| add_fn2()); - - let add_fn3 = add_fn.clone(); - expansion.connect_activate(move |_| add_fn3()); - } - - fn make_app_row_static(app: &str, list: >k::ListBox) -> adw::ActionRow { - let row = adw::ActionRow::builder() - .title(app) - .activatable(false) - .build(); - - let remove_btn = gtk::Button::builder() - .icon_name("user-trash-symbolic") - .css_classes(["flat", "destructive-action"]) - .tooltip_text("Remove") - .build(); - - let list_ref = list.clone(); - let app_name = app.to_string(); - remove_btn.connect_clicked(move |_| { - let mut i = 0; - while let Some(child) = list_ref.row_at_index(i) { - if let Some(row) = child.downcast_ref::() { - if row.title() == app_name { - list_ref.remove(&child); - return; - } - } - i += 1; - } - }); - - row.add_suffix(&remove_btn); - row - } - - fn make_macro_row_static(shortcut: &str, expansion: &str, list: >k::ListBox) -> adw::ActionRow { - let row = adw::ActionRow::builder() - .title(shortcut) - .subtitle(expansion) - .activatable(false) - .build(); - - let arrow = gtk::Label::builder() - .label("→") - .css_classes(["dim-label"]) - .build(); - row.add_prefix(&arrow); - - let remove_btn = gtk::Button::builder() - .icon_name("user-trash-symbolic") - .css_classes(["flat", "destructive-action"]) - .tooltip_text("Remove") - .build(); - - let list_ref = list.clone(); - let shortcut_name = shortcut.to_string(); - remove_btn.connect_clicked(move |_| { - let mut i = 0; - while let Some(child) = list_ref.row_at_index(i) { - if let Some(row) = child.downcast_ref::() { - if row.title() == shortcut_name { - list_ref.remove(&child); - return; - } - } - i += 1; - } - }); - - row.add_suffix(&remove_btn); - row - } - - fn collect_app_names(list: >k::ListBox) -> Vec { - let mut names = Vec::new(); - let mut i = 0; - while let Some(child) = list.row_at_index(i) { - if let Some(row) = child.downcast_ref::() { - names.push(row.title().to_string()); - } - i += 1; - } - names - } - - fn collect_macros(list: >k::ListBox) -> std::collections::HashMap { - let mut map = std::collections::HashMap::new(); - let mut i = 0; - while let Some(child) = list.row_at_index(i) { - if let Some(row) = child.downcast_ref::() { - let shortcut = row.title().to_string(); - let expansion = row.subtitle().unwrap_or_default().to_string(); - if !shortcut.is_empty() { - map.insert(shortcut, expansion); - } - } - i += 1; - } - map - } -}