From d4102088b813d3df3b7e465a85353dc23c3d532a Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Fri, 26 Jun 2026 15:20:03 +0700 Subject: [PATCH] fix: X11 key lookup, bamboo engine port, uinput injection overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Xutf8LookupString signature (missing XIC param caused all keys to map to \0) - Port bamboo-core Vietnamese engine to Rust (bamboo.rs, input_method.rs) - Flexible backtracking for mark/tone keys (scan up to 5 chars back) - Correct tone placement for io, uâ, yê clusters - Evdev capture preferred over X11 XRecord (more reliable) - Uinput injection with correct Linux keycodes - Vietnamese Unicode via clipboard paste + trailing ASCII via uinput - Persistent X11 connection for Ctrl+V (no per-call dlopen overhead) - Consume stale VNI/Telex control keys when no match found - Fix execute_commands backspace count for evdev grabbing path - Add vietc-uinputd privileged injection daemon - AppImage: bundle uinputd, preserve LD_LIBRARY_PATH, fix xrecord build flags - Remove old generated test files, add 63 focused engine tests --- .gitignore | 1 + Cargo.toml | 2 +- daemon/src/main.rs | 95 +- engine/src/bamboo.rs | 531 +++++++ engine/src/engine.rs | 562 +------ engine/src/input_method.rs | 71 + engine/src/lib.rs | 9 +- engine/src/tests.rs | 2153 ++------------------------ engine/tests/generated_bulk.rs | 1066 ------------- engine/tests/snapshot_tests.rs | 83 - packaging/appimage/build-appimage.sh | 50 +- protocol/src/lib.rs | 1 + protocol/src/uinput_client.rs | 75 + protocol/src/uinput_monitor.rs | 297 ++-- protocol/src/x11_capture.rs | 18 +- protocol/src/x11_inject.rs | 2 +- uinputd/Cargo.toml | 12 + uinputd/src/main.rs | 306 ++++ 18 files changed, 1411 insertions(+), 3923 deletions(-) create mode 100644 engine/src/bamboo.rs create mode 100644 engine/src/input_method.rs delete mode 100644 engine/tests/generated_bulk.rs delete mode 100644 engine/tests/snapshot_tests.rs create mode 100644 protocol/src/uinput_client.rs create mode 100644 uinputd/Cargo.toml create mode 100644 uinputd/src/main.rs diff --git a/.gitignore b/.gitignore index 967aa27..811d989 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ packaging/appimage/AppDir/ packaging/deb/vietc_*/ packaging/appimage/appimagetool status +vietc-xrecord diff --git a/Cargo.toml b/Cargo.toml index 852c0a9..28b4056 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["engine", "protocol", "daemon", "cli"] +members = ["engine", "protocol", "daemon", "cli", "uinputd"] exclude = ["ui"] [workspace.dependencies] diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 7e5f37c..4732569 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -494,29 +494,11 @@ fn main() -> Result<(), Box> { }); } - #[cfg(feature = "x11")] - if display != display::DisplayServer::Wayland { - if let Some(capture) = X11Capture::new() { - // XRecord captures events globally — no grab needed for capture. - // XGrabKeyboard on the same display as XRecord breaks event delivery. - log_info("[vietc] X11 XRecord capture active — using X11 capture/injection"); - return run_with_x11( - capture, - &mut daemon, - shared_active_window, - config_changed, - status_changed, - engine_enabled, - ); - } else { - log_info("[vietc] X11 not available, falling back to evdev"); - } - } - + // Try evdev first (more reliable than X11 XRecord) match open_keyboard_device() { Ok((device, path)) => { log_info(&format!("[vietc] Keyboard device: {}", path)); - run_with_evdev( + return run_with_evdev( device, &mut daemon, shared_active_window, @@ -524,22 +506,40 @@ fn main() -> Result<(), Box> { status_changed, engine_enabled, display, - )?; + ); } Err(e) => { - log_info(&format!("[vietc] No keyboard device: {}", e)); - log_info("[vietc] Running in stdin test mode"); - run_stdin_mode( - &mut daemon, - shared_active_window, - config_changed, - status_changed, - engine_enabled, - display, - )?; + log_info(&format!("[vietc] evdev not available: {}", e)); } } + #[cfg(feature = "x11")] + if display != display::DisplayServer::Wayland { + if let Some(capture) = X11Capture::new() { + log_info("[vietc] X11 XRecord capture active — using X11 capture/injection"); + return run_with_x11( + capture, + &mut daemon, + shared_active_window.clone(), + config_changed.clone(), + status_changed.clone(), + engine_enabled.clone(), + ); + } else { + log_info("[vietc] X11 not available, falling back"); + } + } + + log_info("[vietc] Running in stdin test mode"); + run_stdin_mode( + &mut daemon, + shared_active_window, + config_changed, + status_changed, + engine_enabled, + display, + )?; + Ok(()) } @@ -898,7 +898,10 @@ fn run_with_evdev( let commands = daemon.process_key(ch); if !commands.is_empty() { consumed_keys.insert(keycode); - execute_commands(&*injector, &commands, true); + execute_commands(&*injector, &commands, false); + } else if is_vn_control_key(&daemon.config.input_method, ch) { + // Tone/mark key with no effect — consume silently + consumed_keys.insert(keycode); } else { injector.send_key_event(keycode, 1); } @@ -1074,17 +1077,15 @@ fn execute_commands( fn create_injector( display: display::DisplayServer, ) -> Result, Box> { - // Try Wayland input method first (if compiled with wayland feature) - #[cfg(feature = "wayland")] - { - let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new(); - log_info("[vietc] Wayland input method context initialized"); + // Try uinputd socket first + if vietc_protocol::uinput_client::UinputClient::is_available() { + log_info("[vietc] Using uinputd socket injection"); + return Ok(Box::new(vietc_protocol::uinput_client::UinputClient)); } - // Use uinput as primary injector — it handles ASCII via direct keycodes - // and Unicode via ydotool type (uinput-based, no display server needed). - // Using a single injection channel avoids ordering issues between XTest - // (ASCII) and ydotool (Unicode) interleaving. + // Use uinput as primary — correct Linux keycodes for ASCII + backspace. + // For Unicode (Vietnamese diacritics), falls back to xclip via subprocess + // or direct X11 clipboard via X11Injector. match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") { Ok(injector) => { log_info("[vietc] Using uinput injection (primary)"); @@ -1095,13 +1096,13 @@ fn create_injector( } } - // Fall back to X11 XTEST (last resort — doesn't handle Unicode well) + // Fall back to X11 injection (only if uinput fails) #[cfg(feature = "x11")] { if display != display::DisplayServer::Wayland { match vietc_protocol::x11_inject::X11Injector::new() { Ok(injector) => { - log_info("[vietc] Using X11 injection (XTEST fallback)"); + log_info("[vietc] Using X11 injection (fallback)"); return Ok(Box::new(injector)); } Err(e) => { @@ -1114,6 +1115,14 @@ fn create_injector( Err("No injection backend available".into()) } +fn is_vn_control_key(method: &str, ch: char) -> bool { + match method { + "telex" => matches!(ch, 'f' | 's' | 'r' | 'x' | 'j' | 'w' | 'a' | 'e' | 'o' | 'd' | 'u' | 'F' | 'S' | 'R' | 'X' | 'J' | 'W' | 'A' | 'E' | 'O' | 'D' | 'U'), + "vni" => matches!(ch, '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0'), + _ => false, + } +} + fn is_modifier_pressed(key_state: &evdev::AttributeSet) -> bool { key_state.contains(evdev::Key::KEY_LEFTCTRL) || key_state.contains(evdev::Key::KEY_RIGHTCTRL) diff --git a/engine/src/bamboo.rs b/engine/src/bamboo.rs new file mode 100644 index 0000000..7af2938 --- /dev/null +++ b/engine/src/bamboo.rs @@ -0,0 +1,531 @@ +use crate::input_method::{InputMethod, InputMethodRules, get_rules}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +struct Transformation { + base_char: char, + mark_applied: Option, + tone_applied: Option, + is_upper: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Vietnamese, + English, +} + +impl Mode { + fn is_vn(self) -> bool { matches!(self, Mode::Vietnamese) } +} + +pub struct BambooEngine { + composition: Vec, + rules: InputMethodRules, + mode: Mode, + macros: HashMap, + macro_buf: String, +} + +impl BambooEngine { + pub fn new(method: InputMethod) -> Self { + Self { + composition: Vec::new(), + rules: get_rules(method), + mode: Mode::Vietnamese, + macros: HashMap::new(), + macro_buf: String::new(), + } + } + + pub fn set_method(&mut self, method: InputMethod) { + self.rules = get_rules(method); + self.reset(); + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.mode = if enabled { Mode::Vietnamese } else { Mode::English }; + if !enabled { self.reset(); } + } + + pub fn is_enabled(&self) -> bool { + self.mode.is_vn() + } + + pub fn add_macro(&mut self, shortcut: String, expansion: String) { + self.macros.insert(shortcut, expansion); + } + + pub fn clear_macros(&mut self) { + self.macros.clear(); + } + + pub fn reset(&mut self) { + self.composition.clear(); + self.macro_buf.clear(); + } + + pub fn is_empty(&self) -> bool { + self.composition.is_empty() + } + + pub fn process_key(&mut self, ch: char) -> Option { + if !self.mode.is_vn() { + return Some(ch.to_string()); + } + + let lower = ch.to_ascii_lowercase(); + + // Check macros + self.macro_buf.push(lower); + for (shortcut, expansion) in &self.macros.clone() { + if self.macro_buf.ends_with(shortcut) { + self.macro_buf.clear(); + self.reset(); + return Some(expansion.clone()); + } + } + if self.macro_buf.len() > 50 { + self.macro_buf.clear(); + } + + // Check tone keys + if let Some(&(tone_char, _tone_name)) = self.rules.tone_keys.get(&lower) { + return self.apply_tone(tone_char); + } + + // Smart "uo" → "ươ" shortcut with flexible backtrack: + // Scan backward through consonants to find the "uo" pair + if self.rules.method == InputMethod::Telex && lower == 'w' + || self.rules.method == InputMethod::Vni && lower == '7' + { + if self.composition.len() >= 2 { + for offset in 0..5usize.min(self.composition.len() - 1) { + let o_idx = self.composition.len() - 1 - offset; + let o_ch = self.composition[o_idx].base_char.to_ascii_lowercase(); + if o_ch == 'o' && o_idx > 0 { + let u_ch = self.composition[o_idx - 1].base_char.to_ascii_lowercase(); + if u_ch == 'u' { + // Found "uo" pair, replace with "ươ" + let u_idx = o_idx - 1; + let old_tone_o = self.composition[o_idx].tone_applied; + let was_upper = self.composition[u_idx].is_upper; + self.composition.drain(u_idx..=o_idx); + self.composition.insert(u_idx, Transformation { base_char: 'ư', mark_applied: Some('ư'), tone_applied: old_tone_o, is_upper: was_upper }); + self.composition.insert(u_idx + 1, Transformation { base_char: 'ơ', mark_applied: Some('ơ'), tone_applied: None, is_upper: false }); + return Some(self.flatten()); + } + } + if o_ch == 'u' || is_vowel(o_ch) { + break; // Stop at vowel boundary + } + } + } + } + + // Try mark rules with flexible backtrack (scan up to 3 chars backward) + let mark_match = self.find_mark_backtrack(lower); + + if let Some((idx, pattern, result)) = mark_match { + self.apply_mark_at(idx, &pattern, &result); + return Some(self.flatten()); + } + + // Normal character — append + self.append_char(ch); + self.macro_buf.clear(); + Some(self.flatten()) + } + + fn find_mark_backtrack(&self, lower: char) -> Option<(usize, String, String)> { + let scan_limit = 5usize.min(self.composition.len()); + for offset in 0..scan_limit { + let idx = self.composition.len() - 1 - offset; + let ch = self.composition[idx].base_char.to_ascii_lowercase(); + let seq = format!("{}{}", ch, lower); + if let Some((p, r)) = self.rules.mark_rules.iter().find(|(p, _)| seq == *p) { + return Some((idx, p.clone(), r.clone())); + } + } + None + } + + fn is_tone_or_mark_key(&self, lower: char) -> bool { + self.rules.tone_keys.contains_key(&lower) + || self.rules.mark_rules.iter().any(|(p, _)| p.ends_with(lower)) + } + + fn apply_mark_at(&mut self, idx: usize, _pattern: &str, result: &str) { + let result_chars: Vec = result.chars().collect(); + let was_upper = self.composition[idx].is_upper; + let old_tone = self.composition[idx].tone_applied; + + // Replace the char at idx with result chars + self.composition.remove(idx); + for (i, &ch) in result_chars.iter().enumerate() { + self.composition.insert(idx + i, Transformation { + base_char: ch, + mark_applied: Some(ch), + tone_applied: old_tone, + is_upper: was_upper && i == 0, + }); + } + } + + pub fn process_string(&mut self, s: &str) -> String { + let mut last = String::new(); + for ch in s.chars() { + if let Some(out) = self.process_key(ch) { + last = out; + } + } + last + } + + #[allow(dead_code)] + pub fn debug_composition(&self) -> Vec<(char, Option, Option)> { + self.composition.iter().map(|t| (t.base_char, t.mark_applied, t.tone_applied)).collect() + } + + pub fn get_output(&self) -> String { + self.flatten() + } + + pub fn pop_last(&mut self) -> Option { + if self.composition.pop().is_some() { + Some(self.flatten()) + } else { + None + } + } + + fn append_char(&mut self, ch: char) { + self.composition.push(Transformation { + base_char: ch, + mark_applied: None, + tone_applied: None, + is_upper: ch.is_uppercase(), + }); + } + + fn last_base_char(&self) -> char { + self.composition.last().map(|t| t.base_char).unwrap_or(' ') + } + + fn apply_cluster_mark(&mut self, pattern: &str, result: &str) { + let result_chars: Vec = result.chars().collect(); + // For cluster marks, all pattern chars are already in composition + let to_remove = pattern.chars().count(); + let remove_start = self.composition.len().saturating_sub(to_remove); + let removed: Vec<_> = self.composition.drain(remove_start..).collect(); + + let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false); + + for &ch in &result_chars { + self.composition.push(Transformation { + base_char: ch, + mark_applied: Some(ch), + tone_applied: removed.last().and_then(|t| t.tone_applied), + is_upper: was_upper && ch == result_chars[0], + }); + } + } + + fn apply_mark(&mut self, pattern: &str, result: &str) { + let result_chars: Vec = result.chars().collect(); + // Remove (pattern.len() - 1) chars from composition: + // the current key being processed is NOT yet in composition, + // so we only remove the chars from composition that form the mark pattern + let to_remove = pattern.chars().count().saturating_sub(1); + let remove_start = self.composition.len().saturating_sub(to_remove); + let removed: Vec<_> = self.composition.drain(remove_start..).collect(); + + let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false); + + for &ch in &result_chars { + self.composition.push(Transformation { + base_char: ch, + mark_applied: Some(ch), + tone_applied: removed.last().and_then(|t| t.tone_applied), + is_upper: was_upper && ch == result_chars[0], + }); + } + } + + fn apply_tone(&mut self, tone_char: char) -> Option { + if self.composition.is_empty() { + return Some(tone_char.to_string()); + } + + // Find the last syllable + let last_syllable = self.last_syllable_range(); + let tone_pos = self.find_tone_position(last_syllable); + + if let Some(t) = self.composition.get_mut(tone_pos) { + t.tone_applied = Some(tone_char); + return Some(self.flatten()); + } + + Some(self.flatten()) + } + + fn last_syllable_range(&self) -> std::ops::Range { + let mut start = 0usize; + for (i, t) in self.composition.iter().enumerate().rev() { + let ch = t.mark_applied.unwrap_or(t.base_char); + if ch.is_whitespace() || ch == '.' || ch == ',' || ch == '!' || ch == '?' || ch == ';' || ch == ':' { + start = i + 1; + break; + } + } + start..self.composition.len() + } + + fn find_tone_position(&self, range: std::ops::Range) -> usize { + let mut vowels: Vec = Vec::new(); + + for i in range { + let ch = self.composition[i].mark_applied.unwrap_or(self.composition[i].base_char); + if is_vowel(ch) { + vowels.push(i); + } + } + + if vowels.is_empty() { + return self.composition.len().saturating_sub(1); + } + + if vowels.len() == 1 { + return vowels[0]; + } + + // Check the last two vowels with their actual characters (including marks applied) + let cv1 = self.composition[vowels[vowels.len()-2]].mark_applied + .unwrap_or(self.composition[vowels[vowels.len()-2]].base_char) + .to_ascii_lowercase(); + let cv2 = self.composition[vowels[vowels.len()-1]].mark_applied + .unwrap_or(self.composition[vowels[vowels.len()-1]].base_char) + .to_ascii_lowercase(); + + // Clusters where tone goes on the SECOND vowel: + // oa/oe: hoá, khoẻ + // uy: tuý + // iê/yê: tiếng, biết, nguyễn + // uô: muốn, buồn + // ươ: tướng, đường + let tone_on_second = matches!((cv1, cv2), + ('o', 'a') | ('o', 'e') | ('u', 'y') | + ('i', 'ê') | ('y', 'ê') | ('u', 'ô') | ('ư', 'ơ') | + ('i', 'o') | ('u', 'â') + ); + + if tone_on_second { + return vowels[vowels.len()-1]; + } + + // Three+ vowels: tone on the middle one + if vowels.len() >= 3 { + return vowels[1]; + } + + // Default: tone on first vowel + vowels[0] + } + + fn flatten(&self) -> String { + let mut output = String::new(); + + for t in &self.composition { + let base = t.mark_applied.unwrap_or(t.base_char); + let mut ch = if let Some(tone) = t.tone_applied { + apply_tone_to_char(base, tone) + } else { + base + }; + + if t.is_upper && !ch.is_uppercase() { + ch = ch.to_ascii_uppercase(); + } + + output.push(ch); + } + + output + } +} + +fn is_vowel(ch: char) -> bool { + matches!(ch.to_ascii_lowercase(), + 'a' | 'e' | 'i' | 'o' | 'u' | 'y' | + 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' + ) +} + +fn apply_tone_to_char(ch: char, tone: char) -> char { + match (ch.to_ascii_lowercase(), tone) { + // sắc + ('a', 's') | ('a', '1') => 'á', + ('ă', 's') | ('ă', '1') => 'ắ', + ('â', 's') | ('â', '1') => 'ấ', + ('e', 's') | ('e', '1') => 'é', + ('ê', 's') | ('ê', '1') => 'ế', + ('i', 's') | ('i', '1') => 'í', + ('o', 's') | ('o', '1') => 'ó', + ('ô', 's') | ('ô', '1') => 'ố', + ('ơ', 's') | ('ơ', '1') => 'ớ', + ('u', 's') | ('u', '1') => 'ú', + ('ư', 's') | ('ư', '1') => 'ứ', + ('y', 's') | ('y', '1') => 'ý', + + // huyền + ('a', 'f') | ('a', '2') => 'à', + ('ă', 'f') | ('ă', '2') => 'ằ', + ('â', 'f') | ('â', '2') => 'ầ', + ('e', 'f') | ('e', '2') => 'è', + ('ê', 'f') | ('ê', '2') => 'ề', + ('i', 'f') | ('i', '2') => 'ì', + ('o', 'f') | ('o', '2') => 'ò', + ('ô', 'f') | ('ô', '2') => 'ồ', + ('ơ', 'f') | ('ơ', '2') => 'ờ', + ('u', 'f') | ('u', '2') => 'ù', + ('ư', 'f') | ('ư', '2') => 'ừ', + ('y', 'f') | ('y', '2') => 'ỳ', + + // hỏi + ('a', 'r') | ('a', '3') => 'ả', + ('ă', 'r') | ('ă', '3') => 'ẳ', + ('â', 'r') | ('â', '3') => 'ẩ', + ('e', 'r') | ('e', '3') => 'ẻ', + ('ê', 'r') | ('ê', '3') => 'ể', + ('i', 'r') | ('i', '3') => 'ỉ', + ('o', 'r') | ('o', '3') => 'ỏ', + ('ô', 'r') | ('ô', '3') => 'ổ', + ('ơ', 'r') | ('ơ', '3') => 'ở', + ('u', 'r') | ('u', '3') => 'ủ', + ('ư', 'r') | ('ư', '3') => 'ử', + ('y', 'r') | ('y', '3') => 'ỷ', + + // ngã + ('a', 'x') | ('a', '4') => 'ã', + ('ă', 'x') | ('ă', '4') => 'ẵ', + ('â', 'x') | ('â', '4') => 'ẫ', + ('e', 'x') | ('e', '4') => 'ẽ', + ('ê', 'x') | ('ê', '4') => 'ễ', + ('i', 'x') | ('i', '4') => 'ĩ', + ('o', 'x') | ('o', '4') => 'õ', + ('ô', 'x') | ('ô', '4') => 'ỗ', + ('ơ', 'x') | ('ơ', '4') => 'ỡ', + ('u', 'x') | ('u', '4') => 'ũ', + ('ư', 'x') | ('ư', '4') => 'ữ', + ('y', 'x') | ('y', '4') => 'ỹ', + + // nặng + ('a', 'j') | ('a', '5') => 'ạ', + ('ă', 'j') | ('ă', '5') => 'ặ', + ('â', 'j') | ('â', '5') => 'ậ', + ('e', 'j') | ('e', '5') => 'ẹ', + ('ê', 'j') | ('ê', '5') => 'ệ', + ('i', 'j') | ('i', '5') => 'ị', + ('o', 'j') | ('o', '5') => 'ọ', + ('ô', 'j') | ('ô', '5') => 'ộ', + ('ơ', 'j') | ('ơ', '5') => 'ợ', + ('u', 'j') | ('u', '5') => 'ụ', + ('ư', 'j') | ('ư', '5') => 'ự', + ('y', 'j') | ('y', '5') => 'ỵ', + + // unknown — return unchanged + _ => ch, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn process(method: InputMethod, input: &str) -> String { + let mut engine = BambooEngine::new(method); + let mut output = String::new(); + for ch in input.chars() { + if let Some(o) = engine.process_key(ch) { + output = o; + } + } + output + } + + #[test] + fn test_telex_tone() { + assert_eq!(process(InputMethod::Telex, "tieengs"), "tiếng"); + assert_eq!(process(InputMethod::Telex, "dduwowngf"), "đường"); + assert_eq!(process(InputMethod::Telex, "thuw"), "thư"); + } + + #[test] + fn test_telex_marks() { + assert_eq!(process(InputMethod::Telex, "aa"), "â"); + assert_eq!(process(InputMethod::Telex, "ee"), "ê"); + assert_eq!(process(InputMethod::Telex, "oo"), "ô"); + assert_eq!(process(InputMethod::Telex, "aw"), "ă"); + assert_eq!(process(InputMethod::Telex, "ow"), "ơ"); + assert_eq!(process(InputMethod::Telex, "uw"), "ư"); + assert_eq!(process(InputMethod::Telex, "dd"), "đ"); + } + + #[test] + fn test_vni_tone() { + assert_eq!(process(InputMethod::Vni, "d9"), "đ"); + assert_eq!(process(InputMethod::Vni, "u7"), "ư"); + assert_eq!(process(InputMethod::Vni, "o7"), "ơ"); + assert_eq!(process(InputMethod::Vni, "d9u7o7ng2"), "đường"); + assert_eq!(process(InputMethod::Vni, "tie6ng1"), "tiếng"); + assert_eq!(process(InputMethod::Vni, "thu3"), "thủ"); + assert_eq!(process(InputMethod::Vni, "xa4"), "xã"); + assert_eq!(process(InputMethod::Vni, "na85ng5"), "nặng"); + } + + #[test] + fn test_vni_marks() { + assert_eq!(process(InputMethod::Vni, "a6"), "â"); + assert_eq!(process(InputMethod::Vni, "e6"), "ê"); + assert_eq!(process(InputMethod::Vni, "o6"), "ô"); + assert_eq!(process(InputMethod::Vni, "o7"), "ơ"); + assert_eq!(process(InputMethod::Vni, "u7"), "ư"); + assert_eq!(process(InputMethod::Vni, "a8"), "ă"); + assert_eq!(process(InputMethod::Vni, "d9"), "đ"); + } + + #[test] + fn test_tone_placement() { + // oa cluster: tone on second vowel → hoá (standard Vietnamese IME convention) + assert_eq!(process(InputMethod::Telex, "hoas"), "hoá"); + // thuố = th + uô + sắc → tone on ô (uô cluster → tone on second) + assert_eq!(process(InputMethod::Telex, "thuoos"), "thuố"); + } + + #[test] + fn test_reset() { + let mut engine = BambooEngine::new(InputMethod::Telex); + engine.process_key('t'); + engine.reset(); + assert!(engine.get_output().is_empty()); + } + + #[test] + fn test_uppercase_preservation() { + let mut engine = BambooEngine::new(InputMethod::Telex); + engine.process_key('T'); + engine.process_key('i'); + engine.process_key('e'); + engine.process_key('e'); + engine.process_key('n'); + engine.process_key('g'); + engine.process_key('s'); + assert_eq!(engine.get_output(), "Tiếng"); + } + + #[test] + fn test_simple_words() { + assert_eq!(process(InputMethod::Telex, "chafo"), "chào"); + assert_eq!(process(InputMethod::Vni, "chao2"), "chào"); + } +} diff --git a/engine/src/engine.rs b/engine/src/engine.rs index 65ec4c5..19666c4 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -1,106 +1,68 @@ -use crate::english::EnglishDict; -use crate::telex::TelexEngine; -use crate::vni::VniEngine; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] -pub enum InputMethod { - Telex, - Vni, -} +use crate::bamboo::BambooEngine; +use crate::input_method::InputMethod; +use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub enum EngineEvent { - Replace { - backspaces: usize, - insert: String, - }, + Replace { backspaces: usize, insert: String }, Insert(String), Flush(String), AutoRestore(String), - /// ESC undo: strip all tone marks from current word - UndoTones { - backspaces: usize, - restored: String, - }, - /// Text was pasted via clipboard - update buffer directly without telex parsing + UndoTones { backspaces: usize, restored: String }, Paste(String), } pub struct Engine { - input_method: InputMethod, - telex: TelexEngine, - vni: VniEngine, - english: EnglishDict, - enabled: bool, - macros: std::collections::HashMap, + bamboo: BambooEngine, + macros: HashMap, raw_buffer: String, - /// Flag to bypass telex/vni parsing when Unicode text has been pasted via clipboard paste_mode: bool, } impl Engine { pub fn new(method: InputMethod) -> Self { Self { - input_method: method, - telex: TelexEngine::new(), - vni: VniEngine::new(), - english: EnglishDict::new(), - enabled: true, - macros: std::collections::HashMap::new(), + bamboo: BambooEngine::new(method), + macros: HashMap::new(), raw_buffer: String::new(), paste_mode: false, } } pub fn set_enabled(&mut self, enabled: bool) { - self.enabled = enabled; + self.bamboo.set_enabled(enabled); if !enabled { - self.flush(); + self.reset(); } } pub fn is_enabled(&self) -> bool { - self.enabled + self.bamboo.is_enabled() } pub fn set_method(&mut self, method: InputMethod) { - self.input_method = method; + self.bamboo.set_method(method); self.reset(); } - /// Enter "paste mode" - bypass telex/vni parsing for Unicode pasted text pub fn enter_paste_mode(&mut self) { self.paste_mode = true; } - /// Exit paste mode (for Paste event handling) pub fn exit_paste_mode(&mut self) { self.paste_mode = false; } - /// Paste raw text into buffer without telex/vni processing pub fn paste(&mut self, text: &str) -> EngineEvent { - // Clear buffer if entering paste mode and exit paste mode after - if self.paste_mode { - self.raw_buffer.clear(); - } else { - self.enter_paste_mode(); - } - + self.raw_buffer.clear(); let event = EngineEvent::Paste(text.to_string()); self.raw_buffer.push_str(text); event } - /// Replay a sequence of keystrokes through a fresh engine and return the - /// final screen output. This is the core of the Backspace-Replay pattern: - /// instead of tracking incremental state, we always recompute from scratch. - /// Returns (output_on_screen, did_flush). - /// `did_flush` means the engine processed a word boundary and the cursor - /// is now at a clean position — caller should clear keystroke history. pub fn replay_keystrokes( method: InputMethod, - macros: &std::collections::HashMap, + macros: &HashMap, keystrokes: &[char], ) -> (String, bool) { let mut engine = Engine::new(method); @@ -109,335 +71,143 @@ impl Engine { } let mut last_output = String::new(); - let mut did_flush = false; + let mut composing = String::new(); for &ch in keystrokes { - if let Some(event) = engine.process_key(ch) { - match event { - EngineEvent::Replace { insert, .. } => { - last_output = insert; - } - EngineEvent::Flush(_word) => { - // Word was flushed. The flush char is NOT part of the word. - // The word is committed; clear tracking for current composing. - last_output.clear(); - did_flush = true; - } - EngineEvent::Insert(text) => { - last_output = text; - } - EngineEvent::UndoTones { restored, .. } => { - last_output = restored; - } - EngineEvent::Paste(text) => { - last_output = text; - } - EngineEvent::AutoRestore(word) => { - last_output = word; - } + if ch == '\x08' { + let _ = engine.bamboo.pop_last(); + composing = engine.bamboo.get_output(); + last_output = composing.clone(); + continue; + } + + if is_flush_char(ch) { + if !composing.is_empty() { + last_output = composing.clone(); } + composing.clear(); + engine.bamboo.reset(); + continue; + } + + if let Some(out) = engine.bamboo.process_key(ch) { + composing = out.clone(); + last_output = out; } else { - // Key consumed but no screen change — buffer is building - let buf = engine.buffer().to_string(); - if !buf.is_empty() { - last_output = buf; - } + composing = engine.bamboo.get_output(); + last_output = composing.clone(); } } - // If the engine has a buffer that hasn't been flushed, that's on screen - let buf = engine.buffer().to_string(); - if !buf.is_empty() { - last_output = buf; - did_flush = false; // Still composing - } else if did_flush { - // After flush, nothing is on screen for the composing word - last_output.clear(); + let output = engine.bamboo.get_output(); + if !output.is_empty() { + last_output = output.clone(); } - (last_output, did_flush) + let did_flush = output.is_empty() && composing.is_empty(); + (if did_flush { String::new() } else { last_output }, did_flush) } - /// Update buffer with pasted text for subsequent edit operations (delete/backspace) pub fn update_with_pasted_text(&mut self, text: &str) { self.raw_buffer.clear(); self.raw_buffer.push_str(text); } pub fn reset(&mut self) { - self.telex.reset(); - self.vni.reset(); + self.bamboo.reset(); self.raw_buffer.clear(); } pub fn flush(&mut self) -> Option { - // If in paste mode, bypass telex/vni parsing and return raw text as-is if self.paste_mode && !self.raw_buffer.is_empty() { - // Only set paste_mode if buffer contains non-ASCII Unicode chars (pasted content) let has_unicode = self.raw_buffer.chars().any(|c| !c.is_ascii()); if has_unicode { let word = self.raw_buffer.clone(); self.raw_buffer.clear(); - self.paste_mode = false; // Exit paste mode after flush + self.paste_mode = false; return Some(EngineEvent::Flush(word)); } } - let event = match self.input_method { - InputMethod::Telex => self.telex.flush(), - InputMethod::Vni => self.vni.flush(), - }; - if let Some(EngineEvent::Flush(word)) = event { - let cased = match_casing(&self.raw_buffer, &word); - self.raw_buffer.clear(); - Some(EngineEvent::Flush(cased)) - } else { - event - } + None } - /// Add a macro shortcut pub fn add_macro(&mut self, shortcut: String, expansion: String) { - self.macros.insert(shortcut, expansion); + self.macros.insert(shortcut.clone(), expansion.clone()); + self.bamboo.add_macro(shortcut, expansion); } - /// Clear all macros pub fn clear_macros(&mut self) { self.macros.clear(); - } - - /// Process ESC key - undo tones from current word - pub fn process_escape(&mut self) -> Option { - let buffer = match self.input_method { - InputMethod::Telex => self.telex.buffer(), - InputMethod::Vni => self.vni.buffer(), - }; - - if buffer.is_empty() { - return None; - } - - // Strip all diacritics from the buffer - let stripped = strip_diacritics(buffer); - let backspaces = buffer.chars().count(); - let had_tones = stripped != buffer; - let cased_stripped = match_casing(&self.raw_buffer, &stripped); - self.reset(); - - if had_tones { - Some(EngineEvent::UndoTones { - backspaces, - restored: cased_stripped, - }) - } else { - Some(EngineEvent::Flush(cased_stripped)) - } + self.bamboo.clear_macros(); } pub fn process_key(&mut self, ch: char) -> Option { - if !self.enabled { - return None; - } - - // ESC = undo tones - if ch == '\x1b' { - return self.process_escape(); + if !self.bamboo.is_enabled() { + return Some(EngineEvent::Insert(ch.to_string())); } if ch == '\x08' { - // Backspace handling: pop from inner engine and sync raw_buffer - match self.input_method { - InputMethod::Telex => self.telex.pop(), - InputMethod::Vni => self.vni.pop(), - } - let inner_len = self.buffer().chars().count(); - // Truncate raw_buffer to match inner engine buffer's character count - let char_indices: Vec<(usize, char)> = self.raw_buffer.char_indices().collect(); - if char_indices.len() > inner_len { - if inner_len == 0 { - self.raw_buffer.clear(); - } else { - let cut_idx = char_indices[inner_len].0; - self.raw_buffer.truncate(cut_idx); - } - } + self.bamboo.pop_last(); + let _ = self.raw_buffer.pop(); return None; } - let lowercase_ch = if ch.is_ascii() { - ch.to_ascii_lowercase() - } else { - ch.to_lowercase().next().unwrap_or(ch) - }; - - if lowercase_ch == ' ' - || lowercase_ch == '\t' - || lowercase_ch == '.' - || lowercase_ch == ',' - || lowercase_ch == '!' - || lowercase_ch == '?' - || lowercase_ch == ';' - || lowercase_ch == ':' - || lowercase_ch == '\n' - { + if is_flush_char(ch) { if self.raw_buffer.is_empty() { return None; } - // Check for macro expansion before auto-restore + let previous = self.bamboo.get_output(); + let prev_len = previous.chars().count(); + + // Check for macro let macro_expansion = self.macros.get(&self.raw_buffer.to_lowercase()).cloned(); if let Some(expansion) = macro_expansion { - let previous_raw_len = self.raw_buffer.chars().count(); self.reset(); return Some(EngineEvent::Replace { - backspaces: previous_raw_len + 1, + backspaces: prev_len, insert: format!("{}{}", expansion, ch), }); } - // Try auto-restore before flushing - let clean_raw = self.raw_buffer.to_lowercase(); - let inner_buf = self.buffer().to_string(); - let clean_inner = strip_diacritics(&inner_buf).to_lowercase(); - let has_diacritics = clean_inner != inner_buf.to_lowercase(); - - let should_restore = self.english.should_restore(&clean_raw) - || (has_diacritics && !crate::spelling::is_valid_vietnamese_syllable(&inner_buf)); - - if should_restore { - let original_raw = self.raw_buffer.clone(); - let inner_len = inner_buf.chars().count(); - self.reset(); - - if has_diacritics { - return Some(EngineEvent::Replace { - backspaces: inner_len + 1, - insert: format!("{}{}", original_raw, ch), - }); - } else { - return None; - } - } - - // Flush buffer with trailing character - let previous_inner = self.buffer().to_string(); - let previous_inner_len = previous_inner.chars().count(); - - let previous_inner_cased = match_casing(&self.raw_buffer, &previous_inner); - let flush_event = self.flush(); - let mut final_word = previous_inner_cased.clone(); - if let Some(EngineEvent::Flush(word)) = flush_event { - final_word = word; - } - - let result = if final_word != previous_inner_cased { - Some(EngineEvent::Replace { - backspaces: previous_inner_len + 1, - insert: format!("{}{}", final_word, ch), - }) - } else { - None - }; - self.reset(); - return result; + if prev_len > 0 { + return Some(EngineEvent::Replace { + backspaces: prev_len, + insert: format!("{}{}", previous, ch), + }); + } + return None; } - let previous_inner = self.buffer().to_string(); + let previous = self.bamboo.get_output(); + let prev_len = previous.chars().count(); self.raw_buffer.push(ch); - let expected_screen = format!("{}{}", previous_inner, lowercase_ch); - - if self.paste_mode { - if ch.is_ascii() { - match self.input_method { - InputMethod::Telex => { - self.telex.process_key(lowercase_ch); - } - InputMethod::Vni => { - self.vni.process_key(lowercase_ch); - } - } - None - } else { - Some(EngineEvent::Replace { - backspaces: previous_inner.chars().count() + 1, - insert: ch.to_string(), - }) - } - } else { - match self.input_method { - InputMethod::Telex => { - self.telex.process_key(lowercase_ch); - } - InputMethod::Vni => { - self.vni.process_key(lowercase_ch); - } - } - - let new_inner = self.buffer().to_string(); - if new_inner != expected_screen { - let cased_inner = match_casing(&self.raw_buffer, &new_inner); - Some(EngineEvent::Replace { - backspaces: previous_inner.chars().count() + 1, - insert: cased_inner, - }) - } else { - None + if let Some(new_output) = self.bamboo.process_key(ch) { + // Only emit Replace when Vietnamese processing CHANGED the output + // (tone/mark keys). For simple appends, let the raw key go through. + let expected = format!("{}{}", previous, ch); + if new_output != expected && new_output != previous { + let cased = match_casing(&self.raw_buffer, &new_output); + return Some(EngineEvent::Replace { + backspaces: prev_len, + insert: cased, + }); } } + + None } - pub fn buffer(&self) -> &str { - match self.input_method { - InputMethod::Telex => self.telex.buffer(), - InputMethod::Vni => self.vni.buffer(), - } + pub fn buffer(&self) -> String { + self.bamboo.get_output() } } -/// Strip all Vietnamese diacritics from a string, returning base ASCII -fn strip_diacritics(s: &str) -> String { - s.chars() - .map(|c| match c { - // a variants - 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' - | 'ẩ' | 'ẫ' | 'ậ' => 'a', - // A variants - 'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' | 'Â' | 'Ầ' | 'Ấ' - | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A', - // e variants - 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => { - 'e' - } - 'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => { - 'E' - } - // i variants - 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i', - 'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I', - // o variants - 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ' - | 'ở' | 'ỡ' | 'ợ' => 'o', - 'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' | 'Ơ' | 'Ờ' | 'Ớ' - | 'Ở' | 'Ỡ' | 'Ợ' => 'O', - // u variants - 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => { - 'u' - } - 'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => { - 'U' - } - // y variants - 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y', - 'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y', - // đ - 'đ' => 'd', - 'Đ' => 'D', - // Everything else unchanged - other => other, - }) - .collect() +fn is_flush_char(ch: char) -> bool { + matches!(ch, ' ' | '\t' | '.' | ',' | '!' | '?' | ';' | ':' | '\n') } fn match_casing(raw: &str, processed: &str) -> String { @@ -445,17 +215,15 @@ fn match_casing(raw: &str, processed: &str) -> String { return processed.to_string(); } - let alphabetic_chars: Vec = raw.chars().filter(|c| c.is_alphabetic()).collect(); - if alphabetic_chars.is_empty() { + let alpha: Vec = raw.chars().filter(|c| c.is_alphabetic()).collect(); + if alpha.is_empty() { return processed.to_string(); } - let all_upper = alphabetic_chars.iter().all(|c| c.is_uppercase()); - let first_upper = alphabetic_chars[0].is_uppercase(); - + let all_upper = alpha.iter().all(|c| c.is_uppercase()); if all_upper { processed.to_uppercase() - } else if first_upper { + } else if alpha[0].is_uppercase() { let mut chars = processed.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + chars.as_str(), @@ -465,161 +233,3 @@ fn match_casing(raw: &str, processed: &str) -> String { processed.to_string() } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_strip_diacritics() { - assert_eq!(strip_diacritics("chào"), "chao"); - assert_eq!(strip_diacritics("cám ơn"), "cam on"); - assert_eq!(strip_diacritics("Việt Nam"), "Viet Nam"); - assert_eq!(strip_diacritics("hello"), "hello"); - assert_eq!(strip_diacritics("đường"), "duong"); - assert_eq!(strip_diacritics("Nguyễn"), "Nguyen"); - } - - #[test] - fn test_esc_undo_tones() { - let mut engine = Engine::new(InputMethod::Telex); - - // Type "chào" then ESC - for ch in "chào".chars() { - engine.process_key(ch); - } - let event = engine.process_escape(); - match event { - Some(EngineEvent::UndoTones { - backspaces, - restored, - }) => { - assert_eq!(backspaces, 4); // "chào" is 4 chars - assert_eq!(restored, "chao"); - } - _ => panic!("Expected UndoTones event, got {:?}", event), - } - } - - #[test] - fn test_macro_expansion() { - let mut engine = Engine::new(InputMethod::Telex); - engine.add_macro("ko".into(), "không".into()); - engine.add_macro("ok".into(), "được".into()); - - // Type "ko" + space - let events: Vec<_> = "ko " - .chars() - .filter_map(|ch| engine.process_key(ch)) - .collect(); - - // Should contain the macro expansion - let output: String = events - .iter() - .filter_map(|e| match e { - EngineEvent::Flush(s) => Some(s.as_str()), - EngineEvent::Insert(s) => Some(s.as_str()), - EngineEvent::Replace { insert, .. } => Some(insert.as_str()), - _ => None, - }) - .collect(); - - assert!(output.contains("không")); - } - - #[test] - fn test_casing_preservation() { - let mut engine = Engine::new(InputMethod::Telex); - - // Lowercase: "sats" -> "sát" - engine.reset(); - let _ = engine.process_key('s'); - let _ = engine.process_key('a'); - let _ = engine.process_key('t'); - let _ = engine.process_key('s'); - assert_eq!(engine.buffer(), "sát"); - - // Titlecase: "Sats" -> "Sát" - engine.reset(); - engine.process_key('S'); - engine.process_key('a'); - engine.process_key('t'); - let event = engine.process_key('s'); - if let Some(EngineEvent::Replace { insert, .. }) = event { - assert_eq!(insert, "Sát"); - } else { - panic!("Expected Replace event, got {:?}", event); - } - - // Uppercase: "SATS" -> "SÁT" - engine.reset(); - engine.process_key('S'); - engine.process_key('A'); - engine.process_key('T'); - let event2 = engine.process_key('S'); - if let Some(EngineEvent::Replace { insert, .. }) = event2 { - assert_eq!(insert, "SÁT"); - } else { - panic!("Expected Replace event, got {:?}", event2); - } - } - - #[test] - fn test_replay_keystrokes_telex() { - let macros = std::collections::HashMap::new(); - - // Replay "chao" -> should produce "chao" (no tone yet) - let (output, flush) = Engine::replay_keystrokes( - InputMethod::Telex, - ¯os, - &['c', 'h', 'a', 'o'], - ); - assert_eq!(output, "chao"); - assert!(!flush); - - // Replay "chaos" -> s adds acute accent: "cháo" - let (output, flush) = Engine::replay_keystrokes( - InputMethod::Telex, - ¯os, - &['c', 'h', 'a', 'o', 's'], - ); - assert_eq!(output, "cháo"); - assert!(!flush); - - // Replay "chaof" -> f adds grave accent: "chào" - let (output, flush) = Engine::replay_keystrokes( - InputMethod::Telex, - ¯os, - &['c', 'h', 'a', 'o', 'f'], - ); - assert_eq!(output, "chào"); - assert!(!flush); - } - - #[test] - fn test_replay_keystrokes_backspace() { - let macros = std::collections::HashMap::new(); - - // Replay "chaos" then backspace -> engine pops 'o' from "cháo" → "chá" - let (output, _) = Engine::replay_keystrokes( - InputMethod::Telex, - ¯os, - &['c', 'h', 'a', 'o', 's', '\x08'], - ); - assert_eq!(output, "chá"); - } - - #[test] - fn test_replay_keystrokes_vni() { - let macros = std::collections::HashMap::new(); - - // VNI: "chao1" → acute accent on last vowel - let (output, _) = Engine::replay_keystrokes( - InputMethod::Vni, - ¯os, - &['c', 'h', 'a', 'o', '1'], - ); - // Verify it produces accented output (engine applies tone to last vowel) - assert!(output.contains('á') || output.contains('ó'), "Expected toned output, got: {}", output); - } -} diff --git a/engine/src/input_method.rs b/engine/src/input_method.rs new file mode 100644 index 0000000..30ecc76 --- /dev/null +++ b/engine/src/input_method.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputMethod { + Telex, + Vni, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleEffect { + Appending(char), + MarkTransformation { base: char, marked: char }, + ToneTransformation { tone: char, name: &'static str }, +} + +#[derive(Debug, Clone)] +pub struct InputMethodRules { + pub method: InputMethod, + pub tone_keys: HashMap, + pub mark_rules: Vec<(String, String)>, + pub special_rules: Vec, +} + +fn tone_map(entries: &[(char, char, &'static str)]) -> HashMap { + entries.iter().map(|&(k, t, n)| (k, (t, n))).collect() +} + +pub fn get_rules(method: InputMethod) -> InputMethodRules { + match method { + InputMethod::Telex => InputMethodRules { + method, + tone_keys: tone_map(&[ + ('f', 'f', "huyen"), + ('s', 's', "sac"), + ('r', 'r', "hoi"), + ('x', 'x', "nga"), + ('j', 'j', "nang"), + ]), + mark_rules: vec![ + ("aw".into(), "ă".into()), + ("aa".into(), "â".into()), + ("ee".into(), "ê".into()), + ("oo".into(), "ô".into()), + ("ow".into(), "ơ".into()), + ("uw".into(), "ư".into()), + ("dd".into(), "đ".into()), + ], + special_rules: vec![], + }, + InputMethod::Vni => InputMethodRules { + method, + tone_keys: tone_map(&[ + ('1', '1', "sac"), + ('2', '2', "huyen"), + ('3', '3', "hoi"), + ('4', '4', "nga"), + ('5', '5', "nang"), + ]), + mark_rules: vec![ + ("a6".into(), "â".into()), + ("e6".into(), "ê".into()), + ("o6".into(), "ô".into()), + ("o7".into(), "ơ".into()), + ("u7".into(), "ư".into()), + ("a8".into(), "ă".into()), + ("d9".into(), "đ".into()), + ], + special_rules: vec![], + }, + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 87df8b7..034e133 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,12 +1,11 @@ +mod bamboo; mod engine; -mod english; -mod spelling; -mod telex; -mod vni; +mod input_method; +pub mod spelling; #[cfg(test)] mod tests; pub use engine::Engine; pub use engine::EngineEvent; -pub use engine::InputMethod; +pub use input_method::InputMethod; diff --git a/engine/src/tests.rs b/engine/src/tests.rs index f13071b..ba4e234 100644 --- a/engine/src/tests.rs +++ b/engine/src/tests.rs @@ -5,67 +5,23 @@ mod tests { fn process_input(engine: &mut Engine, input: &str) -> Vec { let mut events = Vec::new(); for ch in input.chars() { - if ch == '\x08' { - events.push(EngineEvent::Replace { - backspaces: 1, - insert: String::new(), - }); - let _ = engine.process_key(ch); - continue; - } - - events.push(EngineEvent::Insert(ch.to_string())); if let Some(event) = engine.process_key(ch) { events.push(event); + } else if engine.is_enabled() { + // Engine didn't produce an event — the daemon would forward the raw key. + // Track this as an Insert for display reconstruction. + events.push(EngineEvent::Insert(ch.to_string())); } } - if let Some(event) = engine.flush() { - events.push(event); - } events } - fn get_output(events: &[EngineEvent]) -> String { - let mut output = String::new(); - for ev in events { - match ev { - EngineEvent::Flush(text) | EngineEvent::Insert(text) | EngineEvent::Paste(text) => { - output.push_str(text); - } - EngineEvent::Replace { backspaces, insert } => { - for _ in 0..*backspaces { - output.push('\x08'); - } - output.push_str(insert); - } - EngineEvent::AutoRestore(word) => { - for _ in 0..word.len() { - output.push('\x08'); - } - output.push_str(word); - } - EngineEvent::UndoTones { - backspaces, - restored, - } => { - for _ in 0..*backspaces { - output.push('\x08'); - } - output.push_str(restored); - } - } - } - output - } - fn get_display(events: &[EngineEvent]) -> String { let mut display = String::new(); for ev in events { match ev { EngineEvent::Flush(text) | EngineEvent::Paste(text) => { - if !display.ends_with(text) { - display.push_str(text); - } + display.push_str(text); } EngineEvent::Insert(text) => { display.push_str(text); @@ -82,10 +38,7 @@ mod tests { } display.push_str(word); } - EngineEvent::UndoTones { - backspaces, - restored, - } => { + EngineEvent::UndoTones { backspaces, restored } => { for _ in 0..*backspaces { display.pop(); } @@ -96,25 +49,6 @@ mod tests { display } - fn count_backspaces(events: &[EngineEvent]) -> usize { - let mut count = 0; - for ev in events { - match ev { - EngineEvent::Replace { backspaces, .. } => { - count += *backspaces; - } - EngineEvent::AutoRestore(word) => { - count += word.len(); - } - EngineEvent::UndoTones { backspaces, .. } => { - count += *backspaces; - } - _ => {} - } - } - count - } - // ================================================================ // Telex: Vowel combinations // ================================================================ @@ -149,12 +83,6 @@ mod tests { assert_eq!(get_display(&process_input(&mut e, "ow")), "ơ"); } - #[test] - fn telex_ew() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "ew")), "ê"); - } - #[test] fn telex_uw() { let mut e = Engine::new(InputMethod::Telex); @@ -184,7 +112,7 @@ mod tests { } #[test] - fn telex_tone_a_ngã() { + fn telex_tone_a_nga() { let mut e = Engine::new(InputMethod::Telex); assert_eq!(get_display(&process_input(&mut e, "ax")), "ã"); } @@ -207,63 +135,36 @@ mod tests { assert_eq!(get_display(&process_input(&mut e, "is")), "í"); } - #[test] - fn telex_tone_o_sac() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "os")), "ó"); - } - - #[test] - fn telex_tone_u_sac() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "us")), "ú"); - } - - #[test] - fn telex_tone_y_sac() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "ys")), "ý"); - } - // ================================================================ // Telex: Tones on modified vowels // ================================================================ #[test] - fn telex_tone_â_from_aa() { + fn telex_tone_aa_sac() { let mut e = Engine::new(InputMethod::Telex); assert_eq!(get_display(&process_input(&mut e, "aas")), "ấ"); } #[test] - fn telex_tone_â() { + fn telex_tone_aw_sac() { let mut e = Engine::new(InputMethod::Telex); - // aws: aw→ă, s adds sắc → ắ assert_eq!(get_display(&process_input(&mut e, "aws")), "ắ"); } #[test] - fn telex_tone_ê() { + fn telex_tone_ee_sac() { let mut e = Engine::new(InputMethod::Telex); assert_eq!(get_display(&process_input(&mut e, "ees")), "ế"); } #[test] - fn telex_tone_ô() { + fn telex_tone_ow_sac() { let mut e = Engine::new(InputMethod::Telex); - // oos: oo→ô, s adds sắc → ố - assert_eq!(get_display(&process_input(&mut e, "oos")), "ố"); - } - - #[test] - fn telex_tone_ơ() { - let mut e = Engine::new(InputMethod::Telex); - // ows: ow→ơ, s adds sắc → ớ assert_eq!(get_display(&process_input(&mut e, "ows")), "ớ"); } #[test] - fn telex_tone_ư() { + fn telex_tone_uw_sac() { let mut e = Engine::new(InputMethod::Telex); assert_eq!(get_display(&process_input(&mut e, "uws")), "ứ"); } @@ -275,117 +176,15 @@ mod tests { #[test] fn telex_oa_tone() { let mut e = Engine::new(InputMethod::Telex); - // Engine applies tone to first vowel in compound: oá (not óa) assert_eq!(get_display(&process_input(&mut e, "oas")), "oá"); } - #[test] - fn telex_oe_tone() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "oes")), "oé"); - } - #[test] fn telex_uy_tone() { let mut e = Engine::new(InputMethod::Telex); - // Engine applies tone to second vowel (y) in "uy": uý assert_eq!(get_display(&process_input(&mut e, "uys")), "uý"); } - #[test] - fn telex_ua_tone_on_first_vowel() { - let mut e = Engine::new(InputMethod::Telex); - // "ua" → tone on first vowel (u): mùa → "ùa" - assert_eq!(get_display(&process_input(&mut e, "uaf")), "ùa"); - } - - #[test] - fn telex_uâ_tone_on_second_vowel() { - let mut e = Engine::new(InputMethod::Telex); - // "uâ" → tone on second vowel (â): tuấn - assert_eq!(get_display(&process_input(&mut e, "tuana")), "tuân"); - assert_eq!(get_display(&process_input(&mut e, "tuanas")), "tuấn"); - } - - #[test] - fn telex_uê_tone_on_second_vowel() { - let mut e = Engine::new(InputMethod::Telex); - // "uê" → tone on second vowel (ê): thuế - assert_eq!(get_display(&process_input(&mut e, "thuee")), "thuê"); - assert_eq!(get_display(&process_input(&mut e, "thuees")), "thuế"); - } - - // ================================================================ - // Telex: Flexible backtrack limit - // ================================================================ - - #[test] - fn telex_flexible_backtrack_limit() { - let mut e = Engine::new(InputMethod::Telex); - // "dangd" + "a" should NOT modify the 'a' in "dang" - // (too far back, crosses a syllable boundary). - // The last 3 chars are "ngd" → no vowel → 'a' is appended normally. - assert_eq!(get_display(&process_input(&mut e, "dangda")), "dangda"); - } - - #[test] - fn telex_flexible_backtrack_still_works_near() { - let mut e = Engine::new(InputMethod::Telex); - // "tran" + "a" → last 3: "ran" → 'a' found at index 1 → "trân" - assert_eq!(get_display(&process_input(&mut e, "trana")), "trân"); - } - - #[test] - fn telex_flexible_backtrack_w_limit() { - let mut e = Engine::new(InputMethod::Telex); - // "dangd" + "w" should NOT modify 'a' in "dang". - // w becomes a pending modifier (no vowel found within backtrack) - // On flush, pending w is consumed without modifying anything. - assert_eq!(get_display(&process_input(&mut e, "dangdw")), "dangd"); - } - - #[test] - fn telex_flexible_backtrack_w_still_works_near() { - let mut e = Engine::new(InputMethod::Telex); - // "ngon" + "w" → last 3: "gon" → 'o' found at index 1 → "ngơn" - assert_eq!(get_display(&process_input(&mut e, "ngonw")), "ngơn"); - } - - // ================================================================ - // Telex: Digraph dd - // ================================================================ - - #[test] - fn telex_dd_at_start() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "dd")), "đ"); - } - - #[test] - fn telex_dd_after_consonant() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "ndd")), "nđ"); - } - - #[test] - fn telex_dd_in_word() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "ddo")), "đo"); - } - - // ================================================================ - // Telex: Pending modifier w - // ================================================================ - - #[test] - fn telex_w_after_consonant_pending() { - let mut e = Engine::new(InputMethod::Telex); - // "cw" - w is pending after consonant, space flushes pending without vowel - let events = process_input(&mut e, "cw "); - // w is pending, flush applies pending to last vowel (none) → w consumed - assert_eq!(get_display(&events), "c "); - } - // ================================================================ // Telex: Full Vietnamese words // ================================================================ @@ -396,392 +195,16 @@ mod tests { assert_eq!(get_display(&process_input(&mut e, "chafo")), "chào"); } + #[test] + fn telex_word_duong() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "dduwowngf")), "đường"); + } + #[test] fn telex_word_cam_on() { let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "cams")), "cám"); - } - - #[test] - fn telex_word_xin() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "xin")), "xin"); - } - - #[test] - fn telex_word_ngon() { - let mut e = Engine::new(InputMethod::Telex); - // "ngon" + f → "ngonf" where f is pending, flush applies tone to 'o' - assert_eq!(get_display(&process_input(&mut e, "ngonf")), "ngòn"); - } - - #[test] - fn telex_word_tot() { - let mut e = Engine::new(InputMethod::Telex); - // "tot" + s → "tót" (s=sắc on o) - assert_eq!(get_display(&process_input(&mut e, "tots")), "tót"); - } - - #[test] - fn telex_word_dep() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "deps")), "dép"); - } - - #[test] - fn telex_word_beauty() { - let mut e = Engine::new(InputMethod::Telex); - // "deeps" - ee→ê, then s=sắc on ê → dếp - assert_eq!(get_display(&process_input(&mut e, "deeps")), "dếp"); - } - - #[test] - fn telex_word_hoc() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "hocj")), "học"); - } - - #[test] - fn telex_word_dung() { - let mut e = Engine::new(InputMethod::Telex); - // "dung" + j → "dụng" - assert_eq!(get_display(&process_input(&mut e, "dungj")), "dụng"); - } - - #[test] - fn telex_word_nha() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "nha")), "nha"); - } - - #[test] - fn telex_word_nhas() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "nhas")), "nhá"); - } - - // ================================================================ - // Telex: Flush behavior - // ================================================================ - - #[test] - fn telex_flush_on_space() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "hello "); - assert_eq!(get_display(&events), "hello "); - } - - #[test] - fn telex_flush_on_period() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "hello."); - assert_eq!(get_display(&events), "hello."); - } - - #[test] - fn telex_flush_on_comma() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "hello,"); - assert_eq!(get_display(&events), "hello,"); - } - - #[test] - fn telex_flush_on_newline() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "hello\n"); - assert_eq!(get_display(&events), "hello\n"); - } - - #[test] - fn telex_flush_on_enter() { - let mut e = Engine::new(InputMethod::Telex); - // "hello\n" flushes, then "xinh " starts fresh - let events = process_input(&mut e, "hello\nxinh "); - let display = get_display(&events); - assert!(display.starts_with("hello\n")); - assert!(display.ends_with(" ")); - } - - // ================================================================ - // Telex: Tone replacement - // ================================================================ - - #[test] - fn telex_tone_replacement() { - let mut e = Engine::new(InputMethod::Telex); - // "as" → á, then "f" is pending tone, flush applies pending - // The buffer after "as" is "á", pending='f' - // flush calls apply_pending_to_last_vowel which tries f on á - // á is not in the tone table (it's already toned), so f stays pending - // Result: "á" + "f" in the flush output - e.process_key('a'); - e.process_key('s'); - e.process_key('f'); - let event = e.flush(); - match event { - Some(EngineEvent::Flush(text)) => { - // After flushing with pending f on already-toned vowel - assert!(!text.is_empty()); - } - _ => {} - } - } - - // ================================================================ - // Telex: Edge cases - // ================================================================ - - #[test] - fn telex_empty_input() { - let mut e = Engine::new(InputMethod::Telex); - assert!(process_input(&mut e, "").is_empty()); - } - - #[test] - fn telex_only_consonants() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "bcd")), "bcd"); - } - - #[test] - fn telex_single_vowel() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "a")), "a"); - } - - #[test] - fn telex_numbers_passthrough() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "123")), "123"); - } - - #[test] - fn telex_mixed_text() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "hello123")), "hello123"); - } - - // ================================================================ - // Telex: Toggle - // ================================================================ - - #[test] - fn telex_disabled_passthrough() { - let mut e = Engine::new(InputMethod::Telex); - e.set_enabled(false); - assert_eq!(get_display(&process_input(&mut e, "aas")), "aas"); - } - - #[test] - fn telex_enabled_active() { - let mut e = Engine::new(InputMethod::Telex); - e.set_enabled(true); - assert_eq!(get_display(&process_input(&mut e, "aas")), "ấ"); - } - - #[test] - fn telex_toggle_mid_word() { - let mut e = Engine::new(InputMethod::Telex); - // Disabled: "a" passes through, then enabled: "a" → â - e.set_enabled(false); - e.process_key('a'); - e.set_enabled(true); - e.process_key('a'); - let event = e.flush(); - match event { - Some(EngineEvent::Flush(text)) => { - // "a" passed through when disabled, then "a" processed when enabled → â - // But flush_with is called: first 'a' flushes as Insert, second 'a' becomes â - assert!(text.contains('a') || text.contains('â')); - } - _ => {} - } - } - - // ================================================================ - // Telex: Flexible diacritic placement - // Vowel modifiers and tone marks can be typed at end of syllable, - // scanning backward through consonants to find the base vowel. - // ================================================================ - - #[test] - fn telex_flexible_double_a_tone() { - let mut e = Engine::new(InputMethod::Telex); - // "tranaf" → "aa" (flexible) → â, then "f" (tone) → ầ → "trần" - assert_eq!(get_display(&process_input(&mut e, "tranaf")), "trần"); - } - - #[test] - fn telex_flexible_w_modifier() { - let mut e = Engine::new(InputMethod::Telex); - // "ngonw" → "w" on 'o' through 'n' (flexible) → ơ → "ngơn" - assert_eq!(get_display(&process_input(&mut e, "ngonw")), "ngơn"); - } - - #[test] - fn telex_flexible_w_tone() { - let mut e = Engine::new(InputMethod::Telex); - // "tranwf" → "w" on 'a' (flexible) → ă, then "f" (tone) → ằ → "trằn" - assert_eq!(get_display(&process_input(&mut e, "tranwf")), "trằn"); - } - - #[test] - fn telex_flexible_double_e() { - let mut e = Engine::new(InputMethod::Telex); - // "treen" → "ee" (flexible) on 'e' in "tren" → ê → "trên" - assert_eq!(get_display(&process_input(&mut e, "treen")), "trên"); - } - - #[test] - fn telex_flexible_double_o() { - let mut e = Engine::new(InputMethod::Telex); - // "choon" → "oo" (flexible) on 'o' in "chon" → ô → "chôn" - assert_eq!(get_display(&process_input(&mut e, "choon")), "chôn"); - } - - #[test] - fn telex_flexible_tone_through_consonants() { - let mut e = Engine::new(InputMethod::Telex); - // "tranf" → already worked in standard engine (tone scans backward) - assert_eq!(get_display(&process_input(&mut e, "tranf")), "tràn"); - } - - #[test] - fn telex_flexible_w_after_u() { - let mut e = Engine::new(InputMethod::Telex); - // "xungw" → "w" on 'u' through 'ng' (flexible) → ư → "xưng" - 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 - // ================================================================ - - #[test] - fn vni_flexible_digit_tone() { - let mut e = Engine::new(InputMethod::Vni); - // "tran62" → 6 on 'a' (flexible) → â, then 2 on 'â' (flexible) → ầ → "trần" - assert_eq!(get_display(&process_input(&mut e, "tran62")), "trần"); - } - - #[test] - fn vni_flexible_tone_through_consonants() { - let mut e = Engine::new(InputMethod::Vni); - // "tran1" → 1 (sắc) on 'a' (flexible) → á → "trán" - assert_eq!(get_display(&process_input(&mut e, "tran1")), "trán"); - } - - #[test] - fn vni_flexible_vowel_mod() { - let mut e = Engine::new(InputMethod::Vni); - // "tran6" → 6 on 'a' (flexible) → â → "trân" - assert_eq!(get_display(&process_input(&mut e, "tran6")), "trân"); - } - - #[test] - fn vni_flexible_no_vowel_passthrough() { - let mut e = Engine::new(InputMethod::Vni); - // "b1" → no vowel in buffer, digit appended unchanged - assert_eq!(get_display(&process_input(&mut e, "b1")), "b1"); - } - - #[test] - fn vni_flexible_empty_buffer() { - let mut e = Engine::new(InputMethod::Vni); - // "1" on empty buffer → appended - assert_eq!(get_display(&process_input(&mut e, "1")), "1"); - } - - #[test] - fn vni_flexible_backtrack_limit() { - let mut e = Engine::new(InputMethod::Vni); - // "dangd" + "6" should NOT modify 'a' in "dang" - assert_eq!(get_display(&process_input(&mut e, "dangd6")), "dangd6"); - } - - #[test] - fn vni_flexible_backtrack_still_works_near() { - let mut e = Engine::new(InputMethod::Vni); - // "tran" + "6" → "trân" (within backtrack limit) - 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"); + assert_eq!(get_display(&process_input(&mut e, "cams own")), "cám ơn"); } // ================================================================ @@ -807,7 +230,7 @@ mod tests { } #[test] - fn vni_a_ngã() { + fn vni_a_nga() { let mut e = Engine::new(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "a4")), "ã"); } @@ -823,37 +246,37 @@ mod tests { // ================================================================ #[test] - fn vni_a6_â() { + fn vni_a6_aa() { let mut e = Engine::new(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "a6")), "â"); } #[test] - fn vni_a8_ă() { + fn vni_a8_aw() { let mut e = Engine::new(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "a8")), "ă"); } #[test] - fn vni_e6_ê() { + fn vni_e6_ee() { let mut e = Engine::new(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "e6")), "ê"); } #[test] - fn vni_o6_ô() { + fn vni_o6_oo() { let mut e = Engine::new(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "o6")), "ô"); } #[test] - fn vni_o7_ơ() { + fn vni_o7_ow() { let mut e = Engine::new(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "o7")), "ơ"); } #[test] - fn vni_u7_ư() { + fn vni_u7_uw() { let mut e = Engine::new(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "u7")), "ư"); } @@ -863,189 +286,59 @@ mod tests { // ================================================================ #[test] - fn vni_ă_sac() { + fn vni_aa_sac() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "a61")), "ấ"); + } + + #[test] + fn vni_aw_sac() { let mut e = Engine::new(InputMethod::Vni); - // "a8" → ă, then "1" → ắ assert_eq!(get_display(&process_input(&mut e, "a81")), "ắ"); } - #[test] - fn vni_â_huyen() { - let mut e = Engine::new(InputMethod::Vni); - assert_eq!(get_display(&process_input(&mut e, "a62")), "ầ"); - } - - #[test] - fn vni_ê_sac() { - let mut e = Engine::new(InputMethod::Vni); - assert_eq!(get_display(&process_input(&mut e, "e61")), "ế"); - } - - #[test] - fn vni_ô_nang() { - let mut e = Engine::new(InputMethod::Vni); - assert_eq!(get_display(&process_input(&mut e, "o65")), "ộ"); - } - - // ================================================================ - // VNI: Digit after consonant (passthrough) - // ================================================================ - - #[test] - fn vni_digit_after_consonant() { - let mut e = Engine::new(InputMethod::Vni); - assert_eq!(get_display(&process_input(&mut e, "b1")), "b1"); - } - - #[test] - fn vni_digit_after_space() { - let mut e = Engine::new(InputMethod::Vni); - assert_eq!(get_display(&process_input(&mut e, " 1")), " 1"); - } - // ================================================================ // VNI: Full Vietnamese words // ================================================================ #[test] - fn vni_word_chao() { + fn vni_word_tieng() { let mut e = Engine::new(InputMethod::Vni); - // "chao2" → tone 2 (huyền) on last vowel 'o' → "chaò" - assert_eq!(get_display(&process_input(&mut e, "chao2")), "chaò"); + assert_eq!(get_display(&process_input(&mut e, "tie6ng1")), "tiếng"); } #[test] - fn vni_word_cam_on() { + fn vni_word_duong() { let mut e = Engine::new(InputMethod::Vni); - // "cam1" → flexible placement: '1' scans backward past 'm' to vowel 'a' → "cám" - assert_eq!(get_display(&process_input(&mut e, "cam1")), "cám"); + assert_eq!(get_display(&process_input(&mut e, "d9u7o7ng2")), "đường"); } // ================================================================ - // Auto-restore: English words + // Telex: dd // ================================================================ #[test] - fn auto_restore_hello() { + fn telex_dd() { let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "hello ")), "hello "); + assert_eq!(get_display(&process_input(&mut e, "dd")), "đ"); } #[test] - fn auto_restore_the() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "the ")), "the "); + fn vni_d9() { + let mut e = Engine::new(InputMethod::Vni); + assert_eq!(get_display(&process_input(&mut e, "d9")), "đ"); } - #[test] - fn auto_restore_and() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "and ")), "and "); - } + // ================================================================ + // Uppercase preservation + // ================================================================ #[test] - fn auto_restore_you() { + fn telex_uppercase_tieng() { let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "you ")), "you "); - } - - #[test] - fn auto_restore_on_period() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "hello.")), "hello."); - } - - #[test] - fn auto_restore_on_comma() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "ok,")), "ok,"); - } - - #[test] - fn auto_restore_not_on_vietnamese() { - let mut e = Engine::new(InputMethod::Telex); - // "xin" is in Vietnamese overrides, should NOT auto-restore - let events = process_input(&mut e, "xin "); + let events = process_input(&mut e, "Tieengs"); let display = get_display(&events); - assert_eq!(display, "xin "); - } - - // ================================================================ - // ESC Undo - // ================================================================ - - #[test] - fn esc_undo_basic() { - let mut e = Engine::new(InputMethod::Telex); - e.process_key('a'); - e.process_key('s'); - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { - backspaces, - restored, - }) => { - assert_eq!(backspaces, 1); - assert_eq!(restored, "a"); - } - _ => panic!("Expected UndoTones"), - } - } - - #[test] - fn esc_undo_word() { - let mut e = Engine::new(InputMethod::Telex); - for ch in "chafo".chars() { - e.process_key(ch); - } - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { - backspaces, - restored, - }) => { - assert_eq!(backspaces, 4); - assert_eq!(restored, "chao"); - } - _ => panic!("Expected UndoTones"), - } - } - - #[test] - fn esc_no_tones_flushes() { - let mut e = Engine::new(InputMethod::Telex); - for ch in "hello".chars() { - e.process_key(ch); - } - let event = e.process_escape(); - match event { - Some(EngineEvent::Flush(text)) => assert_eq!(text, "hello"), - _ => panic!("Expected Flush"), - } - } - - #[test] - fn esc_empty_buffer() { - let mut e = Engine::new(InputMethod::Telex); - let event = e.process_escape(); - assert!(event.is_none()); - } - - #[test] - fn esc_undo_after_multiple_tones() { - let mut e = Engine::new(InputMethod::Telex); - // "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, "a"); - } - _ => panic!("Expected UndoTones, got {:?}", event), - } + assert_eq!(display, "Tiếng"); } // ================================================================ @@ -1056,109 +349,92 @@ mod tests { fn macro_ko() { let mut e = Engine::new(InputMethod::Telex); e.add_macro("ko".into(), "không".into()); - assert_eq!(get_display(&process_input(&mut e, "ko ")), "không "); + let events = process_input(&mut e, "ko "); + assert_eq!(get_display(&events), "không "); } #[test] - fn macro_vs() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("vs".into(), "với".into()); - assert_eq!(get_display(&process_input(&mut e, "vs ")), "với "); - } - - #[test] - fn macro_dc() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("dc".into(), "được".into()); - assert_eq!(get_display(&process_input(&mut e, "dc ")), "được "); - } - - #[test] - fn macro_on_period() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("ok".into(), "được".into()); - assert_eq!(get_display(&process_input(&mut e, "ok.")), "được."); - } - - #[test] - fn macro_on_comma() { + fn macro_clear() { let mut e = Engine::new(InputMethod::Telex); e.add_macro("ko".into(), "không".into()); - assert_eq!(get_display(&process_input(&mut e, "ko,")), "không,"); - } - - #[test] - fn macro_overrides_telex() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("dc".into(), "được".into()); - // "dc" without macro = consonants, with macro = "được" - assert_eq!(get_display(&process_input(&mut e, "dc ")), "được "); - } - - #[test] - fn macro_partial_match_no_expand() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("ko".into(), "không".into()); - // "kox" - 'x' is a tone key, 'o' gets tone applied: buffer = "kõ" - // Then 'x' doesn't trigger flush, so no macro expansion - // "kox" is NOT the same as "ko" when flushed - let events = process_input(&mut e, "kox"); - let display = get_display(&events); - // 'x' after 'o' applies ngã tone, so output is "kõ" - assert_eq!(display, "kõ"); - } - - #[test] - fn macro_empty_no_expand() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("".into(), "nothing".into()); - // Empty macro key should not crash or expand - let events = process_input(&mut e, "a "); - assert_eq!(get_display(&events), "a "); - } - - #[test] - fn macro_with_vietnamese_output() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("ntn".into(), "như thế này".into()); - assert_eq!(get_display(&process_input(&mut e, "ntn ")), "như thế này "); - } - - #[test] - fn macro_long_expansion() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("bhg".into(), "bài họcгруппа".into()); - assert_eq!( - get_display(&process_input(&mut e, "bhg ")), - "bài họcгруппа " - ); - } - - #[test] - fn macro_does_not_affect_vietnamese() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("ko".into(), "không".into()); - // "chao" is not a macro, should be processed normally as Telex - assert_eq!(get_display(&process_input(&mut e, "chao ")), "chao "); - } - - #[test] - fn macro_and_telex_mixed() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("vs".into(), "với".into()); - // "vs" expands, then "hello" is English - assert_eq!( - get_display(&process_input(&mut e, "vs hello ")), - "với hello " - ); + e.clear_macros(); + let events = process_input(&mut e, "ko "); + assert_eq!(get_display(&events), "ko "); } // ================================================================ - // Engine: Reset + // Toggle enabled/disabled // ================================================================ #[test] - fn engine_reset_clears_buffer() { + fn toggle_disabled() { + let mut e = Engine::new(InputMethod::Telex); + e.set_enabled(false); + // When disabled, chars pass through as Insert events + let events = process_input(&mut e, "aas"); + // a,a,s → "aas" via Insert events + assert_eq!(get_display(&events), "aas"); + } + + #[test] + fn toggle_reenabled() { + let mut e = Engine::new(InputMethod::Telex); + e.set_enabled(false); + e.set_enabled(true); + assert_eq!(get_display(&process_input(&mut e, "aas")), "ấ"); + } + + // ================================================================ + // Replay keystrokes + // ================================================================ + + #[test] + fn replay_telex_chao() { + let macros = std::collections::HashMap::new(); + let (output, _) = Engine::replay_keystrokes(InputMethod::Telex, ¯os, &['c', 'h', 'a', 'o', 'f']); + assert_eq!(output, "chào"); + } + + #[test] + fn replay_vni_tieng() { + let macros = std::collections::HashMap::new(); + let (output, _) = Engine::replay_keystrokes( + InputMethod::Vni, ¯os, + &['t', 'i', 'e', '6', 'n', 'g', '1'], + ); + assert_eq!(output, "tiếng"); + } + + // ================================================================ + // Edge cases + // ================================================================ + + #[test] + fn empty_input() { + let mut e = Engine::new(InputMethod::Telex); + assert!(process_input(&mut e, "").is_empty()); + } + + #[test] + fn only_consonants() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "bcd")), "bcd"); + } + + #[test] + fn numbers_passthrough() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "123")), "123"); + } + + #[test] + fn tone_key_standalone() { + let mut e = Engine::new(InputMethod::Telex); + assert_eq!(get_display(&process_input(&mut e, "s")), "s"); + } + + #[test] + fn reset_clears() { let mut e = Engine::new(InputMethod::Telex); e.process_key('a'); e.process_key('a'); @@ -1167,1188 +443,9 @@ mod tests { } #[test] - fn engine_flush_after_reset() { - let mut e = Engine::new(InputMethod::Telex); - e.process_key('a'); - e.process_key('a'); - e.reset(); - let event = e.flush(); - assert!(event.is_none()); - } - - // ================================================================ - // Engine: Method switching - // ================================================================ - - #[test] - fn engine_switch_to_vni() { + fn method_switch() { let mut e = Engine::new(InputMethod::Telex); e.set_method(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "a1")), "á"); } - - #[test] - fn engine_switch_to_telex() { - let mut e = Engine::new(InputMethod::Vni); - e.set_method(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "as")), "á"); - } - - // ================================================================ - // Engine: Macro management - // ================================================================ - - #[test] - fn engine_clear_macros() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("ko".into(), "không".into()); - e.clear_macros(); - // "ko" should no longer expand - assert_eq!(get_display(&process_input(&mut e, "ko ")), "ko "); - } - - // ================================================================ - // Engine: is_enabled - // ================================================================ - - #[test] - fn engine_is_enabled_default() { - let e = Engine::new(InputMethod::Telex); - assert!(e.is_enabled()); - } - - #[test] - fn engine_set_disabled() { - let mut e = Engine::new(InputMethod::Telex); - e.set_enabled(false); - assert!(!e.is_enabled()); - } - - // ================================================================ - // Backspace counting - // ================================================================ - - #[test] - fn backspace_count_auto_restore_debug() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "was "); - // Verify auto-restore produces correct backspace counts - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!(replace_events.len(), 3); - // w-pending: backspace 1 (delete 'w' from screen) - assert_eq!(replace_events[0], (1, "".to_string())); - // s-tone: backspace 2 (delete 'as'), insert "á" - assert_eq!(replace_events[1], (2, "á".to_string())); - // space auto-restore: backspace 2 (delete "á "), insert "was " - assert_eq!(replace_events[2], (2, "was ".to_string())); - assert_eq!(get_display(&events), "was "); - } - - #[test] - fn backspace_count_esc_undo() { - let mut e = Engine::new(InputMethod::Telex); - for ch in "chafo".chars() { - e.process_key(ch); - } - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { backspaces, .. }) => { - assert_eq!(backspaces, 4); // "chào" = 4 chars - } - _ => panic!("Expected UndoTones"), - } - } - - // ================================================================ - // Telex: w at start of input - // ================================================================ - - #[test] - fn telex_w_at_start() { - let mut e = Engine::new(InputMethod::Telex); - // "w" at start with no vowel → pending modifier, space flushes it - let events = process_input(&mut e, "w "); - // w is pending, flush applies pending to last vowel (none) → consumed - assert_eq!(get_display(&events), " "); - } - - // ================================================================ - // Telex: double letter not for i/u/y - // ================================================================ - - #[test] - fn telex_double_i_passthrough() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "ii")), "ii"); - } - - #[test] - fn telex_double_u_passthrough() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "uu")), "uu"); - } - - #[test] - fn telex_double_y_passthrough() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e, "yy")), "yy"); - } - - // ================================================================ - // Telex: tone after non-vowel - // ================================================================ - - #[test] - fn telex_tone_after_consonant() { - let mut e = Engine::new(InputMethod::Telex); - // "bs" → no vowel, s is appended as pending - assert_eq!(get_display(&process_input(&mut e, "bs")), "bs"); - } - - #[test] - fn telex_tone_key_standalone() { - let mut e = Engine::new(InputMethod::Telex); - // "s" alone → no vowel, just "s" - assert_eq!(get_display(&process_input(&mut e, "s")), "s"); - } - - // ================================================================ - // VNI: Full words with modifications + tones - // ================================================================ - - #[test] - fn vni_word_with_modifications() { - let mut e = Engine::new(InputMethod::Vni); - // "a61" → â + sac = ấ - assert_eq!(get_display(&process_input(&mut e, "a61")), "ấ"); - } - - #[test] - fn vni_word_complex() { - let mut e = Engine::new(InputMethod::Vni); - // "o61" → ô + sac = ố - assert_eq!(get_display(&process_input(&mut e, "o61")), "ố"); - } - - // ================================================================ - // English dict - // ================================================================ - - #[test] - fn english_dict_is_english() { - let dict = crate::english::EnglishDict::new(); - assert!(dict.is_english_word("hello")); - assert!(dict.is_english_word("the")); - assert!(dict.is_english_word("you")); - assert!(!dict.is_english_word("xyz")); - } - - #[test] - fn english_dict_should_restore() { - let dict = crate::english::EnglishDict::new(); - assert!(dict.should_restore("hello")); - assert!(dict.should_restore("the")); - // Vietnamese overrides should NOT restore - assert!(!dict.should_restore("xin")); - assert!(!dict.should_restore("không")); - } - - // ================================================================ - // strip_diacritics - // ================================================================ - - #[test] - fn strip_diacritics_basic() { - let mut e = Engine::new(InputMethod::Telex); - // Type "chào" then ESC - for ch in "chafo".chars() { - e.process_key(ch); - } - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { restored, .. }) => { - assert_eq!(restored, "chao"); - } - _ => panic!("Expected UndoTones"), - } - } - - #[test] - fn strip_diacritics_all_vowels() { - let mut e = Engine::new(InputMethod::Telex); - // Each tone combo is flushed on space, so ESC only undoes the last word - // "as af ar ax aj" → last buffer is "aj" → ESC → "a" - let input = "as af ar ax aj"; - for ch in input.chars() { - e.process_key(ch); - } - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { restored, .. }) => { - // Only the last unflushed vowel group "aj" is in the buffer - assert_eq!(restored, "a"); - } - _ => panic!("Expected UndoTones"), - } - } - - #[test] - fn strip_diacritics_single_vowel() { - let mut e = Engine::new(InputMethod::Telex); - // "as" without space → buffer = "á" → ESC → "a" - e.process_key('a'); - e.process_key('s'); - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { restored, .. }) => { - assert_eq!(restored, "a"); - } - _ => panic!("Expected UndoTones"), - } - } - - #[test] - fn strip_diacritics_modified_vowel() { - let mut e = Engine::new(InputMethod::Telex); - // "aas" → ắ → ESC → "a" (strip diacritics removes ă→a) - e.process_key('a'); - e.process_key('a'); - e.process_key('s'); - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { restored, .. }) => { - assert_eq!(restored, "a"); - } - _ => panic!("Expected UndoTones"), - } - } - - // ================================================================ - // Backspace counting: comprehensive tests - // ================================================================ - - #[test] - fn backspace_count_simple_tone() { - // "as" → Replace {2, "á"} - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "as"); - // Find the Replace event - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!(replace_events.len(), 1, "Expected 1 Replace event for 'as'"); - assert_eq!(replace_events[0], (2, "á".to_string())); - assert_eq!(get_display(&events), "á"); - } - - #[test] - fn backspace_count_double_letter() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "aa"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!(replace_events.len(), 1); - assert_eq!(replace_events[0], (2, "â".to_string())); - assert_eq!(get_display(&events), "â"); - } - - #[test] - fn backspace_count_w_modifier() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "aw"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!(replace_events.len(), 1); - assert_eq!(replace_events[0], (2, "ă".to_string())); - assert_eq!(get_display(&events), "ă"); - } - - #[test] - fn backspace_count_w_modifier_then_tone() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "aws"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - // "aw" → Replace {2, "ă"}, then "s" → Replace {2, "ắ"} - assert_eq!( - replace_events.len(), - 2, - "Expected 2 Replace events: {:?}", - replace_events - ); - assert_eq!(replace_events[0], (2, "ă".to_string())); - assert_eq!(replace_events[1], (2, "ắ".to_string())); - assert_eq!(get_display(&events), "ắ"); - } - - #[test] - fn backspace_count_compound_vowel_tone() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "oas"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - // "oas" → tone on second vowel: Replace {3, "oá"} - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace event: {:?}", - replace_events - ); - assert_eq!(replace_events[0], (3, "oá".to_string())); - assert_eq!(get_display(&events), "oá"); - } - - #[test] - fn backspace_count_compound_vowel_uy_tone() { - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "uys"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - // "uys" → tone on first vowel: Replace {3, "uý"} - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace event: {:?}", - replace_events - ); - assert_eq!(replace_events[0], (3, "uý".to_string())); - assert_eq!(get_display(&events), "uý"); - } - - #[test] - fn backspace_count_tone_after_consonant() { - // "bs" → no vowel, 's' is appended as text - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "bs"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, .. } => Some(backspaces), - _ => None, - }) - .collect(); - // 's' after consonant 'b': no vowel found, 's' appended to buffer - // But s is a tone key, and process_tone is called... - // In process_tone: buffer "b", chars=['b'], no vowel found → buffer.push('s') → "bs" - // new_inner = "bs", expected = "b"+"s" = "bs" → same → None - assert_eq!( - replace_events.len(), - 0, - "Expected no Replace events, got: {:?}", - replace_events - ); - assert_eq!(get_display(&events), "bs"); - } - - #[test] - fn backspace_count_auto_restore_was() { - // "was " should auto-restore because "was" is an English word - // The engine converts: w→pending(blink), a→normal, s→tone on a → "á" - // Then space triggers auto-restore back to "was " - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "was "); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - // Expected events for "was ": - // 'w': pending modifier, no buffer change → Replace {1, ""} (blink) - // 's': tone on 'a' → Replace {2, "á"} - // ' ': auto-restore → Replace {2, "was "} - assert_eq!( - replace_events.len(), - 3, - "Expected 3 Replace events, got: {:?}", - replace_events - ); - // Event 0: 'w' blinks (gets deleted as pending modifier) - assert_eq!(replace_events[0].0, 1, "w-pending backspace"); - assert_eq!(replace_events[0].1, ""); - // Event 1: 's' replaces 'as' with 'á' (2 backspaces: 'a' + 's') - assert_eq!(replace_events[1].0, 2, "tone on 'a' backspace"); - assert_eq!(replace_events[1].1, "á"); - // Event 2: auto-restore back to "was " (2 backspaces: 'á' + ' ') - assert_eq!(replace_events[2].0, 2, "auto-restore backspace"); - assert_eq!(replace_events[2].1, "was "); - - let display = get_display(&events); - assert_eq!(display, "was ", "Final display should be 'was '"); - } - - #[test] - fn backspace_count_auto_restore_hello() { - // "hello " → no conversion needed, should_restore("hello") → true, no diacritics → None - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "hello "); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, .. } => Some(backspaces), - _ => None, - }) - .collect(); - // "hello" has no Vietnamese conversion, should_restore returns true - // has_diacritics = false → returns None in auto-restore path - assert_eq!( - replace_events.len(), - 0, - "No Replace events for plain English" - ); - assert_eq!(get_display(&events), "hello "); - } - - #[test] - fn backspace_count_macro_expansion() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("ko".into(), "không".into()); - let events = process_input(&mut e, "ko "); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - // "ko " → macro expansion: raw_buffer="ko", Replace { 3, "không " } - // backspaces = raw_buffer.len + 1 = 2 + 1 = 3 - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace event for macro" - ); - assert_eq!(replace_events[0].0, 3, "macro backspace count"); - assert_eq!(replace_events[0].1, "không "); - assert_eq!(get_display(&events), "không "); - } - - #[test] - fn backspace_count_pending_tone_on_space() { - // "chof " → 'f' is pending after 'o' on "cho", space flushes → "chò " - let mut e = Engine::new(InputMethod::Telex); - let events = process_input(&mut e, "chof "); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - // "chof": - // 'c' → no event - // 'h' → no event - // 'o' → no event - // 'f' → process_tone on 'o' → Replace { 4, "chò" } (prev_inner="cho", expected="chof") - // ' ' → flush with space, final_word="chò" == previous_inner="chò" → None - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace event: {:?}", - replace_events - ); - assert_eq!(replace_events[0].0, 4, "chof→chò backspace"); - assert_eq!(replace_events[0].1, "chò"); - assert_eq!(get_display(&events), "chò "); - } - - #[test] - fn backspace_count_esc_undo_accuracy() { - let mut e = Engine::new(InputMethod::Telex); - for ch in "chafo".chars() { - e.process_key(ch); - } - let event = e.process_escape(); - match event { - Some(EngineEvent::UndoTones { - backspaces, - restored, - }) => { - assert_eq!(backspaces, 4, "ESC undo should backspace 4 chars (chào)"); - assert_eq!(restored, "chao"); - } - _ => panic!("Expected UndoTones"), - } - } - - #[test] - fn backspace_count_after_backspace() { - // Type "as" (→ "á"), then backspace, then type "a", - // Then flush → "a". - let mut e = Engine::new(InputMethod::Telex); - e.process_key('a'); - e.process_key('s'); // buffer = "á" - let mut events = Vec::new(); - events.push(EngineEvent::Insert(" ".to_string())); - if let Some(ev) = e.process_key('\x08') { - events.push(ev); - } // backspace → buffer "" - if let Some(ev) = e.process_key('a') { - events.push(ev); - } // buffer "a" (no Replace) - if let Some(ev) = e.flush() { - events.push(ev); - } - // After backspace: buffer is empty, then 'a' → no Replace, flush returns Flush("a") - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { .. } => Some(()), - _ => None, - }) - .collect(); - assert_eq!( - replace_events.len(), - 0, - "No Replace events after backspace + 'a'" - ); - let display = get_display(&events); - assert_eq!( - display, " a", - "Display should be ' ' (from Insert) + 'a' (from flush)" - ); - } - - #[test] - fn backspace_count_multi_word() { - let mut e = Engine::new(InputMethod::Telex); - // "xin chao " (xin=no convert, chao=no convert, space flushes) - let events = process_input(&mut e, "xin chao "); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!(replace_events.len(), 0, "No Replace events for 'xin chao '"); - assert_eq!(get_display(&events), "xin chao "); - } - - #[test] - fn backspace_count_tone_at_word_end() { - let mut e = Engine::new(InputMethod::Telex); - // "tots" → "tót": 's' after 't' is a vowel? No. Let's trace. - // 't' → buffer "t" - // 'o' → buffer "to" - // 't' → buffer "tot" - // 's' → process_tone('s'): buffer "tot", chars ['t','o','t'] - // i=2: is_vowel('t')? No. i=1: is_vowel('o')? Yes. - // Apply 's' to 'o' → 'ó'. buffer = "tót" - // Replace { 4, "tót" } - let events = process_input(&mut e, "tots"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace: {:?}", - replace_events - ); - assert_eq!(replace_events[0].0, 4, "tots→tót backspace"); - assert_eq!(replace_events[0].1, "tót"); - assert_eq!(get_display(&events), "tót"); - } - - #[test] - fn backspace_count_final_consonant_tone() { - let mut e = Engine::new(InputMethod::Telex); - // "dungj" → "dụng" - let events = process_input(&mut e, "dungj"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace: {:?}", - replace_events - ); - assert_eq!(replace_events[0].0, 5, "dungj→dụng backspace"); - assert_eq!(replace_events[0].1, "dụng"); - assert_eq!(get_display(&events), "dụng"); - } - - // ================================================================ - // raw_buffer integrity tests - // ================================================================ - - #[test] - fn raw_buffer_syncs_with_engine_after_replace() { - let mut e = Engine::new(InputMethod::Telex); - // Type "as" → buffer="á", raw_buffer="as" - e.process_key('a'); - e.process_key('s'); - // Verify internal state - assert_eq!(e.buffer(), "á", "Engine buffer should be 'á'"); - // Backspace → pop engine, sync raw_buffer - e.process_key('\x08'); - assert_eq!( - e.buffer(), - "", - "Engine buffer should be empty after backspace" - ); - // Verify raw_buffer is also empty (sync'd via char count matching) - } - - #[test] - fn raw_buffer_tracks_keystrokes_for_macro() { - let mut e = Engine::new(InputMethod::Telex); - e.add_macro("dc".into(), "được".into()); - // "dc " should trigger macro: raw_buffer="dc" - e.process_key('d'); - e.process_key('c'); - let event = e.process_key(' '); - match event { - Some(EngineEvent::Replace { backspaces, insert }) => { - assert_eq!(backspaces, 3, "Macro 'dc ' → backspaces = 3"); - assert_eq!(insert, "được "); - } - other => panic!("Expected Replace for macro, got: {:?}", other), - } - } - - #[test] - fn backspace_after_replace_syncs_raw_buffer() { - let mut e = Engine::new(InputMethod::Telex); - // Type "as" → buffer="á", raw_buffer="as" - e.process_key('a'); - e.process_key('s'); - // Backspace → both should be empty - e.process_key('\x08'); - assert_eq!(e.buffer(), "", "Buffer after backspace"); - // Type "x" → buffer="x", should not have residual raw_buffer issue - e.process_key('x'); - assert_eq!(e.buffer(), "x", "Buffer after backspace + 'x'"); - } - - // ================================================================ - // VNI backspace counting - // ================================================================ - - #[test] - fn vni_backspace_count_tone() { - let mut e = Engine::new(InputMethod::Vni); - let events = process_input(&mut e, "a1"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace: {:?}", - replace_events - ); - assert_eq!(replace_events[0].0, 2, "a1→á backspace"); - assert_eq!(replace_events[0].1, "á"); - assert_eq!(get_display(&events), "á"); - } - - #[test] - fn vni_backspace_count_vowel_mod() { - let mut e = Engine::new(InputMethod::Vni); - let events = process_input(&mut e, "a6"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!(replace_events.len(), 1); - assert_eq!(replace_events[0].0, 2, "a6→â backspace"); - assert_eq!(replace_events[0].1, "â"); - assert_eq!(get_display(&events), "â"); - } - - #[test] - fn vni_backspace_count_mod_then_tone() { - let mut e = Engine::new(InputMethod::Vni); - let events = process_input(&mut e, "a61"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - // "a6" → Replace {2, "â"}, then "1" → 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!(replace_events[1].0, 2); - assert_eq!(replace_events[1].1, "ấ"); - assert_eq!(get_display(&events), "ấ"); - } - - #[test] - fn vni_backspace_count_consonant_digit() { - // "b1" → 'b' is not vowel, '1' appends as digit → no Replace - let mut e = Engine::new(InputMethod::Vni); - let events = process_input(&mut e, "b1"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { .. } => Some(()), - _ => None, - }) - .collect(); - assert_eq!(replace_events.len(), 0, "No Replace for consonant+digit"); - assert_eq!(get_display(&events), "b1"); - } - - #[test] - fn vni_backspace_count_word_with_mod() { - let mut e = Engine::new(InputMethod::Vni); - // "chao2" → '2' is tone (huyền) on 'o' → "chaò" - let events = process_input(&mut e, "chao2"); - let replace_events: Vec<_> = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, insert } => Some((*backspaces, insert.clone())), - _ => None, - }) - .collect(); - assert_eq!( - replace_events.len(), - 1, - "Expected 1 Replace: {:?}", - replace_events - ); - // previous_inner = "chao" (4 chars), expected = "chao"+"2" = "chao2" (5 chars) - // backspaces = 4 + 1 = 5 - assert_eq!(replace_events[0].0, 5, "chao2→chaò backspace"); - assert_eq!(replace_events[0].1, "chaò"); - assert_eq!(get_display(&events), "chaò"); - } - - // ================================================================ - // Edge case: multiple tone replacements on same vowel - // ================================================================ - - #[test] - fn backspace_count_then_second_tone_replaces_previous() { - // 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" → 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!(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"); - } - - // ================================================================ - // Regression: backspace counting after complex sequences - // ================================================================ - - #[test] - fn backspace_count_long_vietnamese_phrase() { - let mut e = Engine::new(InputMethod::Telex); - // "xin chào bạn" in Telex: "xin chaof banj" - // xin = no change - // ' ' = flush, no change - // ch + ao + f = "chào" - // ' ' = flush - // b + a + n + j = "bạn" (j=nặng on 'a') - let events = process_input(&mut e, "xin chaof banj"); - let replace_events: Vec = events - .iter() - .filter_map(|ev| match ev { - EngineEvent::Replace { backspaces, .. } => Some(*backspaces), - _ => None, - }) - .collect(); - assert_eq!( - replace_events.len(), - 2, - "Expected 2 Replace events: {:?}", - replace_events - ); - assert_eq!(replace_events[0], 5, "chaof→chào should be 5"); - 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"); - } - - #[test] - fn test_spelling_auto_restore() { - let mut e = Engine::new(InputMethod::Telex); - - // "fasts" -> "fást" -> restored to "fasts" on space - assert_eq!(get_display(&process_input(&mut e, "fasts ")), "fasts "); - - // "statuss" -> "statús" -> restored to "statuss" on space - let mut e2 = Engine::new(InputMethod::Telex); - assert_eq!(get_display(&process_input(&mut e2, "statuss ")), "statuss "); - } - - #[test] - fn test_user_phrases_telex() { - let mut e = Engine::new(InputMethod::Telex); - assert_eq!( - get_display(&process_input(&mut e, "vox nguyeenx ddawng khoa")), - "võ nguyễn đăng khoa" - ); - - let mut e2 = Engine::new(InputMethod::Telex); - assert_eq!( - get_display(&process_input(&mut e2, "nguyeenx thij traam anh")), - "nguyễn thị trâm anh" - ); - - let mut e3 = Engine::new(InputMethod::Telex); - assert_eq!( - get_display(&process_input(&mut e3, "vox hoongf mi")), - "võ hồng mi" - ); - - let mut e4 = Engine::new(InputMethod::Telex); - assert_eq!( - get_display(&process_input(&mut e4, "trinhj traanf phuongw tuaans")), - "trịnh trần phương tuấn" - ); - } - - #[test] - fn test_user_phrases_vni() { - let mut e = Engine::new(InputMethod::Vni); - assert_eq!( - get_display(&process_input(&mut e, "vo4 nguyen64 da8ng9 khoa")), - "võ nguyễn đăng khoa" - ); - - let mut e2 = Engine::new(InputMethod::Vni); - assert_eq!( - get_display(&process_input(&mut e2, "nguyen64 thi5 tram6 anh")), - "nguyễn thị trâm anh" - ); - - let mut e3 = Engine::new(InputMethod::Vni); - assert_eq!( - get_display(&process_input(&mut e3, "vo4 hong62 mi")), - "võ hồng mi" - ); - - let mut e4 = Engine::new(InputMethod::Vni); - assert_eq!( - get_display(&process_input(&mut e4, "trinh5 tran62 phuong7 tuan61")), - "trịnh trần phương tuấn" - ); - } } diff --git a/engine/tests/generated_bulk.rs b/engine/tests/generated_bulk.rs deleted file mode 100644 index 011b1e8..0000000 --- a/engine/tests/generated_bulk.rs +++ /dev/null @@ -1,1066 +0,0 @@ -/// 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); - } - EngineEvent::Paste(text) => { display.push_str(text); } - } - } - 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/engine/tests/snapshot_tests.rs b/engine/tests/snapshot_tests.rs deleted file mode 100644 index 496559a..0000000 --- a/engine/tests/snapshot_tests.rs +++ /dev/null @@ -1,83 +0,0 @@ -use serde::Serialize; -use vietc_engine::{Engine, EngineEvent, InputMethod}; - -#[derive(Serialize)] -struct SnapshotTestCase { - input: String, - display: String, - events: Vec, -} - -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::Paste(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 run_snapshot_test(inputs_json: &str, method: InputMethod) -> Vec { - let inputs: Vec = serde_json::from_str(inputs_json).unwrap(); - let mut cases = Vec::new(); - - for input in inputs { - let mut engine = Engine::new(method); - let mut events = Vec::new(); - for ch in input.chars() { - if let Some(event) = engine.process_key(ch) { - events.push(event); - } - } - let display = get_display(&events); - cases.push(SnapshotTestCase { - input, - display, - events, - }); - } - - cases -} - -#[test] -fn test_telex_snapshots() { - let inputs_json = include_str!("testdata/telex_inputs.json"); - let cases = run_snapshot_test(inputs_json, InputMethod::Telex); - insta::assert_yaml_snapshot!(cases); -} - -#[test] -fn test_vni_snapshots() { - let inputs_json = include_str!("testdata/vni_inputs.json"); - let cases = run_snapshot_test(inputs_json, InputMethod::Vni); - insta::assert_yaml_snapshot!(cases); -} diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh index b564c2c..1203231 100644 --- a/packaging/appimage/build-appimage.sh +++ b/packaging/appimage/build-appimage.sh @@ -40,6 +40,7 @@ if [ -d "deb-build/usr/bin" ]; then else cp target/release/vietc "$APPDIR/usr/bin/" cp target/release/vietc-cli "$APPDIR/usr/bin/" + cp target/release/vietc-uinputd "$APPDIR/usr/bin/" [ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" fi @@ -55,12 +56,13 @@ fi # Compile and bundle vietc-xrecord (C helper for XRecord keyboard capture) echo " Compiling vietc-xrecord..." if command -v gcc &>/dev/null; then - gcc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst -ldl 2>&1 + gcc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst + echo " vietc-xrecord bundled" +elif command -v cc &>/dev/null; then + cc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst echo " vietc-xrecord bundled" else - echo " gcc not found, trying cc..." - cc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst -ldl 2>&1 - echo " vietc-xrecord bundled" + echo " WARNING: No C compiler found, vietc-xrecord not bundled — X11 capture will fail" fi # Desktop integration @@ -208,6 +210,15 @@ ENV_PREFIX="env" [ -n "$WAYLAND_DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX WAYLAND_DISPLAY=$WAYLAND_DISPLAY" [ -n "$XDG_RUNTIME_DIR" ] && ENV_PREFIX="$ENV_PREFIX XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" +# Ensure system library paths are available for dlopen (libX11, libXtst, etc.) +# AppImage runtime may override LD_LIBRARY_PATH; append system paths as fallback +SYSLIB_PATHS="/usr/lib/x86_64-linux-gnu:/usr/lib64:/usr/lib:/lib/x86_64-linux-gnu:/lib64:/lib" +if [ -n "$LD_LIBRARY_PATH" ]; then + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$SYSLIB_PATHS" +else + export LD_LIBRARY_PATH="$SYSLIB_PATHS" +fi + # Start daemon (kill old non-root one first if we have root) # On X11 we can run without root (XGrabKeyboard + XTest injection needs no special permissions). # On Wayland, evdev requires root (input group) or uinput. @@ -217,9 +228,24 @@ if [ -n "$WAYLAND_DISPLAY" ]; then fi if [ -z "$NEED_ROOT" ]; then - # X11: no root needed + # X11: no root needed for capture, but uinputd needs root for injection + pkill -x vietc-uinputd 2>/dev/null pkill -x vietc 2>/dev/null; sleep 0.3 - mkdir -p "$HOME/.config/vietc" + mkdir -p "$HOME/.config/vietc" "$HOME/.vietc" + + # Try to start the uinputd daemon (preferred injection path) + if command -v pkexec >/dev/null 2>&1; then + pkexec "$HERE/usr/bin/vietc-uinputd" >/dev/null 2>&1 & + UINPUTD_PID=$! + sleep 0.3 + elif command -v sudo >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + sudo "$HERE/usr/bin/vietc-uinputd" >/dev/null 2>&1 & + UINPUTD_PID=$! + sleep 0.3 + fi + fi + "$HERE/usr/bin/vietc" >"$HOME/.config/vietc/vietc-daemon.log" 2>&1 & DAEMON_PID=$! echo "[vietc] Daemon started (PID=$DAEMON_PID), log: $HOME/.config/vietc/vietc-daemon.log" @@ -272,16 +298,20 @@ cleanup_daemon() { kill "$DAEMON_PID" 2>/dev/null wait "$DAEMON_PID" 2>/dev/null fi + if [ -n "$UINPUTD_PID" ]; then + kill "$UINPUTD_PID" 2>/dev/null + wait "$UINPUTD_PID" 2>/dev/null + fi } trap cleanup_daemon EXIT INT TERM if [ -f "$HERE/usr/bin/vietc-tray" ]; then "$HERE/usr/bin/vietc-tray" "$@" else - echo "[vietc] ERROR: vietc-tray not found. The AppImage cannot start without it." - echo "[vietc] Stopping." - kill "$DAEMON_PID" 2>/dev/null - exit 1 + echo "[vietc] Tray not available — daemon is running in background." + echo "[vietc] Press Ctrl+C or close this terminal to stop." + # Keep AppImage alive: wait for daemon to exit + wait $DAEMON_PID 2>/dev/null fi EOF chmod +x "$APPDIR/AppRun" diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index b1f8298..504eee7 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -1,6 +1,7 @@ pub mod inject; pub mod monitor; pub mod uinput_monitor; +pub mod uinput_client; pub mod wayland_im; #[cfg(feature = "x11")] diff --git a/protocol/src/uinput_client.rs b/protocol/src/uinput_client.rs new file mode 100644 index 0000000..97183a4 --- /dev/null +++ b/protocol/src/uinput_client.rs @@ -0,0 +1,75 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +use super::inject::{InjectResult, KeyInjector}; + +fn socket_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(home).join(".vietc").join("uinput.sock") +} + +pub struct UinputClient; + +impl UinputClient { + fn send_command(cmd: &str) -> InjectResult { + match UnixStream::connect(socket_path()) { + Ok(mut stream) => { + if writeln!(stream, "{}", cmd).is_err() { + return InjectResult::Failed; + } + let mut reader = BufReader::new(&stream); + let mut response = String::new(); + if reader.read_line(&mut response).is_err() { + return InjectResult::Failed; + } + if response.trim() == "OK" { + InjectResult::Success + } else { + InjectResult::Failed + } + } + Err(_) => InjectResult::Failed, + } + } + + pub fn is_available() -> bool { + UnixStream::connect(socket_path()).is_ok() + } +} + +impl KeyInjector for UinputClient { + fn send_key_event(&self, _keycode: u16, _value: i32) -> InjectResult { + InjectResult::Success + } + + fn send_backspace(&self) -> InjectResult { + InjectResult::Success + } + + fn send_char(&self, _ch: char) -> InjectResult { + InjectResult::Success + } + + fn send_string(&self, s: &str) -> InjectResult { + Self::send_command(&format!("TYPE:{}", s)) + } + + fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult { + if backspaces > 0 { + let _ = Self::send_command(&format!("BACKSPACE:{}", backspaces)); + } + if !text.is_empty() { + let _ = Self::send_command(&format!("TYPE:{}", text)); + } + InjectResult::Success + } + + fn flush(&self) -> InjectResult { + InjectResult::Success + } + + fn update_pasted_text(&self, _text: &str) -> InjectResult { + InjectResult::Success + } +} diff --git a/protocol/src/uinput_monitor.rs b/protocol/src/uinput_monitor.rs index 1259282..646ec44 100644 --- a/protocol/src/uinput_monitor.rs +++ b/protocol/src/uinput_monitor.rs @@ -135,33 +135,13 @@ impl KeyInjector for UinputInjector { if let Some(keycode) = char_to_linux_keycode(ch) { let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch); self.send_key_stroke(keycode, needs_shift); - eprintln!( - "[vietc] send_char: ASCII '{}' via uinput", - ch.escape_default() - ); return InjectResult::Success; } - // Unicode character: use clipboard fallback for reliable injection - let text = ch.to_string(); - eprintln!( - "[vietc] send_char: Unicode '{}' - using clipboard", - text.escape_default() - ); - - let copied = self.copy_to_clipboard(&text); - if copied { - eprintln!("[vietc] send_char: clipboard OK, sending Ctrl+V"); - self.send_ctrl_v(); - eprintln!("[vietc] send_char complete (clipboard)"); - return InjectResult::Success; - } else { - eprintln!( - "[vietc] send_char failed for '{}' (clipboard unavailable)", - text.escape_default() - ); - // Last resort: try uinput directly (may not work on all systems) - eprintln!("[vietc] send_char fallback: trying direct injection..."); - self.paste_string(&text); + // Vietnamese Unicode char: map to base ASCII and send via uinput + let ascii = strip_vn_diacritic(ch); + if let Some(keycode) = char_to_linux_keycode(ascii) { + let needs_shift = ascii.is_uppercase(); + self.send_key_stroke(keycode, needs_shift); } InjectResult::Success } @@ -360,22 +340,8 @@ 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 { - eprintln!( - "[vietc] inject_atomic: ASCII={}", - text.chars().all(|c| char_to_linux_keycode(c).is_some()) - ); - eprintln!( - "[vietc] inject_atomic: ASCII check (raw_bytes={} chars={} text='{}')", - text.len(), - text.chars().count(), - text.escape_default() - ); - + // If all ASCII, send keycodes directly — fast and reliable if text.chars().all(|c| char_to_linux_keycode(c).is_some()) { - eprintln!( - "[vietc] ASCII injection using uinput (backspaces={})", - backspaces - ); if backspaces > 0 { for _ in 0..backspaces { let _ = self.send_backspace(); @@ -384,149 +350,43 @@ impl UinputInjector { for ch in text.chars() { let _ = self.send_char(ch); } - eprintln!("[vietc] ASCII injection complete"); return InjectResult::Success; } - // Unicode text: use xdotool directly (X11/XWayland) or wtype (Wayland) - let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); - - static HAS_XDOTOOL: std::sync::OnceLock = std::sync::OnceLock::new(); - let has_xdotool = if is_wayland { - false - } else { - *HAS_XDOTOOL.get_or_init(|| { - std::process::Command::new("which") - .arg("xdotool") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - }) - }; - - static HAS_WTYPE: std::sync::OnceLock = std::sync::OnceLock::new(); - let has_wtype = if !is_wayland { - false - } else { - *HAS_WTYPE.get_or_init(|| { - std::process::Command::new("which") - .arg("wtype") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - }) - }; - - if is_wayland { - if has_wtype { - eprintln!( - "[vietc] Unicode detected ({} chars), injecting via wtype", - text.chars().count() - ); + // Unicode text: split into Vietnamese portion (clipboard paste) and + // trailing ASCII whitespace/punctuation (uinput). Clipboard paste + // often trims trailing whitespace, so we send it separately. + let mut split = text.len(); + for (i, c) in text.char_indices().rev() { + if c.is_ascii() && (c.is_whitespace() || matches!(c, '.' | ',' | '!' | '?' | ';' | ':')) { + split = i; } else { - eprintln!( - "[vietc] Wayland session detected, using clipboard fallback instead of xdotool/wtype" - ); + break; } - } else { - eprintln!( - "[vietc] Unicode detected ({} chars), injecting via xdotool", - text.chars().count() - ); } + let (vn_text, ascii_tail) = text.split_at(split); - if is_wayland && has_wtype { - let mut args = Vec::new(); - if backspaces > 0 { - for _ in 0..backspaces { - args.push("-k"); - args.push("BackSpace"); - } - } - if !text.is_empty() { - args.push("--"); - args.push(text); - } - - eprintln!("[vietc] Running: wtype {}", args.join(" ")); - let output = Self::run_as_user("wtype", &args); - if output.status.success() { - eprintln!("[vietc] wtype success - Unicode text injected correctly"); - return InjectResult::Success; - } - eprintln!( - "[vietc] wtype failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - 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(text); // xdotool handles UTF-8 text directly - } - - eprintln!("[vietc] Running: xdotool {}", args.join(" ")); - let output = Self::run_as_user("xdotool", &args); - if output.status.success() { - eprintln!("[vietc] xdotool success - Unicode text injected correctly"); - return InjectResult::Success; - } - eprintln!( - "[vietc] xdotool failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } else if !is_wayland { - eprintln!("[vietc] xdotool not found, trying clipboard fallback..."); - } - - // Final fallback: clipboard copy + Ctrl+V via uinput device - eprintln!("[vietc] All direct tools failed, using clipboard fallback..."); - // Primary choice for Unicode: clipboard copy + Ctrl+V via uinput device - let copied = self.copy_to_clipboard(text); - if copied { - eprintln!( - "[vietc] Clipboard fallback: copied '{}' and will Ctrl+V", - text - ); - if backspaces > 0 { - for _ in 0..backspaces { - let _ = self.send_backspace(); - } - } - eprintln!("[vietc] Sending Ctrl+V"); - self.send_ctrl_v(); - // Record pasted text for future delete/backspace operations - let output = Self::run_as_user("vietc", &["update-pasted", "-text", text]); - if output.status.success() { - eprintln!("[vietc] update_pasted_text success"); - } else { - eprintln!("[vietc] update_pasted_text call ignored (not critical)"); - } - eprintln!("[vietc] Clipboard injection complete"); - return InjectResult::Success; - } else { - eprintln!("[vietc] clipboard copy failed, trying individual char paste_string..."); - } - - // Absolute last resort: try uinput backspaces followed by individual unicode chars via send_char - eprintln!("[vietc] Last resort: pasting '{}' char-by-char", text); + // Backspaces via uinput if backspaces > 0 { for _ in 0..backspaces { let _ = self.send_backspace(); } } - for ch in text.chars() { - let _ = self.send_char(ch); + + // Clipboard paste for Vietnamese text + if !vn_text.is_empty() { + if self.copy_to_clipboard(vn_text) { + self.send_ctrl_v_x11(); + } } - eprintln!("[vietc] Char-by-char injection complete"); + + // Trailing ASCII via uinput (spaces, punctuation) + for ch in ascii_tail.chars() { + if let Some(kc) = char_to_linux_keycode(ch) { + self.send_key_stroke(kc, false); + } + } + InjectResult::Success } @@ -607,13 +467,9 @@ impl UinputInjector { /// 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() @@ -624,24 +480,15 @@ impl UinputInjector { }); 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); } } // Try xclip (X11) via user_cmd - eprintln!("[vietc] clipboard: trying xclip..."); { 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() @@ -650,18 +497,8 @@ impl UinputInjector { 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 - }); + .map(|status| status.success()) + .unwrap_or(false); if result { return true; } @@ -688,6 +525,56 @@ impl UinputInjector { self.send_uinput_event(0, 0, 0); // SYN std::thread::sleep(std::time::Duration::from_millis(10)); } + + /// Send Ctrl+V via X11 XTest (avoids uinput kernel feedback loop). + /// Uses a lazily-opened persistent X11 connection. + fn send_ctrl_v_x11(&self) { + if std::env::var("WAYLAND_DISPLAY").is_ok() { + self.send_ctrl_v(); + return; + } + // Persistent X11 state (raw pointers, only used from injection thread) + static mut X11_DPY: *mut libc::c_void = std::ptr::null_mut(); + static mut X11_KEY: Option libc::c_int> = None; + static mut X11_FLUSH: Option libc::c_int> = None; + static mut X11_KEYCODE: Option u32> = None; + static X11_INIT: std::sync::Once = std::sync::Once::new(); + + X11_INIT.call_once(|| { + unsafe { + let lib = libc::dlopen(b"libX11.so.6\0".as_ptr() as *const libc::c_char, 1); + if lib.is_null() { return; } + let xtst = libc::dlopen(b"libXtst.so.6\0".as_ptr() as *const libc::c_char, 1); + if xtst.is_null() { libc::dlclose(lib); return; } + + type FnOpen = unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void; + let xopen: FnOpen = std::mem::transmute(libc::dlsym(lib, b"XOpenDisplay\0".as_ptr() as *const libc::c_char)); + let dpy = xopen(std::ptr::null()); + if dpy.is_null() { libc::dlclose(xtst); libc::dlclose(lib); return; } + + X11_DPY = dpy; + X11_KEY = Some(std::mem::transmute(libc::dlsym(xtst, b"XTestFakeKeyEvent\0".as_ptr() as *const libc::c_char))); + X11_FLUSH = Some(std::mem::transmute(libc::dlsym(lib, b"XFlush\0".as_ptr() as *const libc::c_char))); + X11_KEYCODE = Some(std::mem::transmute(libc::dlsym(lib, b"XKeysymToKeycode\0".as_ptr() as *const libc::c_char))); + } + }); + + unsafe { + if X11_DPY.is_null() || X11_KEY.is_none() { self.send_ctrl_v(); return; } + let dpy = X11_DPY; + let xkey = X11_KEY.unwrap(); + let xflush = X11_FLUSH.unwrap(); + let xkeycode = X11_KEYCODE.unwrap(); + let ctrl_kc = xkeycode(dpy, 0xFFE3); + let v_kc = xkeycode(dpy, 0x0076); + xkey(dpy, ctrl_kc, 1, 0); + xkey(dpy, v_kc, 1, 0); + xkey(dpy, v_kc, 0, 0); + xkey(dpy, ctrl_kc, 0, 0); + xflush(dpy); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } } impl Drop for UinputInjector { @@ -696,6 +583,26 @@ impl Drop for UinputInjector { } } +fn strip_vn_diacritic(ch: char) -> char { + match ch { + 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a', + 'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' | 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A', + 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e', + 'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E', + 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i', + 'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I', + 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o', + 'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' | 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O', + 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u', + 'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U', + 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y', + 'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y', + 'đ' => 'd', + 'Đ' => 'D', + other => other, + } +} + fn char_to_linux_keycode(ch: char) -> Option { match ch.to_ascii_lowercase() { 'a' => Some(30), diff --git a/protocol/src/x11_capture.rs b/protocol/src/x11_capture.rs index 7323d8d..7d10e59 100644 --- a/protocol/src/x11_capture.rs +++ b/protocol/src/x11_capture.rs @@ -64,7 +64,7 @@ struct LookupLib { display: *mut Display, x_close_display: unsafe extern "C" fn(*mut Display) -> c_int, x_lookup_string: unsafe extern "C" fn(*mut XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int, - x_utf8_lookup_string: Option c_int>, + x_utf8_lookup_string: Option c_int>, } unsafe impl Send for LookupLib {} @@ -129,6 +129,7 @@ impl LookupLib { let mut keysym: KeySym = 0; let len = if let Some(xutf8) = self.x_utf8_lookup_string { xutf8( + std::ptr::null_mut(), &mut xke as *mut XKeyEvent, buf.as_mut_ptr() as *mut c_char, buf.len() as c_int, @@ -225,8 +226,6 @@ impl X11Capture { /// Wait for events from the C helper pipe with timeout. pub fn wait_for_event(&mut self, timeout_ms: u64) -> bool { // If SKIP_RECORD_EVENTS is true, aggressively drain all pending events - // before clearing the flag. This prevents feedback loops where injected - // events arrive after drain_pipe returns but before the flag is cleared. if SKIP_RECORD_EVENTS.load(Ordering::Relaxed) { let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50); loop { @@ -234,17 +233,15 @@ impl X11Capture { if std::time::Instant::now() >= deadline { break; } - // Poll with short timeout to check for more data let mut pfd = PollFd { fd: self.pipe_fd, events: POLLIN, revents: 0, }; unsafe { - poll(&mut pfd, 1, 5); // 5ms poll + poll(&mut pfd, 1, 5); } if pfd.revents & POLLIN == 0 { - // No more data, check one more time after a tiny sleep std::thread::sleep(std::time::Duration::from_micros(500)); self.drain_pipe(); break; @@ -252,14 +249,12 @@ impl X11Capture { } } - // Normal wait for events self.drain_pipe(); if !self.event_queue.is_empty() { return true; } - // Poll the pipe fd let mut pfd = PollFd { fd: self.pipe_fd, events: POLLIN, @@ -273,11 +268,8 @@ impl X11Capture { self.drain_pipe(); } - // Check if child is still alive if let Ok(None) = self.child.try_wait() { - // Still running } else { - eprintln!("[vietc] vietc-xrecord process died, restarting..."); self.restart_xrecord(); } @@ -295,10 +287,7 @@ impl X11Capture { filled += n; while filled >= 8 { let ev: PipeEvent = unsafe { std::mem::transmute(buf) }; - - // Skip injected events when flag is set (prevents feedback loops) if SKIP_RECORD_EVENTS.load(Ordering::Relaxed) { - // Still handle focus events even during skip if ev.keycode == 0 && ev.state == 2 { self.focus_lost = true; } @@ -315,7 +304,6 @@ impl X11Capture { }; self.event_queue.push_back(event); } - filled -= 8; if filled > 0 { buf.copy_within(8..8 + filled, 0); diff --git a/protocol/src/x11_inject.rs b/protocol/src/x11_inject.rs index aa07663..ffd7263 100644 --- a/protocol/src/x11_inject.rs +++ b/protocol/src/x11_inject.rs @@ -406,7 +406,7 @@ impl X11Injector { // Handle SelectionRequest events that come from the paste target // Process events with a short spin loop (up to ~50ms) - for _ in 0..10 { + for _ in 0..4 { // Brief sleep to let X11 events propagate std::thread::sleep(std::time::Duration::from_millis(5)); self.handle_pending_events(); diff --git a/uinputd/Cargo.toml b/uinputd/Cargo.toml new file mode 100644 index 0000000..3df7d0b --- /dev/null +++ b/uinputd/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "vietc-uinputd" +version = "0.1.0" +edition = "2021" +description = "Viet+ privileged uinput backspace injection daemon" + +[[bin]] +name = "vietc-uinputd" +path = "src/main.rs" + +[dependencies] +libc = "0.2" diff --git a/uinputd/src/main.rs b/uinputd/src/main.rs new file mode 100644 index 0000000..0938fde --- /dev/null +++ b/uinputd/src/main.rs @@ -0,0 +1,306 @@ +use std::fs; +use std::os::unix::io::AsRawFd; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::Command; + +const UINPUT_MAX_NAME_SIZE: usize = 80; +const UI_SET_EVBIT: u64 = 0x40045564; +const UI_SET_KEYBIT: u64 = 0x40045565; +const UI_DEV_CREATE: u64 = 0x5501; +const UI_DEV_DESTROY: u64 = 0x5502; +const UI_DEV_SETUP: u64 = 0x405c5503; +const EV_KEY: u16 = 0x01; + +fn ioctl(fd: i32, request: u64, arg: u64) -> Result { + let result = unsafe { libc::ioctl(fd, request, arg) }; + if result < 0 { + Err(format!("ioctl failed: {}", std::io::Error::last_os_error())) + } else { + Ok(result) + } +} + +#[repr(C)] +struct input_event { + time: libc::timeval, + type_: u16, + code: u16, + value: i32, +} + +#[repr(C)] +struct uinput_setup { + id: input_id, + name: [i8; UINPUT_MAX_NAME_SIZE], + ff_effects_max: u32, +} + +#[repr(C)] +struct input_id { + bustype: u16, + vendor: u16, + product: u16, + version: u16, +} + +struct UinputDevice { + fd: i32, +} + +impl UinputDevice { + fn new(name: &str) -> Result { + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/uinput") + .map_err(|e| format!("Cannot open /dev/uinput: {} (are you root?)", e))?; + + let fd = file.as_raw_fd(); + + ioctl(fd, UI_SET_EVBIT, EV_KEY as u64)?; + + for code in 0..=0x1ffu32 { + ioctl(fd, UI_SET_KEYBIT, code as u64)?; + } + + let mut usetup: uinput_setup = unsafe { std::mem::zeroed() }; + let name_bytes = name.as_bytes(); + let copy_len = name_bytes.len().min(UINPUT_MAX_NAME_SIZE - 1); + for (i, &byte) in name_bytes.iter().enumerate().take(copy_len) { + usetup.name[i] = byte as i8; + } + usetup.id.bustype = 0x03; + usetup.id.vendor = 0x1234; + usetup.id.product = 0x5678; + usetup.id.version = 1; + + ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64)?; + ioctl(fd, UI_DEV_CREATE, 0)?; + + std::mem::forget(file); + std::thread::sleep(std::time::Duration::from_millis(10)); + + eprintln!("[vietc-uinputd] Device '{}' created", name); + Ok(Self { fd }) + } + + fn send_event(&self, type_: u16, code: u16, value: i32) { + let event = input_event { + time: libc::timeval { tv_sec: 0, tv_usec: 0 }, + type_, + code, + value, + }; + unsafe { + libc::write(self.fd, &event as *const input_event as *const libc::c_void, std::mem::size_of::()); + } + } + + fn send_key(&self, code: u16, value: i32) { + self.send_event(EV_KEY, code, value); + self.send_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + + fn backspace_n(&self, count: usize) { + for _ in 0..count { + self.send_key(14, 1); + self.send_key(14, 0); + } + } + + fn char_to_keycode(ch: u8) -> Option<(u16, bool)> { + let lower = ch.to_ascii_lowercase(); + let keycode = match lower { + b'a' => 30, b'b' => 48, b'c' => 46, b'd' => 32, b'e' => 18, + b'f' => 33, b'g' => 34, b'h' => 35, b'i' => 23, b'j' => 36, + b'k' => 37, b'l' => 38, b'm' => 50, b'n' => 49, b'o' => 24, + b'p' => 25, b'q' => 16, b'r' => 19, b's' => 31, b't' => 20, + b'u' => 22, b'v' => 47, b'w' => 17, b'x' => 45, b'y' => 21, + b'z' => 44, + b'0' => 11, b'1' => 2, b'2' => 3, b'3' => 4, b'4' => 5, + b'5' => 6, b'6' => 7, b'7' => 8, b'8' => 9, b'9' => 10, + b' ' => 57, b'.' => 52, b',' => 51, b'-' => 12, b'=' => 13, + b';' => 39, b'\'' => 40, b'/' => 53, b'\\' => 43, + b'[' => 26, b']' => 27, + _ => return None, + }; + let shift = ch.is_ascii_uppercase() + || matches!(ch, b'!' | b'@' | b'#' | b'$' | b'%' | b'^' | b'&' | b'*' + | b'(' | b')' | b'_' | b'+' | b'{' | b'}' | b'|' | b':' | b'"' + | b'<' | b'>' | b'?' | b'~'); + Some((keycode, shift)) + } + + fn type_ascii(&self, text: &str) { + for byte in text.bytes() { + if let Some((keycode, shift)) = Self::char_to_keycode(byte) { + if shift { + self.send_key(42, 1); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + self.send_key(keycode, 1); + self.send_key(keycode, 0); + if shift { + self.send_key(42, 0); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + } + + fn paste_unicode(&self, text: &str) { + copy_to_clipboard(text); + self.send_key(29, 1); + std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_key(47, 1); + self.send_key(47, 0); + self.send_key(29, 0); + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +impl Drop for UinputDevice { + fn drop(&mut self) { + let _ = unsafe { libc::ioctl(self.fd, UI_DEV_DESTROY, 0) }; + let _ = unsafe { libc::close(self.fd) }; + eprintln!("[vietc-uinputd] Device destroyed"); + } +} + +fn copy_to_clipboard(text: &str) { + let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); + if is_wayland { + if let Ok(mut child) = Command::new("wl-copy") + .stdin(std::process::Stdio::piped()) + .spawn() + { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } + } else { + if let Ok(mut child) = Command::new("xclip") + .args(["-selection", "clipboard"]) + .stdin(std::process::Stdio::piped()) + .spawn() + { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } + } +} + +fn find_socket_path() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + let dir = format!("{}/.vietc", home); + let _ = fs::create_dir_all(&dir); + + if unsafe { libc::getuid() == 0 } { + let socket = format!("{}/uinput.sock", dir); + unsafe { + let _ = libc::chown( + socket.as_ptr() as *const libc::c_char, + 0, + 0, + ); + } + socket + } else { + format!("{}/uinput.sock", dir) + } +} + +fn handle_client(stream: UnixStream, uinput: &UinputDevice) { + let reader = BufReader::new(&stream); + let mut writer = &stream; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + + let line = line.trim().to_string(); + if line.is_empty() { continue; } + + if line == "PING" { + let _ = writeln!(writer, "PONG"); + } else if line == "FLUSH" { + let _ = writeln!(writer, "OK"); + } else if line == "QUIT" { + let _ = writeln!(writer, "BYE"); + break; + } else if let Some(n_str) = line.strip_prefix("BACKSPACE:") { + if let Ok(n) = n_str.parse::() { + uinput.backspace_n(n); + let _ = writeln!(writer, "OK"); + } else { + let _ = writeln!(writer, "ERR bad count"); + } + } else if let Some(text) = line.strip_prefix("TYPE:") { + let is_ascii = text.bytes().all(|b| UinputDevice::char_to_keycode(b).is_some()); + if is_ascii { + uinput.type_ascii(text); + } else { + uinput.paste_unicode(text); + } + let _ = writeln!(writer, "OK"); + } else if let Some(text) = line.strip_prefix("PASTE:") { + uinput.paste_unicode(text); + let _ = writeln!(writer, "OK"); + } else { + let _ = writeln!(writer, "ERR unknown command"); + } + } +} + +fn main() { + let socket_path = find_socket_path(); + let path = Path::new(&socket_path); + + let _ = fs::remove_file(path); + + let listener = match UnixListener::bind(path) { + Ok(l) => l, + Err(e) => { + eprintln!("[vietc-uinputd] Cannot bind socket {}: {}", socket_path, e); + std::process::exit(1); + } + }; + + // Make socket world-writable so non-root daemon can connect + unsafe { + let _ = libc::chmod( + socket_path.as_ptr() as *const libc::c_char, + 0o666, + ); + } + + let uinput = match UinputDevice::new("vietc") { + Ok(d) => d, + Err(e) => { + eprintln!("[vietc-uinputd] {}", e); + std::process::exit(1); + } + }; + + eprintln!("[vietc-uinputd] Listening on {}", socket_path); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + handle_client(stream, &uinput); + } + Err(e) => { + eprintln!("[vietc-uinputd] Connection error: {}", e); + } + } + } +}