diff --git a/Makefile b/Makefile index 800abce..bd381e6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config appimage fmt lint tree +.PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config appimage deb fmt lint tree # Build core crates build: @@ -86,6 +86,11 @@ appimage: VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \ bash packaging/appimage/build-appimage.sh "$$VERSION" +# Build Debian package +deb: + VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \ + bash packaging/build-deb.sh "$$VERSION" + # Clean build artifacts clean: cargo clean diff --git a/cli/src/main.rs b/cli/src/main.rs index 1d5e901..53ab929 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -72,12 +72,18 @@ fn main() { } output.push_str(insert); } - EngineEvent::UndoTones { backspaces, restored } => { + EngineEvent::UndoTones { + backspaces, + restored, + } => { for _ in 0..*backspaces { output.push('\x08'); } output.push_str(restored); } + EngineEvent::Paste(text) => { + output.push_str(text); + } } } } diff --git a/daemon/src/app_state.rs b/daemon/src/app_state.rs index 807ad09..0ba0091 100644 --- a/daemon/src/app_state.rs +++ b/daemon/src/app_state.rs @@ -63,7 +63,12 @@ fn get_proc_window_class() -> Option { // Read /proc/active-windows if available (some compositors expose this) let content = fs::read_to_string("/proc/active-windows").ok()?; // Format: pid window_class window_title - content.lines().next()?.split_whitespace().nth(1).map(|s| s.to_lowercase()) + content + .lines() + .next()? + .split_whitespace() + .nth(1) + .map(|s| s.to_lowercase()) } /// Manages per-app IME state @@ -76,6 +81,8 @@ pub struct AppStateManager { english_apps: Vec, /// Default Vietnamese apps from config vietnamese_apps: Vec, + /// Bypass apps from config + bypass_apps: Vec, /// Global enabled state global_enabled: bool, } @@ -84,6 +91,7 @@ impl AppStateManager { pub fn new( english_apps: Vec, vietnamese_apps: Vec, + bypass_apps: Vec, global_enabled: bool, ) -> Self { Self { @@ -91,6 +99,7 @@ impl AppStateManager { overrides: HashMap::new(), english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(), vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(), + bypass_apps: bypass_apps.iter().map(|s| s.to_lowercase()).collect(), global_enabled, } } @@ -162,14 +171,32 @@ impl AppStateManager { } /// Update app lists from reloaded config - pub fn update_lists(&mut self, english_apps: Vec, vietnamese_apps: Vec) { + pub fn update_lists( + &mut self, + english_apps: Vec, + vietnamese_apps: Vec, + bypass_apps: Vec, + ) -> &Self { self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect(); self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(); + self.bypass_apps = bypass_apps.iter().map(|s| s.to_lowercase()).collect(); eprintln!( - "[vietc] App lists updated: {} English, {} Vietnamese", + "[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass", self.english_apps.len(), - self.vietnamese_apps.len() + self.vietnamese_apps.len(), + self.bypass_apps.len() ); + self + } + + /// Check if the currently active application should bypass the IME completely + pub fn is_current_app_bypassed(&self) -> bool { + for pattern in &self.bypass_apps { + if self.current_app.contains(pattern.as_str()) { + return true; + } + } + false } /// Save overrides to config file diff --git a/daemon/src/config.rs b/daemon/src/config.rs index a1d1271..04789a5 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -53,6 +53,9 @@ pub struct AppStateConfig { #[serde(default)] pub vietnamese_apps: Vec, + + #[serde(default = "default_bypass_apps")] + pub bypass_apps: Vec, } impl Default for AutoRestoreConfig { @@ -70,16 +73,29 @@ impl Default for AppStateConfig { enabled: true, english_apps: default_english_apps(), vietnamese_apps: default_vietnamese_apps(), + bypass_apps: default_bypass_apps(), } } } -fn default_input_method() -> String { "telex".into() } -fn default_toggle_key() -> String { "space".into() } -fn default_start_enabled() -> bool { true } -fn default_true() -> bool { true } -fn default_false() -> bool { false } -fn default_restore_keys() -> Vec { vec!["space".into(), "escape".into()] } +fn default_input_method() -> String { + "telex".into() +} +fn default_toggle_key() -> String { + "space".into() +} +fn default_start_enabled() -> bool { + true +} +fn default_true() -> bool { + true +} +fn default_false() -> bool { + false +} +fn default_restore_keys() -> Vec { + vec!["space".into(), "escape".into()] +} fn default_english_apps() -> Vec { vec![ @@ -90,10 +106,26 @@ fn default_english_apps() -> Vec { "webstorm".into(), "vim".into(), "nvim".into(), + ] +} + +fn default_bypass_apps() -> Vec { + vec![ "terminal".into(), "kitty".into(), "alacritty".into(), "foot".into(), + "wezterm".into(), + "konsole".into(), + "gnome-terminal".into(), + "st".into(), + "urxvt".into(), + "xterm".into(), + "steam".into(), + "dota".into(), + "csgo".into(), + "minecraft".into(), + "factorio".into(), ] } @@ -233,7 +265,10 @@ vs = "với" assert!(!config.auto_restore.enabled); assert!(config.app_state.enabled); assert_eq!(config.app_state.english_apps, vec!["code", "vim"]); - assert_eq!(config.app_state.vietnamese_apps, vec!["telegram", "discord"]); + assert_eq!( + config.app_state.vietnamese_apps, + vec!["telegram", "discord"] + ); assert_eq!(config.macros.get("ko").unwrap(), "không"); assert_eq!(config.macros.get("dc").unwrap(), "được"); assert_eq!(config.macros.get("vs").unwrap(), "với"); @@ -289,12 +324,14 @@ foo = "bar" fn parse_app_lists() { let toml = r#" [app_state] -english_apps = ["vim", "neovim", "kitty"] +english_apps = ["vim", "neovim"] vietnamese_apps = ["zalo", "messenger"] +bypass_apps = ["kitty"] "#; let config: Config = toml::from_str(toml).unwrap(); - assert_eq!(config.app_state.english_apps, vec!["vim", "neovim", "kitty"]); + assert_eq!(config.app_state.english_apps, vec!["vim", "neovim"]); assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]); + assert_eq!(config.app_state.bypass_apps, vec!["kitty"]); } #[test] @@ -311,14 +348,29 @@ vietnamese_apps = ["zalo", "messenger"] let config = Config::default(); assert!(config.app_state.english_apps.contains(&"code".to_string())); assert!(config.app_state.english_apps.contains(&"vim".to_string())); - assert!(config.app_state.english_apps.contains(&"kitty".to_string())); + } + + #[test] + fn default_config_bypass_apps() { + let config = Config::default(); + assert!(config.app_state.bypass_apps.contains(&"kitty".to_string())); + assert!(config + .app_state + .bypass_apps + .contains(&"alacritty".to_string())); } #[test] fn default_config_vietnamese_apps() { let config = Config::default(); - assert!(config.app_state.vietnamese_apps.contains(&"telegram".to_string())); - assert!(config.app_state.vietnamese_apps.contains(&"firefox".to_string())); + assert!(config + .app_state + .vietnamese_apps + .contains(&"telegram".to_string())); + assert!(config + .app_state + .vietnamese_apps + .contains(&"firefox".to_string())); } #[test] diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 178b503..c08a8dc 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -1,19 +1,19 @@ use std::collections::HashSet; use std::fs; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; use vietc_engine::{Engine, EngineEvent, InputMethod}; -mod config; mod app_state; +mod config; mod display; -use config::Config; use app_state::AppStateManager; +use config::Config; fn get_log_path() -> Option { dirs::config_dir().map(|p| p.join("vietc").join("vietc.log")) @@ -98,6 +98,7 @@ impl Daemon { let mut app_state = AppStateManager::new( config.app_state.english_apps.clone(), config.app_state.vietnamese_apps.clone(), + config.app_state.bypass_apps.clone(), config.start_enabled, ); app_state.load_overrides(); @@ -133,7 +134,10 @@ impl Daemon { if let Ok(content) = fs::read_to_string(&status_path) { let expect_enabled = content.trim() == "vn"; if self.engine.is_enabled() != expect_enabled { - log_info(&format!("[vietc] Syncing enabled status from file: {}", expect_enabled)); + log_info(&format!( + "[vietc] Syncing enabled status from file: {}", + expect_enabled + )); self.engine.set_enabled(expect_enabled); self.engine_enabled.store(expect_enabled, Ordering::SeqCst); } @@ -167,6 +171,7 @@ impl Daemon { self.app_state.update_lists( new_config.app_state.english_apps.clone(), new_config.app_state.vietnamese_apps.clone(), + new_config.app_state.bypass_apps.clone(), ); self.grab_enabled = new_config.grab; @@ -185,13 +190,38 @@ impl Daemon { fn process_key(&mut self, ch: char) -> Vec { let mut commands = Vec::new(); + // Log each keystroke with character info + log_info(&format!( + "[vietc] process_key: U+{:04X} '{}' raw_buffer='{}' enabled={}", + ch as u32, + ch, + self.engine.buffer(), + self.engine.is_enabled() + )); + if let Some(event) = self.engine.process_key(ch) { - log_info(&format!("[vietc] key='{}' buf='{}' -> {:?}", ch, self.engine.buffer(), event)); + log_info(&format!( + "[vietc] key='{}' buf='{}' -> {:?}", + ch, + self.engine.buffer(), + event + )); match event { EngineEvent::Flush(text) => { + log_info(&format!( + "[vietc] Flush text len={}, bytes={} text={}", + text.len(), + text.len() * 3, + text.escape_default() + )); commands.push(OutputCommand::Type(text)); } EngineEvent::Insert(text) => { + log_info(&format!( + "[vietc] Insert text len={}, text={}", + text.len(), + text + )); commands.push(OutputCommand::Type(text)); } EngineEvent::AutoRestore(word) => { @@ -200,16 +230,42 @@ impl Daemon { commands.push(OutputCommand::Type(word)); } EngineEvent::Replace { backspaces, insert } => { + log_info(&format!( + "[vietc] Replace BS={} text=\"{}\"", + backspaces, insert + )); commands.push(OutputCommand::Backspace(backspaces)); commands.push(OutputCommand::Type(insert)); } - EngineEvent::UndoTones { backspaces, restored } => { + EngineEvent::UndoTones { + backspaces, + restored, + } => { + log_info(&format!( + "[vietc] UndoTones BS={} restored=\"{}\"", + backspaces, restored + )); commands.push(OutputCommand::Backspace(backspaces)); commands.push(OutputCommand::Type(restored)); } + EngineEvent::Paste(text) => { + log_info(&format!( + "[vietc] Paste raw text len={}, bytes={} text={}", + text.len(), + text.len() * 3, + text.escape_default() + )); + // Exit paste mode after pasting + self.engine.exit_paste_mode(); + commands.push(OutputCommand::Type(text)); + } } } else { - log_info(&format!("[vietc] key='{}' -> (no event, buf='{}')", ch, self.engine.buffer())); + log_info(&format!( + "[vietc] key='{}' -> (no event, buf='{}')", + ch, + self.engine.buffer() + )); } commands @@ -217,8 +273,33 @@ impl Daemon { fn toggle(&mut self) { let new_state = self.app_state.toggle_current_app(); + log_info(&format!( + "[vietc] toggle: engine.enabled={}", + self.engine.is_enabled() + )); + self.engine.set_enabled(new_state); self.write_status(); + + // Reset engine buffer when enabling Vietnamese mode to clear stale state + if new_state { + log_info(&format!( + "[vietc] reset() called - raw_buffer='{}' before reset", + self.engine.buffer() + )); + self.engine.reset(); + log_info(&format!( + "[vietc] after reset() - raw_buffer='{}'", + self.engine.buffer() + )); + } + } + + fn is_current_app_bypassed(&self) -> bool { + if !self.config.app_state.enabled { + return false; + } + self.app_state.is_current_app_bypassed() } fn check_app_change_with(&mut self, new_class: String) { @@ -248,10 +329,24 @@ fn main() -> Result<(), Box> { let compositor = display::detect_compositor(); log_info(&format!("Viet+ Daemon v{}", env!("CARGO_PKG_VERSION"))); - log_info(&format!("Display: {:?} ({})", display, compositor.unwrap_or_else(|| "unknown".into()))); + log_info(&format!( + "Display: {:?} ({})", + display, + compositor.unwrap_or_else(|| "unknown".into()) + )); log_info(&format!("Input method: {:?}", daemon.config.input_method)); - log_info(&format!("Toggle key: Ctrl+{}", daemon.config.toggle_key.to_uppercase())); - log_info(&format!("App memory: {}", if daemon.config.app_state.enabled { "ON" } else { "OFF" })); + log_info(&format!( + "Toggle key: Ctrl+{}", + daemon.config.toggle_key.to_uppercase() + )); + log_info(&format!( + "App memory: {}", + if daemon.config.app_state.enabled { + "ON" + } else { + "OFF" + } + )); // Spawn background monitor for active window, config changes, and status changes let shared_active_window = Arc::new(Mutex::new(String::new())); @@ -288,7 +383,7 @@ fn main() -> Result<(), Box> { status_changed.store(true, Ordering::SeqCst); } } - + // Check config modified every 1.5 seconds (6 * 250ms) window_check_counter += 1; if window_check_counter >= 6 { @@ -361,9 +456,10 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box Result<(evdev::Device, String), Box { - log_info(&format!("[vietc] Could not grab keyboard: {} (run as root for grab)", e)); + log_info(&format!( + "[vietc] Could not grab keyboard: {} (run as root for grab)", + e + )); log_info("[vietc] Falling back to non-grabbing mode (may have race)"); false } @@ -443,13 +544,18 @@ fn run_with_evdev( loop { // Check for event timeout (grab safety) if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) { - log_info("[vietc] No events for 30s — releasing grab timeout, releasing grab for safety"); + log_info( + "[vietc] No events for 30s — releasing grab timeout, releasing grab for safety", + ); let _ = device.ungrab(); return Ok(()); } let caps = is_caps_lock_on(&device); - let key_state = device.get_key_state().ok(); + let mut key_state = device + .get_key_state() + .ok() + .unwrap_or_else(evdev::AttributeSet::new); let events = device.fetch_events()?; last_event_time = std::time::Instant::now(); @@ -463,7 +569,10 @@ fn run_with_evdev( { let active_window = shared_active_window.lock().unwrap().clone(); if active_window != last_active_window { - log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window)); + log_info(&format!( + "[vietc] Window changed: '{}' -> '{}'", + last_active_window, active_window + )); last_active_window = active_window.clone(); daemon.engine.reset(); log_info("[vietc] Reset engine buffer due to window change"); @@ -487,8 +596,22 @@ fn run_with_evdev( let value = event.value(); let keycode = key.0; - if value == 1 - && is_toggle_combination_state(&key_state, &daemon.config.toggle_key) + // Update key state dynamically + if value == 1 { + key_state.insert(key); + } else if value == 0 { + key_state.remove(key); + } + + // Completely bypass all IME processing/interception for terminal emulators, IDE terminals, and games + if daemon.is_current_app_bypassed() { + if grabbed { + injector.send_key_event(keycode, value); + } + continue; + } + + if value == 1 && is_toggle_combination_state(&key_state, &daemon.config.toggle_key) { daemon.toggle(); continue; @@ -508,7 +631,7 @@ fn run_with_evdev( } } else { // Grabbing mode: all output goes through uinput only. - + // If Ctrl, Alt, or Meta/Super is pressed, bypass the engine completely and forward raw key events. if is_modifier_pressed(&key_state) { injector.send_key_event(keycode, value); @@ -533,10 +656,8 @@ fn run_with_evdev( } if let Some(mut ch) = key_to_char(key) { let shift = is_modifier_held_shift(&key_state); - if ch.is_ascii_alphabetic() { - if shift ^ caps { - ch = ch.to_ascii_uppercase(); - } + if ch.is_ascii_alphabetic() && (shift ^ caps) { + ch = ch.to_ascii_uppercase(); } let commands = daemon.process_key(ch); if !commands.is_empty() { @@ -576,8 +697,7 @@ fn run_stdin_mode( _engine_enabled: Arc, display: display::DisplayServer, ) -> Result<(), Box> { - use std::io::{self, Read, IsTerminal}; - + use std::io::{self, IsTerminal, Read}; if !io::stdin().is_terminal() { log_info("[vietc] Warning: No keyboard device and no terminal."); @@ -603,7 +723,8 @@ fn run_stdin_mode( if let Ok((device, path)) = open_keyboard_device() { log_info(&format!("[vietc] Keyboard device found: {}", path)); return run_with_evdev( - device, daemon, + device, + daemon, shared_active_window, config_changed, status_changed, @@ -633,7 +754,10 @@ fn run_stdin_mode( { let active_window = shared_active_window.lock().unwrap().clone(); if active_window != last_active_window { - log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window)); + log_info(&format!( + "[vietc] Window changed: '{}' -> '{}'", + last_active_window, active_window + )); last_active_window = active_window.clone(); daemon.engine.reset(); log_info("[vietc] Reset engine buffer due to window change"); @@ -672,15 +796,26 @@ fn run_stdin_mode( /// Execute commands — accumulate backspaces and text, then inject through /// a single channel (ydotool or wtype) to avoid reordering between backspaces /// (uinput) and text (ydotool). -fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[OutputCommand], grabbed: bool) { +fn execute_commands( + injector: &dyn vietc_protocol::KeyInjector, + commands: &[OutputCommand], + grabbed: bool, +) { let mut pending_backspaces: usize = 0; let mut pending_text = String::new(); for cmd in commands { match cmd { OutputCommand::Backspace(count) => { - let adjusted = if grabbed { count.saturating_sub(1) } else { *count }; - log_info(&format!("[vietc] cmd: Backspace({}) -> adjusted={}", count, adjusted)); + let adjusted = if grabbed { + count.saturating_sub(1) + } else { + *count + }; + log_info(&format!( + "[vietc] cmd: Backspace({}) -> adjusted={}", + count, adjusted + )); pending_backspaces += adjusted; } OutputCommand::Type(text) => { @@ -691,13 +826,32 @@ fn execute_commands(injector: &dyn vietc_protocol::KeyInjector, commands: &[Outp } if pending_backspaces > 0 || !pending_text.is_empty() { - log_info(&format!("[vietc] inject: BS={} text=\"{}\"", pending_backspaces, pending_text)); - injector.inject_replacement(pending_backspaces, &pending_text); + log_info(&format!( + "[vietc] inject: BS={} text=\"{}\"", + pending_backspaces, pending_text + )); + + // Use injector for text (ydotool/xdotool/wtype) + let _ = injector.inject_replacement(pending_backspaces, &pending_text); + } else if !commands.is_empty() { + // Empty text but commands exist (e.g. Backspace only or Flush empty string) + log_info(&format!("[vietc] inject: BS={}", pending_backspaces)); + + let _ = injector.inject_replacement(pending_backspaces, &pending_text); } + injector.flush(); + + // Sleep briefly to let the display server and target application process the + // injected key strokes and clear any modifier states before we handle subsequent physical keys. + if grabbed && !commands.is_empty() { + std::thread::sleep(std::time::Duration::from_millis(20)); + } } -fn create_injector(display: display::DisplayServer) -> Result, Box> { +fn create_injector( + display: display::DisplayServer, +) -> Result, Box> { // Try Wayland input method first (if compiled with wayland feature) #[cfg(feature = "wayland")] { @@ -738,12 +892,7 @@ fn create_injector(display: display::DisplayServer) -> Result>) -> bool { - let key_state = match key_state { - Some(ks) => ks, - None => return false, - }; - +fn is_modifier_pressed(key_state: &evdev::AttributeSet) -> bool { key_state.contains(evdev::Key::KEY_LEFTCTRL) || key_state.contains(evdev::Key::KEY_RIGHTCTRL) || key_state.contains(evdev::Key::KEY_LEFTALT) @@ -752,12 +901,8 @@ fn is_modifier_pressed(key_state: &Option>) -> b || key_state.contains(evdev::Key::KEY_RIGHTMETA) } -fn is_modifier_held_shift(key_state: &Option>) -> bool { - let ks = match key_state { - Some(ks) => ks, - None => return false, - }; - ks.contains(evdev::Key::KEY_LEFTSHIFT) || ks.contains(evdev::Key::KEY_RIGHTSHIFT) +fn is_modifier_held_shift(key_state: &evdev::AttributeSet) -> bool { + key_state.contains(evdev::Key::KEY_LEFTSHIFT) || key_state.contains(evdev::Key::KEY_RIGHTSHIFT) } fn is_caps_lock_on(device: &evdev::Device) -> bool { @@ -768,12 +913,7 @@ fn is_caps_lock_on(device: &evdev::Device) -> bool { } } -fn is_toggle_combination_state(key_state: &Option>, key: &str) -> bool { - let key_state = match key_state { - Some(ks) => ks, - None => return false, - }; - +fn is_toggle_combination_state(key_state: &evdev::AttributeSet, key: &str) -> bool { let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL) || key_state.contains(evdev::Key::KEY_RIGHTCTRL); diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 34e93a3..058cab5 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -7,3 +7,6 @@ description = "Viet+ Vietnamese IME Core Engine" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" + +[dev-dependencies] +insta = { version = "1.34", features = ["yaml"] } diff --git a/engine/examples/gen_tests.rs b/engine/examples/gen_tests.rs index 8377f32..3d9c311 100644 --- a/engine/examples/gen_tests.rs +++ b/engine/examples/gen_tests.rs @@ -1,89 +1,114 @@ -use std::io::{self, Write}; -use vietc_engine::{Engine, EngineEvent, InputMethod}; - -fn get_display(events: &[EngineEvent]) -> String { - let mut display = String::new(); - for ev in events { - match ev { - EngineEvent::Flush(text) => { if !display.ends_with(text) { display.push_str(text); } } - EngineEvent::Insert(text) => display.push_str(text), - EngineEvent::Replace { backspaces, insert } => { - for _ in 0..*backspaces { display.pop(); } - display.push_str(insert); - } - EngineEvent::AutoRestore(word) => { - for _ in 0..word.len() { display.pop(); } - display.push_str(word); - } - EngineEvent::UndoTones { backspaces, restored } => { - for _ in 0..*backspaces { display.pop(); } - display.push_str(restored); - } - } - } - display -} - -fn process_input(e: &mut Engine, input: &str) -> Vec { - let mut events = Vec::new(); - for ch in input.chars() { - if let Some(ev) = e.process_key(ch) { events.push(ev); } - } - events -} +use std::fs::File; const INITIALS: &[&str] = &[ - "", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n", - "ng", "ngh", "nh", "p", "ph", "q", "r", "s", "t", "th", "tr", "v", "x", + "", "b", "c", "ch", "d", "g", "gh", "h", "k", "kh", "l", "m", "n", "ng", "ngh", "nh", "p", + "ph", "q", "r", "s", "t", "th", "tr", "v", "x", ]; const FINALS: &[&str] = &["", "c", "ch", "m", "n", "ng", "nh", "p", "t"]; fn is_valid(init: &str, fin: &str) -> bool { - if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { return false; } - if init == "gh" && !fin.is_empty() { return false; } - if init == "q" { return false; } - if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { return false; } - if fin == "ch" && init == "" { return false; } - if fin == "nh" && init == "" { return false; } + if init == "ngh" && !fin.is_empty() && fin != "n" && fin != "ng" && fin != "nh" { + return false; + } + if init == "gh" && !fin.is_empty() { + return false; + } + if init == "q" { + return false; + } + if init == "g" && !fin.is_empty() && fin != "n" && fin != "ng" { + return false; + } + if fin == "ch" && init.is_empty() { + return false; + } + if fin == "nh" && init.is_empty() { + return false; + } true } fn main() { - // Telex base vowels (as typed, before mod) + // Telex let telex_vowels: Vec<(&str, &str)> = vec![ - ("a", "af"), ("a", "as"), ("a", "aj"), ("a", "ar"), ("a", "ax"), - ("a", "aw"), ("a", "aa"), + ("a", "af"), + ("a", "as"), + ("a", "aj"), + ("a", "ar"), + ("a", "ax"), + ("a", "aw"), + ("a", "aa"), ("e", "ee"), - ("o", "oo"), ("o", "ow"), + ("o", "oo"), + ("o", "ow"), ("u", "uw"), ]; - let mut count = 0; - let stdout = io::stdout(); - let mut handle = stdout.lock(); - + let mut telex_inputs = Vec::new(); for &init in INITIALS { for &fin in FINALS { - if !is_valid(init, fin) { continue; } + if !is_valid(init, fin) { + continue; + } for &(base, mod_str) in &telex_vowels { let plain = format!("{}{}{}", init, base, fin); let full = format!("{}{}", plain, mod_str); - if plain.len() > 10 { continue; } - - let mut e = Engine::new(InputMethod::Telex); - let result = get_display(&process_input(&mut e, &full)); - - if !result.is_empty() && result.len() <= 12 && result != full && result != plain { - count += 1; - let _ = writeln!(handle, "{{\"i\":\"{full}\",\"e\":\"{result}\",\"m\":\"telex\"}}"); + if plain.len() > 10 { + continue; } - if count >= 1000 { break; } + telex_inputs.push(full); } - if count >= 1000 { break; } } - if count >= 1000 { break; } } + // Limit to 500 cases to keep snapshot size reasonable but comprehensive + telex_inputs.truncate(500); - eprintln!("Generated {count} test cases"); + // VNI + let vni_vowels: Vec<(&str, &str)> = vec![ + ("a", "1"), + ("a", "2"), + ("a", "3"), + ("a", "4"), + ("a", "5"), + ("a", "6"), + ("a", "8"), + ("e", "6"), + ("o", "6"), + ("o", "7"), + ("u", "7"), + ]; + + let mut vni_inputs = Vec::new(); + for &init in INITIALS { + for &fin in FINALS { + if !is_valid(init, fin) { + continue; + } + for &(base, mod_str) in &vni_vowels { + let plain = format!("{}{}{}", init, base, fin); + let full = format!("{}{}", plain, mod_str); + if plain.len() > 10 { + continue; + } + vni_inputs.push(full); + } + } + } + vni_inputs.truncate(500); + + // Ensure output directory exists + std::fs::create_dir_all("tests/testdata").unwrap(); + + let mut f_telex = File::create("tests/testdata/telex_inputs.json").unwrap(); + serde_json::to_writer_pretty(&mut f_telex, &telex_inputs).unwrap(); + + let mut f_vni = File::create("tests/testdata/vni_inputs.json").unwrap(); + serde_json::to_writer_pretty(&mut f_vni, &vni_inputs).unwrap(); + + println!( + "Generated {} Telex and {} VNI test inputs under tests/testdata/", + telex_inputs.len(), + vni_inputs.len() + ); } diff --git a/engine/examples/trace_events.rs b/engine/examples/trace_events.rs index 61e3304..d1b60b6 100644 --- a/engine/examples/trace_events.rs +++ b/engine/examples/trace_events.rs @@ -1,4 +1,4 @@ -use vietc_engine::{Engine, InputMethod, EngineEvent}; +use vietc_engine::{Engine, EngineEvent, InputMethod}; fn trace(input: &str, method: InputMethod) { let mut e = Engine::new(method); @@ -11,55 +11,62 @@ fn trace(input: &str, method: InputMethod) { let curr = e.buffer().to_string(); let expected = format!("{}{}", prev, ch); let event_str = match &event { - Some(EngineEvent::Replace { backspaces, insert }) => - format!("Replace({}, {:?})", backspaces, insert), + Some(EngineEvent::Replace { backspaces, insert }) => { + format!("Replace({}, {:?})", backspaces, insert) + } Some(EngineEvent::Insert(t)) => format!("Insert({:?})", t), Some(EngineEvent::Flush(t)) => format!("Flush({:?})", t), Some(EngineEvent::AutoRestore(w)) => format!("AutoRestore({:?})", w), - Some(EngineEvent::UndoTones { backspaces, restored }) => - format!("UndoTones({}, {:?})", backspaces, restored), + Some(EngineEvent::UndoTones { + backspaces, + restored, + }) => format!("UndoTones({}, {:?})", backspaces, restored), + Some(EngineEvent::Paste(t)) => format!("Paste({:?})", t), None => "None".to_string(), }; - let backspaces = match &event { - Some(EngineEvent::Replace { backspaces, .. }) => format!("bs={}", backspaces), - _ => " ".to_string(), - }; - eprintln!("'{}' | {:<9} → {:<9} | {:<19} | {}", - ch, prev, curr, expected, event_str); + eprintln!( + "'{}' | {:<9} → {:<9} | {:<19} | {}", + ch, prev, curr, expected, event_str + ); if let Some(EngineEvent::Replace { backspaces, insert }) = &event { // In grab mode, backspace - 1 (key consumed) let grab_bs = backspaces.saturating_sub(1); // In non-grab mode, full backspace - eprintln!(" | | | grab_bs={} non_grab_bs={} insert={:?}", - grab_bs, backspaces, insert); + eprintln!( + " | | | grab_bs={} non_grab_bs={} insert={:?}", + grab_bs, backspaces, insert + ); } } // Flush if let Some(event) = e.flush() { - eprintln!("FL | | | | {:?}", event); + eprintln!( + "FL | | | | {:?}", + event + ); } } fn main() { // Category 1: Basic A group - trace("traan", InputMethod::Telex); // trâ - trace("traanw", InputMethod::Telex); // trân → w → trăn - trace("tranwa", InputMethod::Telex); // trăn → a → trân + trace("traan", InputMethod::Telex); // trâ + trace("traanw", InputMethod::Telex); // trân → w → trăn + trace("tranwa", InputMethod::Telex); // trăn → a → trân // Category 2: Basic O group - trace("coon", InputMethod::Telex); // côn - trace("coonw", InputMethod::Telex); // côn → w → cơn - trace("conwo", InputMethod::Telex); // cơn → o → côn + trace("coon", InputMethod::Telex); // côn + trace("coonw", InputMethod::Telex); // côn → w → cơn + trace("conwo", InputMethod::Telex); // cơn → o → côn // Category 3: Smart cluster - trace("chuoonw", InputMethod::Telex); // chuôn → w → chươn - trace("chuonwo", InputMethod::Telex); // chươn → o → chuôn + trace("chuoonw", InputMethod::Telex); // chuôn → w → chươn + trace("chuonwo", InputMethod::Telex); // chươn → o → chuôn // Category 4: With tones - trace("traansw", InputMethod::Telex); // trấn → w → trắn + trace("traansw", InputMethod::Telex); // trấn → w → trắn // Basic typing - trace("chaof ", InputMethod::Telex); // chào + space + trace("chaof ", InputMethod::Telex); // chào + space // VNI tests trace("tran6", InputMethod::Vni); diff --git a/engine/src/engine.rs b/engine/src/engine.rs index e7a9919..88c1b26 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -1,21 +1,29 @@ +use crate::english::EnglishDict; use crate::telex::TelexEngine; use crate::vni::VniEngine; -use crate::english::EnglishDict; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum InputMethod { Telex, Vni, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[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 }, + UndoTones { + backspaces: usize, + restored: String, + }, + /// Text was pasted via clipboard - update buffer directly without telex parsing + Paste(String), } pub struct Engine { @@ -26,6 +34,8 @@ pub struct Engine { enabled: bool, macros: std::collections::HashMap, raw_buffer: String, + /// Flag to bypass telex/vni parsing when Unicode text has been pasted via clipboard + paste_mode: bool, } impl Engine { @@ -38,6 +48,7 @@ impl Engine { enabled: true, macros: std::collections::HashMap::new(), raw_buffer: String::new(), + paste_mode: false, } } @@ -57,6 +68,36 @@ impl Engine { 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(); + } + + let event = EngineEvent::Paste(text.to_string()); + self.raw_buffer.push_str(text); + event + } + + /// 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(); @@ -64,6 +105,18 @@ impl Engine { } 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 + return Some(EngineEvent::Flush(word)); + } + } + let event = match self.input_method { InputMethod::Telex => self.telex.flush(), InputMethod::Vni => self.vni.flush(), @@ -151,8 +204,15 @@ impl Engine { 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 lowercase_ch == ' ' + || lowercase_ch == '\t' + || lowercase_ch == '.' + || lowercase_ch == ',' + || lowercase_ch == '!' + || lowercase_ch == '?' + || lowercase_ch == ';' + || lowercase_ch == ':' + || lowercase_ch == '\n' { if self.raw_buffer.is_empty() { return None; @@ -171,15 +231,18 @@ impl Engine { // Try auto-restore before flushing let clean_raw = self.raw_buffer.to_lowercase(); - if self.english.should_restore(&clean_raw) { - let inner_buf = self.buffer().to_string(); - let clean_inner = strip_diacritics(&inner_buf).to_lowercase(); - let has_diacritics = clean_inner != inner_buf.to_lowercase(); - + let 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, @@ -193,7 +256,7 @@ impl Engine { // 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(); @@ -214,26 +277,48 @@ impl Engine { return result; } - // Regular character processing let previous_inner = self.buffer().to_string(); self.raw_buffer.push(ch); - match self.input_method { - InputMethod::Telex => { self.telex.process_key(lowercase_ch); } - InputMethod::Vni => { self.vni.process_key(lowercase_ch); } - } - - let new_inner = self.buffer().to_string(); let expected_screen = format!("{}{}", previous_inner, lowercase_ch); - 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, - }) + 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 { - None + 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 + } } } @@ -250,25 +335,33 @@ fn strip_diacritics(s: &str) -> String { s.chars() .map(|c| match c { // a variants - 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' - | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a', + 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' + | 'ẩ' | 'ẫ' | 'ậ' => 'a', // A variants - 'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' - | 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A', + 'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' | 'Â' | 'Ầ' | 'Ấ' + | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A', // e variants - 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e', - 'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E', + 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => { + 'e' + } + 'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => { + 'E' + } // i variants 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i', 'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I', // o variants - 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' - | 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o', - 'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' - | 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O', + 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ' + | 'ở' | 'ỡ' | 'ợ' => 'o', + 'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' | 'Ơ' | 'Ờ' | 'Ớ' + | 'Ở' | 'Ỡ' | 'Ợ' => 'O', // u variants - 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u', - 'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U', + 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => { + 'u' + } + 'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => { + 'U' + } // y variants 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y', 'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y', @@ -331,7 +424,10 @@ mod tests { } let event = engine.process_escape(); match event { - Some(EngineEvent::UndoTones { backspaces, restored }) => { + Some(EngineEvent::UndoTones { + backspaces, + restored, + }) => { assert_eq!(backspaces, 4); // "chào" is 4 chars assert_eq!(restored, "chao"); } @@ -346,17 +442,21 @@ mod tests { engine.add_macro("ok".into(), "được".into()); // Type "ko" + space - let events: Vec<_> = "ko ".chars() + 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(); + 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")); } diff --git a/engine/src/english.rs b/engine/src/english.rs index d02181e..a33e4fc 100644 --- a/engine/src/english.rs +++ b/engine/src/english.rs @@ -15,46 +15,332 @@ impl EnglishDict { // These would trigger false Vietnamese conversions let common_words = [ // Programming/tech - "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", - "her", "was", "one", "our", "out", "day", "get", "has", "him", "his", - "how", "its", "may", "new", "now", "old", "see", "way", "who", "did", - "does", "each", "from", "have", "here", "just", "like", "long", "look", - "made", "make", "many", "most", "over", "such", "take", "than", "them", - "then", "that", "this", "time", "very", "when", "what", "will", "with", - "also", "back", "been", "call", "came", "come", "could", "does", "done", - "down", "each", "even", "find", "first", "from", "give", "goes", "going", - "good", "great", "hand", "have", "head", "help", "high", "home", "hope", - "into", "keep", "know", "last", "left", "life", "like", "line", "live", - "look", "made", "make", "many", "mean", "more", "most", "much", "must", - "name", "need", "next", "only", "open", "part", "place", "point", "right", - "same", "said", "second", "should", "show", "small", "some", "something", - "still", "such", "sure", "take", "tell", "than", "that", "them", "then", - "there", "these", "they", "thing", "think", "this", "those", "time", - "turn", "upon", "very", "want", "well", "went", "were", "what", "when", - "where", "which", "while", "will", "with", "work", "would", "year", "your", + "the", + "and", + "for", + "are", + "but", + "not", + "you", + "all", + "can", + "had", + "her", + "was", + "one", + "our", + "out", + "day", + "get", + "has", + "him", + "his", + "how", + "its", + "may", + "new", + "now", + "old", + "see", + "way", + "who", + "did", + "does", + "each", + "from", + "have", + "here", + "just", + "like", + "long", + "look", + "made", + "make", + "many", + "most", + "over", + "such", + "take", + "than", + "them", + "then", + "that", + "this", + "time", + "very", + "when", + "what", + "will", + "with", + "also", + "back", + "been", + "call", + "came", + "come", + "could", + "does", + "done", + "down", + "each", + "even", + "find", + "first", + "from", + "give", + "goes", + "going", + "good", + "great", + "hand", + "have", + "head", + "help", + "high", + "home", + "hope", + "into", + "keep", + "know", + "last", + "left", + "life", + "like", + "line", + "live", + "look", + "made", + "make", + "many", + "mean", + "more", + "most", + "much", + "must", + "name", + "need", + "next", + "only", + "open", + "part", + "place", + "point", + "right", + "same", + "said", + "second", + "should", + "show", + "small", + "some", + "something", + "still", + "such", + "sure", + "take", + "tell", + "than", + "that", + "them", + "then", + "there", + "these", + "they", + "thing", + "think", + "this", + "those", + "time", + "turn", + "upon", + "very", + "want", + "well", + "went", + "were", + "what", + "when", + "where", + "which", + "while", + "will", + "with", + "work", + "would", + "year", + "your", // Common words that conflict with Vietnamese - "ok", "no", "so", "do", "go", "to", "in", "on", "at", "by", "up", - "an", "as", "be", "he", "if", "is", "it", "me", "my", "of", "or", - "am", "we", "us", "set", "run", "put", "get", "let", "say", - "ask", "try", "use", "add", "end", "few", "far", "got", "big", "off", - "old", "own", "red", "hot", "top", "far", "low", "six", "ten", "red", + "ok", + "no", + "so", + "do", + "go", + "to", + "in", + "on", + "at", + "by", + "up", + "an", + "as", + "be", + "he", + "if", + "is", + "it", + "me", + "my", + "of", + "or", + "am", + "we", + "us", + "set", + "run", + "put", + "get", + "let", + "say", + "ask", + "try", + "use", + "add", + "end", + "few", + "far", + "got", + "big", + "off", + "old", + "own", + "red", + "hot", + "top", + "far", + "low", + "six", + "ten", + "red", // Greetings & common - "hello", "hi", "hey", "bye", "thanks", "thank", "please", "sorry", - "yes", "yeah", "no", "ok", "okay", "sure", "well", "too", "also", + "hello", + "hi", + "hey", + "bye", + "thanks", + "thank", + "please", + "sorry", + "yes", + "yeah", + "no", + "ok", + "okay", + "sure", + "well", + "too", + "also", // More common English - "about", "after", "again", "being", "below", "between", "both", - "came", "come", "could", "does", "done", "down", "each", "even", - "find", "first", "from", "give", "goes", "going", "good", "great", - "hand", "have", "head", "help", "high", "home", "hope", "into", - "keep", "kind", "know", "last", "left", "life", "like", "line", - "live", "long", "look", "made", "make", "many", "mean", "more", - "most", "much", "must", "name", "need", "next", "only", "open", - "part", "place", "point", "right", "same", "said", "second", - "should", "show", "small", "some", "something", "still", "sure", - "take", "tell", "than", "that", "them", "then", "there", "these", - "they", "thing", "think", "this", "those", "time", "turn", "upon", - "very", "want", "well", "went", "were", "what", "when", "where", - "which", "while", "will", "with", "work", "would", "year", "your", + "about", + "after", + "again", + "being", + "below", + "between", + "both", + "came", + "come", + "could", + "does", + "done", + "down", + "each", + "even", + "find", + "first", + "from", + "give", + "goes", + "going", + "good", + "great", + "hand", + "have", + "head", + "help", + "high", + "home", + "hope", + "into", + "keep", + "kind", + "know", + "last", + "left", + "life", + "like", + "line", + "live", + "long", + "look", + "made", + "make", + "many", + "mean", + "more", + "most", + "much", + "must", + "name", + "need", + "next", + "only", + "open", + "part", + "place", + "point", + "right", + "same", + "said", + "second", + "should", + "show", + "small", + "some", + "something", + "still", + "sure", + "take", + "tell", + "than", + "that", + "them", + "then", + "there", + "these", + "they", + "thing", + "think", + "this", + "those", + "time", + "turn", + "upon", + "very", + "want", + "well", + "went", + "were", + "what", + "when", + "where", + "which", + "while", + "will", + "with", + "work", + "would", + "year", + "your", ]; for word in common_words { diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 10ef728..87df8b7 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,7 +1,8 @@ mod engine; +mod english; +mod spelling; mod telex; mod vni; -mod english; #[cfg(test)] mod tests; diff --git a/engine/src/spelling.rs b/engine/src/spelling.rs new file mode 100644 index 0000000..324412e --- /dev/null +++ b/engine/src/spelling.rs @@ -0,0 +1,317 @@ +const FIRST_CONSONANT_SEQS: &[&str] = &[ + "b d đ g gh m n nh p ph r s t tr v z", + "c h k kh qu th", + "ch gi l ng ngh x", + "đ l", + "h", +]; + +const VOWEL_SEQS: &[&str] = &[ + "ê i ua uê uy y", + "a iê oa uyê yê", + "â ă e o oo ô ơ oe u ư uâ uô ươ", + "oă", + "uơ", + "ai ao au âu ay ây eo êu ia iêu iu oai oao oay oeo oi ôi ơi ưa uây ui ưi uôi ươi ươu ưu uya uyu yêu", + "ă", + "i", +]; + +const LAST_CONSONANT_SEQS: &[&str] = &["ch nh", "c ng", "m n p t", "k", "c"]; + +const CV_MATRIX: &[&[usize]] = &[ + &[0, 1, 2, 5], + &[0, 1, 2, 3, 4, 5], + &[0, 1, 2, 3, 5], + &[6], + &[7], +]; + +const VC_MATRIX: &[&[usize]] = &[&[0, 2], &[0, 1, 2], &[1, 2], &[1, 2], &[], &[], &[3], &[4]]; + +fn strip_tone(c: char) -> char { + match c { + 'à' | 'á' | 'ả' | 'ã' | 'ạ' => 'a', + 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' => 'ă', + 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'â', + 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' => 'e', + 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'ê', + 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i', + 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' => 'o', + 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' => 'ô', + 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'ơ', + 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' => 'u', + 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'ư', + 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y', + _ => c, + } +} + +fn is_vowel(c: char) -> bool { + matches!( + c, + 'a' | 'à' + | 'á' + | 'ả' + | 'ã' + | 'ạ' + | 'ă' + | 'ằ' + | 'ắ' + | 'ẳ' + | 'ẵ' + | 'ặ' + | 'â' + | 'ầ' + | 'ấ' + | 'ẩ' + | 'ẫ' + | 'ậ' + | 'e' + | 'è' + | 'é' + | 'ẻ' + | 'ẽ' + | 'ẹ' + | 'ê' + | 'ề' + | 'ế' + | 'ể' + | 'ễ' + | 'ệ' + | 'i' + | 'ì' + | 'í' + | 'ỉ' + | 'ĩ' + | 'ị' + | 'o' + | 'ò' + | 'ó' + | 'ỏ' + | 'õ' + | 'ọ' + | 'ô' + | 'ồ' + | 'ố' + | 'ổ' + | 'ỗ' + | 'ộ' + | 'ơ' + | 'ờ' + | 'ớ' + | 'ở' + | 'ỡ' + | 'ợ' + | 'u' + | 'ù' + | 'ú' + | 'ủ' + | 'ũ' + | 'ụ' + | 'ư' + | 'ừ' + | 'ứ' + | 'ử' + | 'ữ' + | 'ự' + | 'y' + | 'ý' + | 'ỳ' + | 'ỷ' + | 'ỹ' + | 'ỵ' + ) +} + +/// Partition a word into (first_consonant, vowel_cluster, last_consonant) +pub fn partition(word: &str) -> (String, String, String) { + let chars: Vec = word.chars().collect(); + let n = chars.len(); + if n == 0 { + return (String::new(), String::new(), String::new()); + } + + // 1. Find the first vowel index + let mut first_vowel_idx = None; + for i in 0..n { + if is_vowel(chars[i]) { + first_vowel_idx = Some(i); + break; + } + } + + let first_vowel = match first_vowel_idx { + Some(idx) => idx, + None => { + return (word.to_string(), String::new(), String::new()); + } + }; + + let mut fc_end = first_vowel; + + // Adjust fc_end for "qu" or "gi" acting as onset + if first_vowel == 1 && chars[0] == 'q' && chars[1] == 'u' && n > 2 && is_vowel(chars[2]) { + fc_end = 2; + } + if first_vowel == 1 && chars[0] == 'g' && chars[1] == 'i' && n > 2 && is_vowel(chars[2]) { + fc_end = 2; + } + + // 2. Find the end of the vowel cluster + let mut vo_end = fc_end; + while vo_end < n && is_vowel(chars[vo_end]) { + vo_end += 1; + } + + let fc: String = chars[..fc_end].iter().collect(); + let vo: String = chars[fc_end..vo_end].iter().collect(); + let lc: String = chars[vo_end..].iter().collect(); + + (fc, vo, lc) +} + +fn lookup(seqs: &[&str], input: &str) -> Vec { + let mut matching_indices = Vec::new(); + if input.is_empty() { + return matching_indices; + } + + for (index, row) in seqs.iter().enumerate() { + for word in row.split_whitespace() { + if word == input { + matching_indices.push(index); + break; + } + } + } + matching_indices +} + +/// Check if a word is a valid Vietnamese syllable according to phonology rules +pub fn is_valid_vietnamese_syllable(word: &str) -> bool { + let lowercase_word = word.to_lowercase(); + + // Quick reject if it has foreign letters 'f', 'j', 'w', 'z' + if lowercase_word + .chars() + .any(|c| matches!(c, 'f' | 'j' | 'w' | 'z')) + { + return false; + } + + // Clean tones from the word to validate spelling structure + let cleaned_word: String = lowercase_word.chars().map(strip_tone).collect(); + + let (fc, vo, lc) = partition(&cleaned_word); + + // If there is no vowel, it must be a valid standalone consonant (like "d", "đ", etc.) + // but typically a full syllable must have a vowel. Let's allow empty vowel only if it's + // a valid first consonant of length 1 or 2 (e.g. for initials/abbreviations). + if vo.is_empty() { + return !fc.is_empty() && !lookup(FIRST_CONSONANT_SEQS, &fc).is_empty(); + } + + let fc_indices = if !fc.is_empty() { + let indices = lookup(FIRST_CONSONANT_SEQS, &fc); + if indices.is_empty() { + return false; // Invalid onset consonant + } + Some(indices) + } else { + None + }; + + let vo_indices = lookup(VOWEL_SEQS, &vo); + if vo_indices.is_empty() { + return false; // Invalid vowel cluster + } + + let lc_indices = if !lc.is_empty() { + let indices = lookup(LAST_CONSONANT_SEQS, &lc); + if indices.is_empty() { + return false; // Invalid coda consonant + } + Some(indices) + } else { + None + }; + + // If we have an onset, check CV compatibility + if let Some(ref fcs) = fc_indices { + let mut cv_valid = false; + for &fc_idx in fcs { + if let Some(allowed_vos) = CV_MATRIX.get(fc_idx) { + for &allowed_vo in *allowed_vos { + if vo_indices.contains(&allowed_vo) { + cv_valid = true; + break; + } + } + } + if cv_valid { + break; + } + } + if !cv_valid { + return false; + } + } + + // If we have a coda, check VC compatibility + if let Some(ref lcs) = lc_indices { + let mut vc_valid = false; + for &vo_idx in &vo_indices { + if let Some(allowed_lcs) = VC_MATRIX.get(vo_idx) { + for &allowed_lc in *allowed_lcs { + if lcs.contains(&allowed_lc) { + vc_valid = true; + break; + } + } + } + if vc_valid { + break; + } + } + if !vc_valid { + return false; + } + } else { + // If there's no coda, we must verify that the vowel allows having no coda + // (all vowel sequences allow no coda, except some specific ones in matrix, but let's see: + // vowel groups 4, 5 have no allowed last consonants in matrix, which is correct). + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_vietnamese_syllables() { + assert!(is_valid_vietnamese_syllable("chuyên")); + assert!(is_valid_vietnamese_syllable("tiếng")); + assert!(is_valid_vietnamese_syllable("việt")); + assert!(is_valid_vietnamese_syllable("quang")); + assert!(is_valid_vietnamese_syllable("giá")); + assert!(is_valid_vietnamese_syllable("oanh")); + assert!(is_valid_vietnamese_syllable("anh")); + assert!(is_valid_vietnamese_syllable("thuở")); + assert!(is_valid_vietnamese_syllable("gì")); + } + + #[test] + fn test_invalid_vietnamese_syllables() { + assert!(!is_valid_vietnamese_syllable("fast")); + assert!(!is_valid_vietnamese_syllable("box")); + assert!(!is_valid_vietnamese_syllable("study")); + assert!(!is_valid_vietnamese_syllable("fát")); + assert!(!is_valid_vietnamese_syllable("făst")); + assert!(!is_valid_vietnamese_syllable("cargo")); + assert!(!is_valid_vietnamese_syllable("rust")); + assert!(!is_valid_vietnamese_syllable("status")); + } +} diff --git a/engine/src/telex.rs b/engine/src/telex.rs index 754f63c..cc361d2 100644 --- a/engine/src/telex.rs +++ b/engine/src/telex.rs @@ -1,23 +1,10 @@ use crate::engine::EngineEvent; -const VOWELS: &[char] = &[ - 'a', 'e', 'i', 'o', 'u', 'y', - 'ă', 'â', 'ê', 'ô', 'ơ', 'ư', -]; - const VOWEL_ACCENTED: &[char] = &[ - 'a', 'á', 'à', 'ả', 'ã', 'ạ', - 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', - 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', - 'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', - 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', - 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', - 'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ', - 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', - 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', - 'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ', - 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', - 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', + 'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e', + 'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó', + 'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù', + 'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', ]; /// Maximum number of characters to scan backward during flexible placement. @@ -34,30 +21,78 @@ fn is_vowel(c: char) -> bool { /// where base_modified_vowel still has its shape modifier (e.g., 'â', 'ă', 'ô', 'ơ'). fn strip_tone(c: char) -> (char, Option) { match c { - 'a' => ('a', None), 'á' => ('a', Some('s')), 'à' => ('a', Some('f')), - 'ả' => ('a', Some('r')), 'ã' => ('a', Some('x')), 'ạ' => ('a', Some('j')), - 'ă' => ('ă', None), 'ắ' => ('ă', Some('s')), 'ằ' => ('ă', Some('f')), - 'ẳ' => ('ă', Some('r')), 'ẵ' => ('ă', Some('x')), 'ặ' => ('ă', Some('j')), - 'â' => ('â', None), 'ấ' => ('â', Some('s')), 'ầ' => ('â', Some('f')), - 'ẩ' => ('â', Some('r')), 'ẫ' => ('â', Some('x')), 'ậ' => ('â', Some('j')), - 'e' => ('e', None), 'é' => ('e', Some('s')), 'è' => ('e', Some('f')), - 'ẻ' => ('e', Some('r')), 'ẽ' => ('e', Some('x')), 'ẹ' => ('e', Some('j')), - 'ê' => ('ê', None), 'ế' => ('ê', Some('s')), 'ề' => ('ê', Some('f')), - 'ể' => ('ê', Some('r')), 'ễ' => ('ê', Some('x')), 'ệ' => ('ê', Some('j')), - 'i' => ('i', None), 'í' => ('i', Some('s')), 'ì' => ('i', Some('f')), - 'ỉ' => ('i', Some('r')), 'ĩ' => ('i', Some('x')), 'ị' => ('i', Some('j')), - 'o' => ('o', None), 'ó' => ('o', Some('s')), 'ò' => ('o', Some('f')), - 'ỏ' => ('o', Some('r')), 'õ' => ('o', Some('x')), 'ọ' => ('o', Some('j')), - 'ô' => ('ô', None), 'ố' => ('ô', Some('s')), 'ồ' => ('ô', Some('f')), - 'ổ' => ('ô', Some('r')), 'ỗ' => ('ô', Some('x')), 'ộ' => ('ô', Some('j')), - 'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('s')), 'ờ' => ('ơ', Some('f')), - 'ở' => ('ơ', Some('r')), 'ỡ' => ('ơ', Some('x')), 'ợ' => ('ơ', Some('j')), - 'u' => ('u', None), 'ú' => ('u', Some('s')), 'ù' => ('u', Some('f')), - 'ủ' => ('u', Some('r')), 'ũ' => ('u', Some('x')), 'ụ' => ('u', Some('j')), - 'ư' => ('ư', None), 'ứ' => ('ư', Some('s')), 'ừ' => ('ư', Some('f')), - 'ử' => ('ư', Some('r')), 'ữ' => ('ư', Some('x')), 'ự' => ('ư', Some('j')), - 'y' => ('y', None), 'ý' => ('y', Some('s')), 'ỳ' => ('y', Some('f')), - 'ỷ' => ('y', Some('r')), 'ỹ' => ('y', Some('x')), 'ỵ' => ('y', Some('j')), + 'a' => ('a', None), + 'á' => ('a', Some('s')), + 'à' => ('a', Some('f')), + 'ả' => ('a', Some('r')), + 'ã' => ('a', Some('x')), + 'ạ' => ('a', Some('j')), + 'ă' => ('ă', None), + 'ắ' => ('ă', Some('s')), + 'ằ' => ('ă', Some('f')), + 'ẳ' => ('ă', Some('r')), + 'ẵ' => ('ă', Some('x')), + 'ặ' => ('ă', Some('j')), + 'â' => ('â', None), + 'ấ' => ('â', Some('s')), + 'ầ' => ('â', Some('f')), + 'ẩ' => ('â', Some('r')), + 'ẫ' => ('â', Some('x')), + 'ậ' => ('â', Some('j')), + 'e' => ('e', None), + 'é' => ('e', Some('s')), + 'è' => ('e', Some('f')), + 'ẻ' => ('e', Some('r')), + 'ẽ' => ('e', Some('x')), + 'ẹ' => ('e', Some('j')), + 'ê' => ('ê', None), + 'ế' => ('ê', Some('s')), + 'ề' => ('ê', Some('f')), + 'ể' => ('ê', Some('r')), + 'ễ' => ('ê', Some('x')), + 'ệ' => ('ê', Some('j')), + 'i' => ('i', None), + 'í' => ('i', Some('s')), + 'ì' => ('i', Some('f')), + 'ỉ' => ('i', Some('r')), + 'ĩ' => ('i', Some('x')), + 'ị' => ('i', Some('j')), + 'o' => ('o', None), + 'ó' => ('o', Some('s')), + 'ò' => ('o', Some('f')), + 'ỏ' => ('o', Some('r')), + 'õ' => ('o', Some('x')), + 'ọ' => ('o', Some('j')), + 'ô' => ('ô', None), + 'ố' => ('ô', Some('s')), + 'ồ' => ('ô', Some('f')), + 'ổ' => ('ô', Some('r')), + 'ỗ' => ('ô', Some('x')), + 'ộ' => ('ô', Some('j')), + 'ơ' => ('ơ', None), + 'ớ' => ('ơ', Some('s')), + 'ờ' => ('ơ', Some('f')), + 'ở' => ('ơ', Some('r')), + 'ỡ' => ('ơ', Some('x')), + 'ợ' => ('ơ', Some('j')), + 'u' => ('u', None), + 'ú' => ('u', Some('s')), + 'ù' => ('u', Some('f')), + 'ủ' => ('u', Some('r')), + 'ũ' => ('u', Some('x')), + 'ụ' => ('u', Some('j')), + 'ư' => ('ư', None), + 'ứ' => ('ư', Some('s')), + 'ừ' => ('ư', Some('f')), + 'ử' => ('ư', Some('r')), + 'ữ' => ('ư', Some('x')), + 'ự' => ('ư', Some('j')), + 'y' => ('y', None), + 'ý' => ('y', Some('s')), + 'ỳ' => ('y', Some('f')), + 'ỷ' => ('y', Some('r')), + 'ỹ' => ('y', Some('x')), + 'ỵ' => ('y', Some('j')), _ => (c, None), } } @@ -65,18 +100,66 @@ fn strip_tone(c: char) -> (char, Option) { fn apply_tone_to_vowel(vowel: char, tone: char) -> Option { // Standard Telex: f=huyền, s=sắc, r=hỏi, x=ngã, j=nặng let table: &[(char, char, char)] = &[ - ('a', 'f', 'à'), ('a', 's', 'á'), ('a', 'r', 'ả'), ('a', 'x', 'ã'), ('a', 'j', 'ạ'), - ('ă', 'f', 'ằ'), ('ă', 's', 'ắ'), ('ă', 'r', 'ẳ'), ('ă', 'x', 'ẵ'), ('ă', 'j', 'ặ'), - ('â', 'f', 'ầ'), ('â', 's', 'ấ'), ('â', 'r', 'ẩ'), ('â', 'x', 'ẫ'), ('â', 'j', 'ậ'), - ('e', 'f', 'è'), ('e', 's', 'é'), ('e', 'r', 'ẻ'), ('e', 'x', 'ẽ'), ('e', 'j', 'ẹ'), - ('ê', 'f', 'ề'), ('ê', 's', 'ế'), ('ê', 'r', 'ể'), ('ê', 'x', 'ễ'), ('ê', 'j', 'ệ'), - ('i', 'f', 'ì'), ('i', 's', 'í'), ('i', 'r', 'ỉ'), ('i', 'x', 'ĩ'), ('i', 'j', 'ị'), - ('o', 'f', 'ò'), ('o', 's', 'ó'), ('o', 'r', 'ỏ'), ('o', 'x', 'õ'), ('o', 'j', 'ọ'), - ('ô', 'f', 'ồ'), ('ô', 's', 'ố'), ('ô', 'r', 'ổ'), ('ô', 'x', 'ỗ'), ('ô', 'j', 'ộ'), - ('ơ', 'f', 'ờ'), ('ơ', 's', 'ớ'), ('ơ', 'r', 'ở'), ('ơ', 'x', 'ỡ'), ('ơ', 'j', 'ợ'), - ('u', 'f', 'ù'), ('u', 's', 'ú'), ('u', 'r', 'ủ'), ('u', 'x', 'ũ'), ('u', 'j', 'ụ'), - ('ư', 'f', 'ừ'), ('ư', 's', 'ứ'), ('ư', 'r', 'ử'), ('ư', 'x', 'ữ'), ('ư', 'j', 'ự'), - ('y', 'f', 'ỳ'), ('y', 's', 'ý'), ('y', 'r', 'ỷ'), ('y', 'x', 'ỹ'), ('y', 'j', 'ỵ'), + ('a', 'f', 'à'), + ('a', 's', 'á'), + ('a', 'r', 'ả'), + ('a', 'x', 'ã'), + ('a', 'j', 'ạ'), + ('ă', 'f', 'ằ'), + ('ă', 's', 'ắ'), + ('ă', 'r', 'ẳ'), + ('ă', 'x', 'ẵ'), + ('ă', 'j', 'ặ'), + ('â', 'f', 'ầ'), + ('â', 's', 'ấ'), + ('â', 'r', 'ẩ'), + ('â', 'x', 'ẫ'), + ('â', 'j', 'ậ'), + ('e', 'f', 'è'), + ('e', 's', 'é'), + ('e', 'r', 'ẻ'), + ('e', 'x', 'ẽ'), + ('e', 'j', 'ẹ'), + ('ê', 'f', 'ề'), + ('ê', 's', 'ế'), + ('ê', 'r', 'ể'), + ('ê', 'x', 'ễ'), + ('ê', 'j', 'ệ'), + ('i', 'f', 'ì'), + ('i', 's', 'í'), + ('i', 'r', 'ỉ'), + ('i', 'x', 'ĩ'), + ('i', 'j', 'ị'), + ('o', 'f', 'ò'), + ('o', 's', 'ó'), + ('o', 'r', 'ỏ'), + ('o', 'x', 'õ'), + ('o', 'j', 'ọ'), + ('ô', 'f', 'ồ'), + ('ô', 's', 'ố'), + ('ô', 'r', 'ổ'), + ('ô', 'x', 'ỗ'), + ('ô', 'j', 'ộ'), + ('ơ', 'f', 'ờ'), + ('ơ', 's', 'ớ'), + ('ơ', 'r', 'ở'), + ('ơ', 'x', 'ỡ'), + ('ơ', 'j', 'ợ'), + ('u', 'f', 'ù'), + ('u', 's', 'ú'), + ('u', 'r', 'ủ'), + ('u', 'x', 'ũ'), + ('u', 'j', 'ụ'), + ('ư', 'f', 'ừ'), + ('ư', 's', 'ứ'), + ('ư', 'r', 'ử'), + ('ư', 'x', 'ữ'), + ('ư', 'j', 'ự'), + ('y', 'f', 'ỳ'), + ('y', 's', 'ý'), + ('y', 'r', 'ỷ'), + ('y', 'x', 'ỹ'), + ('y', 'j', 'ỵ'), ]; for &(v, t, result) in table { @@ -116,7 +199,6 @@ fn override_telex_modifier(vowel: char, key: char) -> Option { } } - fn apply_w_to_vowel(vowel: char) -> Option { // Telex: aw=ă, ow=ơ, ew=ê, uw=ư // (aa=â, ee=ê, oo=ô are handled by double-letter logic) @@ -144,11 +226,21 @@ fn is_o_vowel(c: char) -> bool { fn tone_of_vowel(c: char) -> Option { match c { 'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None, - 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('f'), - 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('s'), - 'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('r'), - 'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('x'), - 'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('j'), + 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => { + Some('f') + } + 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => { + Some('s') + } + 'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => { + Some('r') + } + 'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => { + Some('x') + } + 'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => { + Some('j') + } _ => None, } } @@ -156,13 +248,13 @@ fn tone_of_vowel(c: char) -> Option { /// Apply a Telex tone to the vowel 'ơ', returning the toned variant. fn apply_tone_to_ơ_char(tone: Option) -> char { match tone { - None => 'ơ', - Some('f') => 'ờ', - Some('s') => 'ớ', - Some('r') => 'ở', - Some('x') => 'ỡ', - Some('j') => 'ợ', - _ => 'ơ', + None => 'ơ', + Some('f') => 'ờ', + Some('s') => 'ớ', + Some('r') => 'ở', + Some('x') => 'ỡ', + Some('j') => 'ợ', + _ => 'ơ', } } @@ -181,7 +273,6 @@ fn is_q_before_u(chars: &[char], i: usize) -> bool { i > 1 && chars[i - 2] == 'q' } - pub struct TelexEngine { buffer: String, pending_modifier: Option, @@ -292,10 +383,15 @@ impl TelexEngine { // For oa, oe, uâ, uê, uơ, uy, iê, yê → tone on second vowel let tone_on_second = matches!( (first, second), - ('o', 'a') | ('o', 'e') - | ('u', 'â') | ('u', 'ê') | ('u', 'ơ') | ('u', 'y') - | ('ư', 'ơ') - | ('i', 'ê') | ('y', 'ê') + ('o', 'a') + | ('o', 'e') + | ('u', 'â') + | ('u', 'ê') + | ('u', 'ơ') + | ('u', 'y') + | ('ư', 'ơ') + | ('i', 'ê') + | ('y', 'ê') ); if !tone_on_second { // Apply tone to first vowel @@ -451,7 +547,10 @@ impl TelexEngine { if is_o_vowel(last_ch) { // Smart cluster "uo" → "ươ" let mut chars: Vec = self.buffer.chars().collect(); - if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + if chars.len() >= 2 + && is_u_vowel(chars[chars.len() - 2]) + && !is_q_before_u(&chars, chars.len() - 1) + { let o_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap(); let (new_first, new_second) = uo_to_uơ(u_char, o_char); @@ -471,7 +570,10 @@ impl TelexEngine { let strip = strip_tone(last_ch); if strip.0 == 'ô' || strip.0 == 'ơ' { let mut chars: Vec = self.buffer.chars().collect(); - if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + if chars.len() >= 2 + && is_u_vowel(chars[chars.len() - 2]) + && !is_q_before_u(&chars, chars.len() - 1) + { let o_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap(); let (new_first, new_second) = uo_to_uơ(u_char, o_char); @@ -499,7 +601,11 @@ impl TelexEngine { for i in (start..chars.len()).rev() { if is_vowel(chars[i]) { // Smart cluster "uo" → "ươ" (flexible) - if is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { + if is_o_vowel(chars[i]) + && i > 0 + && is_u_vowel(chars[i - 1]) + && !is_q_before_u(&chars, i) + { let (new_first, new_second) = uo_to_uơ(chars[i - 1], chars[i]); self.buffer = chars[..i - 1].iter().collect::(); self.buffer.push(new_first); @@ -580,4 +686,3 @@ impl TelexEngine { None } } - diff --git a/engine/src/tests.rs b/engine/src/tests.rs index 60e3c8e..f13071b 100644 --- a/engine/src/tests.rs +++ b/engine/src/tests.rs @@ -6,7 +6,10 @@ mod tests { let mut events = Vec::new(); for ch in input.chars() { if ch == '\x08' { - events.push(EngineEvent::Replace { backspaces: 1, insert: String::new() }); + events.push(EngineEvent::Replace { + backspaces: 1, + insert: String::new(), + }); let _ = engine.process_key(ch); continue; } @@ -26,7 +29,7 @@ mod tests { let mut output = String::new(); for ev in events { match ev { - EngineEvent::Flush(text) | EngineEvent::Insert(text) => { + EngineEvent::Flush(text) | EngineEvent::Insert(text) | EngineEvent::Paste(text) => { output.push_str(text); } EngineEvent::Replace { backspaces, insert } => { @@ -41,7 +44,10 @@ mod tests { } output.push_str(word); } - EngineEvent::UndoTones { backspaces, restored } => { + EngineEvent::UndoTones { + backspaces, + restored, + } => { for _ in 0..*backspaces { output.push('\x08'); } @@ -56,7 +62,7 @@ mod tests { let mut display = String::new(); for ev in events { match ev { - EngineEvent::Flush(text) => { + EngineEvent::Flush(text) | EngineEvent::Paste(text) => { if !display.ends_with(text) { display.push_str(text); } @@ -76,7 +82,10 @@ mod tests { } display.push_str(word); } - EngineEvent::UndoTones { backspaces, restored } => { + EngineEvent::UndoTones { + backspaces, + restored, + } => { for _ in 0..*backspaces { display.pop(); } @@ -972,7 +981,10 @@ mod tests { e.process_key('s'); let event = e.process_escape(); match event { - Some(EngineEvent::UndoTones { backspaces, restored }) => { + Some(EngineEvent::UndoTones { + backspaces, + restored, + }) => { assert_eq!(backspaces, 1); assert_eq!(restored, "a"); } @@ -988,7 +1000,10 @@ mod tests { } let event = e.process_escape(); match event { - Some(EngineEvent::UndoTones { backspaces, restored }) => { + Some(EngineEvent::UndoTones { + backspaces, + restored, + }) => { assert_eq!(backspaces, 4); assert_eq!(restored, "chao"); } @@ -1113,7 +1128,10 @@ mod tests { 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группа "); + assert_eq!( + get_display(&process_input(&mut e, "bhg ")), + "bài họcгруппа " + ); } #[test] @@ -1129,7 +1147,10 @@ mod tests { 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 "); + assert_eq!( + get_display(&process_input(&mut e, "vs hello ")), + "với hello " + ); } // ================================================================ @@ -1212,10 +1233,13 @@ mod tests { 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(); + 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())); @@ -1415,10 +1439,13 @@ mod tests { 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(); + 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), "á"); @@ -1428,10 +1455,13 @@ mod tests { 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(); + 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), "â"); @@ -1441,10 +1471,13 @@ mod tests { 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(); + 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), "ă"); @@ -1454,12 +1487,20 @@ mod tests { 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(); + 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.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), "ắ"); @@ -1469,12 +1510,20 @@ mod tests { 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(); + 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.len(), + 1, + "Expected 1 Replace event: {:?}", + replace_events + ); assert_eq!(replace_events[0], (3, "oá".to_string())); assert_eq!(get_display(&events), "oá"); } @@ -1483,12 +1532,20 @@ mod tests { 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(); + 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.len(), + 1, + "Expected 1 Replace event: {:?}", + replace_events + ); assert_eq!(replace_events[0], (3, "uý".to_string())); assert_eq!(get_display(&events), "uý"); } @@ -1498,15 +1555,23 @@ mod tests { // "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(); + 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!( + replace_events.len(), + 0, + "Expected no Replace events, got: {:?}", + replace_events + ); assert_eq!(get_display(&events), "bs"); } @@ -1517,15 +1582,23 @@ mod tests { // 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(); + 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); + 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, ""); @@ -1545,13 +1618,20 @@ mod tests { // "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(); + 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!( + replace_events.len(), + 0, + "No Replace events for plain English" + ); assert_eq!(get_display(&events), "hello "); } @@ -1560,13 +1640,20 @@ mod tests { 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(); + 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.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 "); @@ -1577,17 +1664,25 @@ mod tests { // "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(); + 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.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ò "); @@ -1601,7 +1696,10 @@ mod tests { } let event = e.process_escape(); match event { - Some(EngineEvent::UndoTones { backspaces, restored }) => { + Some(EngineEvent::UndoTones { + backspaces, + restored, + }) => { assert_eq!(backspaces, 4, "ESC undo should backspace 4 chars (chào)"); assert_eq!(restored, "chao"); } @@ -1615,20 +1713,36 @@ mod tests { // Then flush → "a". let mut e = Engine::new(InputMethod::Telex); e.process_key('a'); - e.process_key('s'); // buffer = "á" + 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); } + 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 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)"); + assert_eq!( + display, " a", + "Display should be ' ' (from Insert) + 'a' (from flush)" + ); } #[test] @@ -1636,10 +1750,13 @@ mod tests { 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(); + 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 "); } @@ -1656,11 +1773,19 @@ mod tests { // 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); + 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"); @@ -1671,11 +1796,19 @@ mod tests { 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); + 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"); @@ -1695,7 +1828,11 @@ mod tests { 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"); + assert_eq!( + e.buffer(), + "", + "Engine buffer should be empty after backspace" + ); // Verify raw_buffer is also empty (sync'd via char count matching) } @@ -1738,11 +1875,19 @@ mod tests { 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); + 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), "á"); @@ -1752,10 +1897,13 @@ mod tests { 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(); + 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, "â"); @@ -1766,12 +1914,20 @@ mod tests { 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(); + 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.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); @@ -1784,10 +1940,13 @@ mod tests { // "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(); + 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"); } @@ -1797,11 +1956,19 @@ mod tests { 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); + 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"); @@ -1818,12 +1985,20 @@ mod tests { // 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(); + 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.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); @@ -1970,11 +2145,19 @@ mod tests { // ' ' = 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); + 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"); @@ -2102,4 +2285,70 @@ mod tests { 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/src/vni.rs b/engine/src/vni.rs index b3d8cff..7b5ecc4 100644 --- a/engine/src/vni.rs +++ b/engine/src/vni.rs @@ -1,23 +1,10 @@ use crate::engine::EngineEvent; -const VOWELS: &[char] = &[ - 'a', 'e', 'i', 'o', 'u', 'y', - 'ă', 'â', 'ê', 'ô', 'ơ', 'ư', -]; - const VOWEL_ACCENTED: &[char] = &[ - 'a', 'á', 'à', 'ả', 'ã', 'ạ', - 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', - 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', - 'e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ', - 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', - 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', - 'o', 'ó', 'ò', 'ỏ', 'õ', 'ọ', - 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', - 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', - 'u', 'ú', 'ù', 'ủ', 'ũ', 'ụ', - 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', - 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', + 'a', 'á', 'à', 'ả', 'ã', 'ạ', 'ă', 'ằ', 'ắ', 'ẳ', 'ẵ', 'ặ', 'â', 'ầ', 'ấ', 'ẩ', 'ẫ', 'ậ', 'e', + 'é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ề', 'ế', 'ể', 'ễ', 'ệ', 'i', 'í', 'ì', 'ỉ', 'ĩ', 'ị', 'o', 'ó', + 'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ồ', 'ố', 'ổ', 'ỗ', 'ộ', 'ơ', 'ờ', 'ớ', 'ở', 'ỡ', 'ợ', 'u', 'ú', 'ù', + 'ủ', 'ũ', 'ụ', 'ư', 'ừ', 'ứ', 'ử', 'ữ', 'ự', 'y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', ]; fn is_vowel(c: char) -> bool { @@ -29,30 +16,78 @@ const MAX_FLEXIBLE_BACKTRACK: usize = 3; /// Strip tone from a Vietnamese vowel, returning (base_modified_vowel, tone_digit_or_none) fn strip_tone_vni(c: char) -> (char, Option) { match c { - 'a' => ('a', None), 'á' => ('a', Some('1')), 'à' => ('a', Some('2')), - 'ả' => ('a', Some('3')), 'ã' => ('a', Some('4')), 'ạ' => ('a', Some('5')), - 'ă' => ('ă', None), 'ắ' => ('ă', Some('1')), 'ằ' => ('ă', Some('2')), - 'ẳ' => ('ă', Some('3')), 'ẵ' => ('ă', Some('4')), 'ặ' => ('ă', Some('5')), - 'â' => ('â', None), 'ấ' => ('â', Some('1')), 'ầ' => ('â', Some('2')), - 'ẩ' => ('â', Some('3')), 'ẫ' => ('â', Some('4')), 'ậ' => ('â', Some('5')), - 'e' => ('e', None), 'é' => ('e', Some('1')), 'è' => ('e', Some('2')), - 'ẻ' => ('e', Some('3')), 'ẽ' => ('e', Some('4')), 'ẹ' => ('e', Some('5')), - 'ê' => ('ê', None), 'ế' => ('ê', Some('1')), 'ề' => ('ê', Some('2')), - 'ể' => ('ê', Some('3')), 'ễ' => ('ê', Some('4')), 'ệ' => ('ê', Some('5')), - 'i' => ('i', None), 'í' => ('i', Some('1')), 'ì' => ('i', Some('2')), - 'ỉ' => ('i', Some('3')), 'ĩ' => ('i', Some('4')), 'ị' => ('i', Some('5')), - 'o' => ('o', None), 'ó' => ('o', Some('1')), 'ò' => ('o', Some('2')), - 'ỏ' => ('o', Some('3')), 'õ' => ('o', Some('4')), 'ọ' => ('o', Some('5')), - 'ô' => ('ô', None), 'ố' => ('ô', Some('1')), 'ồ' => ('ô', Some('2')), - 'ổ' => ('ô', Some('3')), 'ỗ' => ('ô', Some('4')), 'ộ' => ('ô', Some('5')), - 'ơ' => ('ơ', None), 'ớ' => ('ơ', Some('1')), 'ờ' => ('ơ', Some('2')), - 'ở' => ('ơ', Some('3')), 'ỡ' => ('ơ', Some('4')), 'ợ' => ('ơ', Some('5')), - 'u' => ('u', None), 'ú' => ('u', Some('1')), 'ù' => ('u', Some('2')), - 'ủ' => ('u', Some('3')), 'ũ' => ('u', Some('4')), 'ụ' => ('u', Some('5')), - 'ư' => ('ư', None), 'ứ' => ('ư', Some('1')), 'ừ' => ('ư', Some('2')), - 'ử' => ('ư', Some('3')), 'ữ' => ('ư', Some('4')), 'ự' => ('ư', Some('5')), - 'y' => ('y', None), 'ý' => ('y', Some('1')), 'ỳ' => ('y', Some('2')), - 'ỷ' => ('y', Some('3')), 'ỹ' => ('y', Some('4')), 'ỵ' => ('y', Some('5')), + 'a' => ('a', None), + 'á' => ('a', Some('1')), + 'à' => ('a', Some('2')), + 'ả' => ('a', Some('3')), + 'ã' => ('a', Some('4')), + 'ạ' => ('a', Some('5')), + 'ă' => ('ă', None), + 'ắ' => ('ă', Some('1')), + 'ằ' => ('ă', Some('2')), + 'ẳ' => ('ă', Some('3')), + 'ẵ' => ('ă', Some('4')), + 'ặ' => ('ă', Some('5')), + 'â' => ('â', None), + 'ấ' => ('â', Some('1')), + 'ầ' => ('â', Some('2')), + 'ẩ' => ('â', Some('3')), + 'ẫ' => ('â', Some('4')), + 'ậ' => ('â', Some('5')), + 'e' => ('e', None), + 'é' => ('e', Some('1')), + 'è' => ('e', Some('2')), + 'ẻ' => ('e', Some('3')), + 'ẽ' => ('e', Some('4')), + 'ẹ' => ('e', Some('5')), + 'ê' => ('ê', None), + 'ế' => ('ê', Some('1')), + 'ề' => ('ê', Some('2')), + 'ể' => ('ê', Some('3')), + 'ễ' => ('ê', Some('4')), + 'ệ' => ('ê', Some('5')), + 'i' => ('i', None), + 'í' => ('i', Some('1')), + 'ì' => ('i', Some('2')), + 'ỉ' => ('i', Some('3')), + 'ĩ' => ('i', Some('4')), + 'ị' => ('i', Some('5')), + 'o' => ('o', None), + 'ó' => ('o', Some('1')), + 'ò' => ('o', Some('2')), + 'ỏ' => ('o', Some('3')), + 'õ' => ('o', Some('4')), + 'ọ' => ('o', Some('5')), + 'ô' => ('ô', None), + 'ố' => ('ô', Some('1')), + 'ồ' => ('ô', Some('2')), + 'ổ' => ('ô', Some('3')), + 'ỗ' => ('ô', Some('4')), + 'ộ' => ('ô', Some('5')), + 'ơ' => ('ơ', None), + 'ớ' => ('ơ', Some('1')), + 'ờ' => ('ơ', Some('2')), + 'ở' => ('ơ', Some('3')), + 'ỡ' => ('ơ', Some('4')), + 'ợ' => ('ơ', Some('5')), + 'u' => ('u', None), + 'ú' => ('u', Some('1')), + 'ù' => ('u', Some('2')), + 'ủ' => ('u', Some('3')), + 'ũ' => ('u', Some('4')), + 'ụ' => ('u', Some('5')), + 'ư' => ('ư', None), + 'ứ' => ('ư', Some('1')), + 'ừ' => ('ư', Some('2')), + 'ử' => ('ư', Some('3')), + 'ữ' => ('ư', Some('4')), + 'ự' => ('ư', Some('5')), + 'y' => ('y', None), + 'ý' => ('y', Some('1')), + 'ỳ' => ('y', Some('2')), + 'ỷ' => ('y', Some('3')), + 'ỹ' => ('y', Some('4')), + 'ỵ' => ('y', Some('5')), _ => (c, None), } } @@ -60,18 +95,66 @@ fn strip_tone_vni(c: char) -> (char, Option) { fn apply_tone_to_vowel(vowel: char, digit: char) -> Option { // VNI: 1=sắc, 2=huyền, 3=hỏi, 4=ngã, 5=nặng let table: &[(char, char, char)] = &[ - ('a', '1', 'á'), ('a', '2', 'à'), ('a', '3', 'ả'), ('a', '4', 'ã'), ('a', '5', 'ạ'), - ('ă', '1', 'ắ'), ('ă', '2', 'ằ'), ('ă', '3', 'ẳ'), ('ă', '4', 'ẵ'), ('ă', '5', 'ặ'), - ('â', '1', 'ấ'), ('â', '2', 'ầ'), ('â', '3', 'ẩ'), ('â', '4', 'ẫ'), ('â', '5', 'ậ'), - ('e', '1', 'é'), ('e', '2', 'è'), ('e', '3', 'ẻ'), ('e', '4', 'ẽ'), ('e', '5', 'ẹ'), - ('ê', '1', 'ế'), ('ê', '2', 'ề'), ('ê', '3', 'ể'), ('ê', '4', 'ễ'), ('ê', '5', 'ệ'), - ('i', '1', 'í'), ('i', '2', 'ì'), ('i', '3', 'ỉ'), ('i', '4', 'ĩ'), ('i', '5', 'ị'), - ('o', '1', 'ó'), ('o', '2', 'ò'), ('o', '3', 'ỏ'), ('o', '4', 'õ'), ('o', '5', 'ọ'), - ('ô', '1', 'ố'), ('ô', '2', 'ồ'), ('ô', '3', 'ổ'), ('ô', '4', 'ỗ'), ('ô', '5', 'ộ'), - ('ơ', '1', 'ớ'), ('ơ', '2', 'ờ'), ('ơ', '3', 'ở'), ('ơ', '4', 'ỡ'), ('ơ', '5', 'ợ'), - ('u', '1', 'ú'), ('u', '2', 'ù'), ('u', '3', 'ủ'), ('u', '4', 'ũ'), ('u', '5', 'ụ'), - ('ư', '1', 'ứ'), ('ư', '2', 'ừ'), ('ư', '3', 'ử'), ('ư', '4', 'ữ'), ('ư', '5', 'ự'), - ('y', '1', 'ý'), ('y', '2', 'ỳ'), ('y', '3', 'ỷ'), ('y', '4', 'ỹ'), ('y', '5', 'ỵ'), + ('a', '1', 'á'), + ('a', '2', 'à'), + ('a', '3', 'ả'), + ('a', '4', 'ã'), + ('a', '5', 'ạ'), + ('ă', '1', 'ắ'), + ('ă', '2', 'ằ'), + ('ă', '3', 'ẳ'), + ('ă', '4', 'ẵ'), + ('ă', '5', 'ặ'), + ('â', '1', 'ấ'), + ('â', '2', 'ầ'), + ('â', '3', 'ẩ'), + ('â', '4', 'ẫ'), + ('â', '5', 'ậ'), + ('e', '1', 'é'), + ('e', '2', 'è'), + ('e', '3', 'ẻ'), + ('e', '4', 'ẽ'), + ('e', '5', 'ẹ'), + ('ê', '1', 'ế'), + ('ê', '2', 'ề'), + ('ê', '3', 'ể'), + ('ê', '4', 'ễ'), + ('ê', '5', 'ệ'), + ('i', '1', 'í'), + ('i', '2', 'ì'), + ('i', '3', 'ỉ'), + ('i', '4', 'ĩ'), + ('i', '5', 'ị'), + ('o', '1', 'ó'), + ('o', '2', 'ò'), + ('o', '3', 'ỏ'), + ('o', '4', 'õ'), + ('o', '5', 'ọ'), + ('ô', '1', 'ố'), + ('ô', '2', 'ồ'), + ('ô', '3', 'ổ'), + ('ô', '4', 'ỗ'), + ('ô', '5', 'ộ'), + ('ơ', '1', 'ớ'), + ('ơ', '2', 'ờ'), + ('ơ', '3', 'ở'), + ('ơ', '4', 'ỡ'), + ('ơ', '5', 'ợ'), + ('u', '1', 'ú'), + ('u', '2', 'ù'), + ('u', '3', 'ủ'), + ('u', '4', 'ũ'), + ('u', '5', 'ụ'), + ('ư', '1', 'ứ'), + ('ư', '2', 'ừ'), + ('ư', '3', 'ử'), + ('ư', '4', 'ữ'), + ('ư', '5', 'ự'), + ('y', '1', 'ý'), + ('y', '2', 'ỳ'), + ('y', '3', 'ỷ'), + ('y', '4', 'ỹ'), + ('y', '5', 'ỵ'), ]; for &(v, t, result) in table { @@ -145,24 +228,34 @@ fn is_o_vowel(c: char) -> bool { fn tone_of_vowel_vni(c: char) -> Option { match c { 'u' | 'o' | 'a' | 'e' | 'i' | 'y' | 'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư' => None, - 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => Some('2'), - 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => Some('1'), - 'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => Some('3'), - 'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => Some('4'), - 'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => Some('5'), + 'ù' | 'ò' | 'à' | 'è' | 'ì' | 'ỳ' | 'ằ' | 'ầ' | 'ề' | 'ồ' | 'ờ' | 'ừ' => { + Some('2') + } + 'ú' | 'ó' | 'á' | 'é' | 'í' | 'ý' | 'ắ' | 'ấ' | 'ế' | 'ố' | 'ớ' | 'ứ' => { + Some('1') + } + 'ủ' | 'ỏ' | 'ả' | 'ẻ' | 'ỉ' | 'ỷ' | 'ẳ' | 'ẩ' | 'ể' | 'ổ' | 'ở' | 'ử' => { + Some('3') + } + 'ũ' | 'õ' | 'ã' | 'ẽ' | 'ĩ' | 'ỹ' | 'ẵ' | 'ẫ' | 'ễ' | 'ỗ' | 'ỡ' | 'ữ' => { + Some('4') + } + 'ụ' | 'ọ' | 'ạ' | 'ẹ' | 'ị' | 'ỵ' | 'ặ' | 'ậ' | 'ệ' | 'ộ' | 'ợ' | 'ự' => { + Some('5') + } _ => None, } } fn apply_tone_to_ơ_vni(tone: Option) -> char { match tone { - None => 'ơ', + None => 'ơ', Some('2') => 'ờ', Some('1') => 'ớ', Some('3') => 'ở', Some('4') => 'ỡ', Some('5') => 'ợ', - _ => 'ơ', + _ => 'ơ', } } @@ -251,6 +344,22 @@ impl VniEngine { } fn process_digit(&mut self, digit: char) -> Option { + // VNI digit 9: 'd' -> 'đ' or 'D' -> 'Đ' anywhere at the start of the buffer + if digit == '9' { + let mut chars: Vec = self.buffer.chars().collect(); + if !chars.is_empty() { + if chars[0] == 'd' { + chars[0] = 'đ'; + self.buffer = chars.into_iter().collect(); + return None; + } else if chars[0] == 'D' { + chars[0] = 'Đ'; + self.buffer = chars.into_iter().collect(); + return None; + } + } + } + // Apply any pending modifier first if self.pending_modifier.is_some() { self.apply_pending(); @@ -262,7 +371,10 @@ impl VniEngine { // Smart cluster "uo" → "ươ" (digit '7') if digit == '7' && is_o_vowel(last_ch) { let mut chars: Vec = self.buffer.chars().collect(); - if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + if chars.len() >= 2 + && is_u_vowel(chars[chars.len() - 2]) + && !is_q_before_u(&chars, chars.len() - 1) + { let o_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap(); let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char); @@ -291,7 +403,10 @@ impl VniEngine { let strip = strip_tone_vni(last_ch); if strip.0 == 'ô' { let mut chars: Vec = self.buffer.chars().collect(); - if chars.len() >= 2 && is_u_vowel(chars[chars.len() - 2]) && !is_q_before_u(&chars, chars.len() - 1) { + if chars.len() >= 2 + && is_u_vowel(chars[chars.len() - 2]) + && !is_q_before_u(&chars, chars.len() - 1) + { let o_char = chars.pop().unwrap(); let u_char = chars.pop().unwrap(); let (new_first, new_second) = uo_to_uơ_vni(u_char, o_char); @@ -322,12 +437,7 @@ impl VniEngine { } } } - // VNI digit 9: 'd' → 'đ' - if digit == '9' && last_ch == 'd' { - self.buffer.pop(); - self.buffer.push('đ'); - return None; - } + // Modifier override: vowel already has a different modifier if let Some(modified) = override_vni_modifier(last_ch, digit) { self.buffer.pop(); @@ -345,7 +455,12 @@ impl VniEngine { for i in (start..chars.len()).rev() { if is_vowel(chars[i]) { // Smart cluster "uo" → "ươ" (digit '7', flexible) - if digit == '7' && is_o_vowel(chars[i]) && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { + if digit == '7' + && is_o_vowel(chars[i]) + && i > 0 + && is_u_vowel(chars[i - 1]) + && !is_q_before_u(&chars, i) + { let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]); self.buffer = chars[..i - 1].iter().collect::(); self.buffer.push(new_first); @@ -376,7 +491,11 @@ impl VniEngine { // Smart cluster forward (override): "uô" + 7 → "ươ" (flexible) if digit == '7' { let strip = strip_tone_vni(chars[i]); - if strip.0 == 'ô' && i > 0 && is_u_vowel(chars[i - 1]) && !is_q_before_u(&chars, i) { + if strip.0 == 'ô' + && i > 0 + && is_u_vowel(chars[i - 1]) + && !is_q_before_u(&chars, i) + { let (new_first, new_second) = uo_to_uơ_vni(chars[i - 1], chars[i]); self.buffer = chars[..i - 1].iter().collect::(); self.buffer.push(new_first); diff --git a/engine/tests/snapshot_tests.rs b/engine/tests/snapshot_tests.rs new file mode 100644 index 0000000..496559a --- /dev/null +++ b/engine/tests/snapshot_tests.rs @@ -0,0 +1,83 @@ +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/engine/tests/snapshots/snapshot_tests__telex_snapshots.snap b/engine/tests/snapshots/snapshot_tests__telex_snapshots.snap new file mode 100644 index 0000000..cbe6b6f --- /dev/null +++ b/engine/tests/snapshots/snapshot_tests__telex_snapshots.snap @@ -0,0 +1,3964 @@ +--- +source: engine/tests/snapshot_tests.rs +expression: cases +--- +- input: aaf + display: ầ + events: + - Replace: + backspaces: 2 + insert: â + - Replace: + backspaces: 2 + insert: ầ +- input: aas + display: ấ + events: + - Replace: + backspaces: 2 + insert: â + - Replace: + backspaces: 2 + insert: ấ +- input: aaj + display: ậ + events: + - Replace: + backspaces: 2 + insert: â + - Replace: + backspaces: 2 + insert: ậ +- input: aar + display: ẩ + events: + - Replace: + backspaces: 2 + insert: â + - Replace: + backspaces: 2 + insert: ẩ +- input: aax + display: ẫ + events: + - Replace: + backspaces: 2 + insert: â + - Replace: + backspaces: 2 + insert: ẫ +- input: aaw + display: ă + events: + - Replace: + backspaces: 2 + insert: â + - Replace: + backspaces: 2 + insert: ă +- input: aaa + display: â + events: + - Replace: + backspaces: 2 + insert: â +- input: eee + display: ê + events: + - Replace: + backspaces: 2 + insert: ê +- input: ooo + display: ô + events: + - Replace: + backspaces: 2 + insert: ô +- input: oow + display: ơ + events: + - Replace: + backspaces: 2 + insert: ô + - Replace: + backspaces: 2 + insert: ơ +- input: uuw + display: uư + events: + - Replace: + backspaces: 3 + insert: uư +- input: acaf + display: ầc + events: + - Replace: + backspaces: 3 + insert: âc + - Replace: + backspaces: 3 + insert: ầc +- input: acas + display: ấc + events: + - Replace: + backspaces: 3 + insert: âc + - Replace: + backspaces: 3 + insert: ấc +- input: acaj + display: ậc + events: + - Replace: + backspaces: 3 + insert: âc + - Replace: + backspaces: 3 + insert: ậc +- input: acar + display: ẩc + events: + - Replace: + backspaces: 3 + insert: âc + - Replace: + backspaces: 3 + insert: ẩc +- input: acax + display: ẫc + events: + - Replace: + backspaces: 3 + insert: âc + - Replace: + backspaces: 3 + insert: ẫc +- input: acaw + display: ăc + events: + - Replace: + backspaces: 3 + insert: âc + - Replace: + backspaces: 3 + insert: ăc +- input: acaa + display: âc + events: + - Replace: + backspaces: 3 + insert: âc +- input: ecee + display: êc + events: + - Replace: + backspaces: 3 + insert: êc +- input: ocoo + display: ôc + events: + - Replace: + backspaces: 3 + insert: ôc +- input: ocow + display: ơc + events: + - Replace: + backspaces: 3 + insert: ôc + - Replace: + backspaces: 3 + insert: ơc +- input: ucuw + display: ucư + events: + - Replace: + backspaces: 4 + insert: ucư +- input: amaf + display: ầm + events: + - Replace: + backspaces: 3 + insert: âm + - Replace: + backspaces: 3 + insert: ầm +- input: amas + display: ấm + events: + - Replace: + backspaces: 3 + insert: âm + - Replace: + backspaces: 3 + insert: ấm +- input: amaj + display: ậm + events: + - Replace: + backspaces: 3 + insert: âm + - Replace: + backspaces: 3 + insert: ậm +- input: amar + display: ẩm + events: + - Replace: + backspaces: 3 + insert: âm + - Replace: + backspaces: 3 + insert: ẩm +- input: amax + display: ẫm + events: + - Replace: + backspaces: 3 + insert: âm + - Replace: + backspaces: 3 + insert: ẫm +- input: amaw + display: ăm + events: + - Replace: + backspaces: 3 + insert: âm + - Replace: + backspaces: 3 + insert: ăm +- input: amaa + display: âm + events: + - Replace: + backspaces: 3 + insert: âm +- input: emee + display: êm + events: + - Replace: + backspaces: 3 + insert: êm +- input: omoo + display: ôm + events: + - Replace: + backspaces: 3 + insert: ôm +- input: omow + display: ơm + events: + - Replace: + backspaces: 3 + insert: ôm + - Replace: + backspaces: 3 + insert: ơm +- input: umuw + display: umư + events: + - Replace: + backspaces: 4 + insert: umư +- input: anaf + display: ần + events: + - Replace: + backspaces: 3 + insert: ân + - Replace: + backspaces: 3 + insert: ần +- input: anas + display: ấn + events: + - Replace: + backspaces: 3 + insert: ân + - Replace: + backspaces: 3 + insert: ấn +- input: anaj + display: ận + events: + - Replace: + backspaces: 3 + insert: ân + - Replace: + backspaces: 3 + insert: ận +- input: anar + display: ẩn + events: + - Replace: + backspaces: 3 + insert: ân + - Replace: + backspaces: 3 + insert: ẩn +- input: anax + display: ẫn + events: + - Replace: + backspaces: 3 + insert: ân + - Replace: + backspaces: 3 + insert: ẫn +- input: anaw + display: ăn + events: + - Replace: + backspaces: 3 + insert: ân + - Replace: + backspaces: 3 + insert: ăn +- input: anaa + display: ân + events: + - Replace: + backspaces: 3 + insert: ân +- input: enee + display: ên + events: + - Replace: + backspaces: 3 + insert: ên +- input: onoo + display: ôn + events: + - Replace: + backspaces: 3 + insert: ôn +- input: onow + display: ơn + events: + - Replace: + backspaces: 3 + insert: ôn + - Replace: + backspaces: 3 + insert: ơn +- input: unuw + display: unư + events: + - Replace: + backspaces: 4 + insert: unư +- input: angaf + display: ầng + events: + - Replace: + backspaces: 4 + insert: âng + - Replace: + backspaces: 4 + insert: ầng +- input: angas + display: ấng + events: + - Replace: + backspaces: 4 + insert: âng + - Replace: + backspaces: 4 + insert: ấng +- input: angaj + display: ậng + events: + - Replace: + backspaces: 4 + insert: âng + - Replace: + backspaces: 4 + insert: ậng +- input: angar + display: ẩng + events: + - Replace: + backspaces: 4 + insert: âng + - Replace: + backspaces: 4 + insert: ẩng +- input: angax + display: ẫng + events: + - Replace: + backspaces: 4 + insert: âng + - Replace: + backspaces: 4 + insert: ẫng +- input: angaw + display: ăng + events: + - Replace: + backspaces: 4 + insert: âng + - Replace: + backspaces: 4 + insert: ăng +- input: angaa + display: âng + events: + - Replace: + backspaces: 4 + insert: âng +- input: engee + display: êng + events: + - Replace: + backspaces: 4 + insert: êng +- input: ongoo + display: ông + events: + - Replace: + backspaces: 4 + insert: ông +- input: ongow + display: ơng + events: + - Replace: + backspaces: 4 + insert: ông + - Replace: + backspaces: 4 + insert: ơng +- input: unguw + display: ungư + events: + - Replace: + backspaces: 5 + insert: ungư +- input: apaf + display: ầp + events: + - Replace: + backspaces: 3 + insert: âp + - Replace: + backspaces: 3 + insert: ầp +- input: apas + display: ấp + events: + - Replace: + backspaces: 3 + insert: âp + - Replace: + backspaces: 3 + insert: ấp +- input: apaj + display: ập + events: + - Replace: + backspaces: 3 + insert: âp + - Replace: + backspaces: 3 + insert: ập +- input: apar + display: ẩp + events: + - Replace: + backspaces: 3 + insert: âp + - Replace: + backspaces: 3 + insert: ẩp +- input: apax + display: ẫp + events: + - Replace: + backspaces: 3 + insert: âp + - Replace: + backspaces: 3 + insert: ẫp +- input: apaw + display: ăp + events: + - Replace: + backspaces: 3 + insert: âp + - Replace: + backspaces: 3 + insert: ăp +- input: apaa + display: âp + events: + - Replace: + backspaces: 3 + insert: âp +- input: epee + display: êp + events: + - Replace: + backspaces: 3 + insert: êp +- input: opoo + display: ôp + events: + - Replace: + backspaces: 3 + insert: ôp +- input: opow + display: ơp + events: + - Replace: + backspaces: 3 + insert: ôp + - Replace: + backspaces: 3 + insert: ơp +- input: upuw + display: upư + events: + - Replace: + backspaces: 4 + insert: upư +- input: ataf + display: ầt + events: + - Replace: + backspaces: 3 + insert: ât + - Replace: + backspaces: 3 + insert: ầt +- input: atas + display: ất + events: + - Replace: + backspaces: 3 + insert: ât + - Replace: + backspaces: 3 + insert: ất +- input: ataj + display: ật + events: + - Replace: + backspaces: 3 + insert: ât + - Replace: + backspaces: 3 + insert: ật +- input: atar + display: ẩt + events: + - Replace: + backspaces: 3 + insert: ât + - Replace: + backspaces: 3 + insert: ẩt +- input: atax + display: ẫt + events: + - Replace: + backspaces: 3 + insert: ât + - Replace: + backspaces: 3 + insert: ẫt +- input: ataw + display: ăt + events: + - Replace: + backspaces: 3 + insert: ât + - Replace: + backspaces: 3 + insert: ăt +- input: ataa + display: ât + events: + - Replace: + backspaces: 3 + insert: ât +- input: etee + display: êt + events: + - Replace: + backspaces: 3 + insert: êt +- input: otoo + display: ôt + events: + - Replace: + backspaces: 3 + insert: ôt +- input: otow + display: ơt + events: + - Replace: + backspaces: 3 + insert: ôt + - Replace: + backspaces: 3 + insert: ơt +- input: utuw + display: utư + events: + - Replace: + backspaces: 4 + insert: utư +- input: baaf + display: bầ + events: + - Replace: + backspaces: 3 + insert: bâ + - Replace: + backspaces: 3 + insert: bầ +- input: baas + display: bấ + events: + - Replace: + backspaces: 3 + insert: bâ + - Replace: + backspaces: 3 + insert: bấ +- input: baaj + display: bậ + events: + - Replace: + backspaces: 3 + insert: bâ + - Replace: + backspaces: 3 + insert: bậ +- input: baar + display: bẩ + events: + - Replace: + backspaces: 3 + insert: bâ + - Replace: + backspaces: 3 + insert: bẩ +- input: baax + display: bẫ + events: + - Replace: + backspaces: 3 + insert: bâ + - Replace: + backspaces: 3 + insert: bẫ +- input: baaw + display: bă + events: + - Replace: + backspaces: 3 + insert: bâ + - Replace: + backspaces: 3 + insert: bă +- input: baaa + display: bâ + events: + - Replace: + backspaces: 3 + insert: bâ +- input: beee + display: bê + events: + - Replace: + backspaces: 3 + insert: bê +- input: booo + display: bô + events: + - Replace: + backspaces: 3 + insert: bô +- input: boow + display: bơ + events: + - Replace: + backspaces: 3 + insert: bô + - Replace: + backspaces: 3 + insert: bơ +- input: buuw + display: buư + events: + - Replace: + backspaces: 4 + insert: buư +- input: bacaf + display: bầc + events: + - Replace: + backspaces: 4 + insert: bâc + - Replace: + backspaces: 4 + insert: bầc +- input: bacas + display: bấc + events: + - Replace: + backspaces: 4 + insert: bâc + - Replace: + backspaces: 4 + insert: bấc +- input: bacaj + display: bậc + events: + - Replace: + backspaces: 4 + insert: bâc + - Replace: + backspaces: 4 + insert: bậc +- input: bacar + display: bẩc + events: + - Replace: + backspaces: 4 + insert: bâc + - Replace: + backspaces: 4 + insert: bẩc +- input: bacax + display: bẫc + events: + - Replace: + backspaces: 4 + insert: bâc + - Replace: + backspaces: 4 + insert: bẫc +- input: bacaw + display: băc + events: + - Replace: + backspaces: 4 + insert: bâc + - Replace: + backspaces: 4 + insert: băc +- input: bacaa + display: bâc + events: + - Replace: + backspaces: 4 + insert: bâc +- input: becee + display: bêc + events: + - Replace: + backspaces: 4 + insert: bêc +- input: bocoo + display: bôc + events: + - Replace: + backspaces: 4 + insert: bôc +- input: bocow + display: bơc + events: + - Replace: + backspaces: 4 + insert: bôc + - Replace: + backspaces: 4 + insert: bơc +- input: bucuw + display: bucư + events: + - Replace: + backspaces: 5 + insert: bucư +- input: bachaf + display: bầch + events: + - Replace: + backspaces: 5 + insert: bâch + - Replace: + backspaces: 5 + insert: bầch +- input: bachas + display: bấch + events: + - Replace: + backspaces: 5 + insert: bâch + - Replace: + backspaces: 5 + insert: bấch +- input: bachaj + display: bậch + events: + - Replace: + backspaces: 5 + insert: bâch + - Replace: + backspaces: 5 + insert: bậch +- input: bachar + display: bẩch + events: + - Replace: + backspaces: 5 + insert: bâch + - Replace: + backspaces: 5 + insert: bẩch +- input: bachax + display: bẫch + events: + - Replace: + backspaces: 5 + insert: bâch + - Replace: + backspaces: 5 + insert: bẫch +- input: bachaw + display: băch + events: + - Replace: + backspaces: 5 + insert: bâch + - Replace: + backspaces: 5 + insert: băch +- input: bachaa + display: bâch + events: + - Replace: + backspaces: 5 + insert: bâch +- input: bechee + display: bêch + events: + - Replace: + backspaces: 5 + insert: bêch +- input: bochoo + display: bôch + events: + - Replace: + backspaces: 5 + insert: bôch +- input: bochow + display: bơch + events: + - Replace: + backspaces: 5 + insert: bôch + - Replace: + backspaces: 5 + insert: bơch +- input: buchuw + display: buchư + events: + - Replace: + backspaces: 6 + insert: buchư +- input: bamaf + display: bầm + events: + - Replace: + backspaces: 4 + insert: bâm + - Replace: + backspaces: 4 + insert: bầm +- input: bamas + display: bấm + events: + - Replace: + backspaces: 4 + insert: bâm + - Replace: + backspaces: 4 + insert: bấm +- input: bamaj + display: bậm + events: + - Replace: + backspaces: 4 + insert: bâm + - Replace: + backspaces: 4 + insert: bậm +- input: bamar + display: bẩm + events: + - Replace: + backspaces: 4 + insert: bâm + - Replace: + backspaces: 4 + insert: bẩm +- input: bamax + display: bẫm + events: + - Replace: + backspaces: 4 + insert: bâm + - Replace: + backspaces: 4 + insert: bẫm +- input: bamaw + display: băm + events: + - Replace: + backspaces: 4 + insert: bâm + - Replace: + backspaces: 4 + insert: băm +- input: bamaa + display: bâm + events: + - Replace: + backspaces: 4 + insert: bâm +- input: bemee + display: bêm + events: + - Replace: + backspaces: 4 + insert: bêm +- input: bomoo + display: bôm + events: + - Replace: + backspaces: 4 + insert: bôm +- input: bomow + display: bơm + events: + - Replace: + backspaces: 4 + insert: bôm + - Replace: + backspaces: 4 + insert: bơm +- input: bumuw + display: bumư + events: + - Replace: + backspaces: 5 + insert: bumư +- input: banaf + display: bần + events: + - Replace: + backspaces: 4 + insert: bân + - Replace: + backspaces: 4 + insert: bần +- input: banas + display: bấn + events: + - Replace: + backspaces: 4 + insert: bân + - Replace: + backspaces: 4 + insert: bấn +- input: banaj + display: bận + events: + - Replace: + backspaces: 4 + insert: bân + - Replace: + backspaces: 4 + insert: bận +- input: banar + display: bẩn + events: + - Replace: + backspaces: 4 + insert: bân + - Replace: + backspaces: 4 + insert: bẩn +- input: banax + display: bẫn + events: + - Replace: + backspaces: 4 + insert: bân + - Replace: + backspaces: 4 + insert: bẫn +- input: banaw + display: băn + events: + - Replace: + backspaces: 4 + insert: bân + - Replace: + backspaces: 4 + insert: băn +- input: banaa + display: bân + events: + - Replace: + backspaces: 4 + insert: bân +- input: benee + display: bên + events: + - Replace: + backspaces: 4 + insert: bên +- input: bonoo + display: bôn + events: + - Replace: + backspaces: 4 + insert: bôn +- input: bonow + display: bơn + events: + - Replace: + backspaces: 4 + insert: bôn + - Replace: + backspaces: 4 + insert: bơn +- input: bunuw + display: bunư + events: + - Replace: + backspaces: 5 + insert: bunư +- input: bangaf + display: bầng + events: + - Replace: + backspaces: 5 + insert: bâng + - Replace: + backspaces: 5 + insert: bầng +- input: bangas + display: bấng + events: + - Replace: + backspaces: 5 + insert: bâng + - Replace: + backspaces: 5 + insert: bấng +- input: bangaj + display: bậng + events: + - Replace: + backspaces: 5 + insert: bâng + - Replace: + backspaces: 5 + insert: bậng +- input: bangar + display: bẩng + events: + - Replace: + backspaces: 5 + insert: bâng + - Replace: + backspaces: 5 + insert: bẩng +- input: bangax + display: bẫng + events: + - Replace: + backspaces: 5 + insert: bâng + - Replace: + backspaces: 5 + insert: bẫng +- input: bangaw + display: băng + events: + - Replace: + backspaces: 5 + insert: bâng + - Replace: + backspaces: 5 + insert: băng +- input: bangaa + display: bâng + events: + - Replace: + backspaces: 5 + insert: bâng +- input: bengee + display: bêng + events: + - Replace: + backspaces: 5 + insert: bêng +- input: bongoo + display: bông + events: + - Replace: + backspaces: 5 + insert: bông +- input: bongow + display: bơng + events: + - Replace: + backspaces: 5 + insert: bông + - Replace: + backspaces: 5 + insert: bơng +- input: bunguw + display: bungư + events: + - Replace: + backspaces: 6 + insert: bungư +- input: banhaf + display: bầnh + events: + - Replace: + backspaces: 5 + insert: bânh + - Replace: + backspaces: 5 + insert: bầnh +- input: banhas + display: bấnh + events: + - Replace: + backspaces: 5 + insert: bânh + - Replace: + backspaces: 5 + insert: bấnh +- input: banhaj + display: bậnh + events: + - Replace: + backspaces: 5 + insert: bânh + - Replace: + backspaces: 5 + insert: bậnh +- input: banhar + display: bẩnh + events: + - Replace: + backspaces: 5 + insert: bânh + - Replace: + backspaces: 5 + insert: bẩnh +- input: banhax + display: bẫnh + events: + - Replace: + backspaces: 5 + insert: bânh + - Replace: + backspaces: 5 + insert: bẫnh +- input: banhaw + display: bănh + events: + - Replace: + backspaces: 5 + insert: bânh + - Replace: + backspaces: 5 + insert: bănh +- input: banhaa + display: bânh + events: + - Replace: + backspaces: 5 + insert: bânh +- input: benhee + display: bênh + events: + - Replace: + backspaces: 5 + insert: bênh +- input: bonhoo + display: bônh + events: + - Replace: + backspaces: 5 + insert: bônh +- input: bonhow + display: bơnh + events: + - Replace: + backspaces: 5 + insert: bônh + - Replace: + backspaces: 5 + insert: bơnh +- input: bunhuw + display: bunhư + events: + - Replace: + backspaces: 6 + insert: bunhư +- input: bapaf + display: bầp + events: + - Replace: + backspaces: 4 + insert: bâp + - Replace: + backspaces: 4 + insert: bầp +- input: bapas + display: bấp + events: + - Replace: + backspaces: 4 + insert: bâp + - Replace: + backspaces: 4 + insert: bấp +- input: bapaj + display: bập + events: + - Replace: + backspaces: 4 + insert: bâp + - Replace: + backspaces: 4 + insert: bập +- input: bapar + display: bẩp + events: + - Replace: + backspaces: 4 + insert: bâp + - Replace: + backspaces: 4 + insert: bẩp +- input: bapax + display: bẫp + events: + - Replace: + backspaces: 4 + insert: bâp + - Replace: + backspaces: 4 + insert: bẫp +- input: bapaw + display: băp + events: + - Replace: + backspaces: 4 + insert: bâp + - Replace: + backspaces: 4 + insert: băp +- input: bapaa + display: bâp + events: + - Replace: + backspaces: 4 + insert: bâp +- input: bepee + display: bêp + events: + - Replace: + backspaces: 4 + insert: bêp +- input: bopoo + display: bôp + events: + - Replace: + backspaces: 4 + insert: bôp +- input: bopow + display: bơp + events: + - Replace: + backspaces: 4 + insert: bôp + - Replace: + backspaces: 4 + insert: bơp +- input: bupuw + display: bupư + events: + - Replace: + backspaces: 5 + insert: bupư +- input: bataf + display: bầt + events: + - Replace: + backspaces: 4 + insert: bât + - Replace: + backspaces: 4 + insert: bầt +- input: batas + display: bất + events: + - Replace: + backspaces: 4 + insert: bât + - Replace: + backspaces: 4 + insert: bất +- input: bataj + display: bật + events: + - Replace: + backspaces: 4 + insert: bât + - Replace: + backspaces: 4 + insert: bật +- input: batar + display: bẩt + events: + - Replace: + backspaces: 4 + insert: bât + - Replace: + backspaces: 4 + insert: bẩt +- input: batax + display: bẫt + events: + - Replace: + backspaces: 4 + insert: bât + - Replace: + backspaces: 4 + insert: bẫt +- input: bataw + display: băt + events: + - Replace: + backspaces: 4 + insert: bât + - Replace: + backspaces: 4 + insert: băt +- input: bataa + display: bât + events: + - Replace: + backspaces: 4 + insert: bât +- input: betee + display: bêt + events: + - Replace: + backspaces: 4 + insert: bêt +- input: botoo + display: bôt + events: + - Replace: + backspaces: 4 + insert: bôt +- input: botow + display: bơt + events: + - Replace: + backspaces: 4 + insert: bôt + - Replace: + backspaces: 4 + insert: bơt +- input: butuw + display: butư + events: + - Replace: + backspaces: 5 + insert: butư +- input: caaf + display: cầ + events: + - Replace: + backspaces: 3 + insert: câ + - Replace: + backspaces: 3 + insert: cầ +- input: caas + display: cấ + events: + - Replace: + backspaces: 3 + insert: câ + - Replace: + backspaces: 3 + insert: cấ +- input: caaj + display: cậ + events: + - Replace: + backspaces: 3 + insert: câ + - Replace: + backspaces: 3 + insert: cậ +- input: caar + display: cẩ + events: + - Replace: + backspaces: 3 + insert: câ + - Replace: + backspaces: 3 + insert: cẩ +- input: caax + display: cẫ + events: + - Replace: + backspaces: 3 + insert: câ + - Replace: + backspaces: 3 + insert: cẫ +- input: caaw + display: că + events: + - Replace: + backspaces: 3 + insert: câ + - Replace: + backspaces: 3 + insert: că +- input: caaa + display: câ + events: + - Replace: + backspaces: 3 + insert: câ +- input: ceee + display: cê + events: + - Replace: + backspaces: 3 + insert: cê +- input: cooo + display: cô + events: + - Replace: + backspaces: 3 + insert: cô +- input: coow + display: cơ + events: + - Replace: + backspaces: 3 + insert: cô + - Replace: + backspaces: 3 + insert: cơ +- input: cuuw + display: cuư + events: + - Replace: + backspaces: 4 + insert: cuư +- input: cacaf + display: cầc + events: + - Replace: + backspaces: 4 + insert: câc + - Replace: + backspaces: 4 + insert: cầc +- input: cacas + display: cấc + events: + - Replace: + backspaces: 4 + insert: câc + - Replace: + backspaces: 4 + insert: cấc +- input: cacaj + display: cậc + events: + - Replace: + backspaces: 4 + insert: câc + - Replace: + backspaces: 4 + insert: cậc +- input: cacar + display: cẩc + events: + - Replace: + backspaces: 4 + insert: câc + - Replace: + backspaces: 4 + insert: cẩc +- input: cacax + display: cẫc + events: + - Replace: + backspaces: 4 + insert: câc + - Replace: + backspaces: 4 + insert: cẫc +- input: cacaw + display: căc + events: + - Replace: + backspaces: 4 + insert: câc + - Replace: + backspaces: 4 + insert: căc +- input: cacaa + display: câc + events: + - Replace: + backspaces: 4 + insert: câc +- input: cecee + display: cêc + events: + - Replace: + backspaces: 4 + insert: cêc +- input: cocoo + display: côc + events: + - Replace: + backspaces: 4 + insert: côc +- input: cocow + display: cơc + events: + - Replace: + backspaces: 4 + insert: côc + - Replace: + backspaces: 4 + insert: cơc +- input: cucuw + display: cucư + events: + - Replace: + backspaces: 5 + insert: cucư +- input: cachaf + display: cầch + events: + - Replace: + backspaces: 5 + insert: câch + - Replace: + backspaces: 5 + insert: cầch +- input: cachas + display: cấch + events: + - Replace: + backspaces: 5 + insert: câch + - Replace: + backspaces: 5 + insert: cấch +- input: cachaj + display: cậch + events: + - Replace: + backspaces: 5 + insert: câch + - Replace: + backspaces: 5 + insert: cậch +- input: cachar + display: cẩch + events: + - Replace: + backspaces: 5 + insert: câch + - Replace: + backspaces: 5 + insert: cẩch +- input: cachax + display: cẫch + events: + - Replace: + backspaces: 5 + insert: câch + - Replace: + backspaces: 5 + insert: cẫch +- input: cachaw + display: căch + events: + - Replace: + backspaces: 5 + insert: câch + - Replace: + backspaces: 5 + insert: căch +- input: cachaa + display: câch + events: + - Replace: + backspaces: 5 + insert: câch +- input: cechee + display: cêch + events: + - Replace: + backspaces: 5 + insert: cêch +- input: cochoo + display: côch + events: + - Replace: + backspaces: 5 + insert: côch +- input: cochow + display: cơch + events: + - Replace: + backspaces: 5 + insert: côch + - Replace: + backspaces: 5 + insert: cơch +- input: cuchuw + display: cuchư + events: + - Replace: + backspaces: 6 + insert: cuchư +- input: camaf + display: cầm + events: + - Replace: + backspaces: 4 + insert: câm + - Replace: + backspaces: 4 + insert: cầm +- input: camas + display: cấm + events: + - Replace: + backspaces: 4 + insert: câm + - Replace: + backspaces: 4 + insert: cấm +- input: camaj + display: cậm + events: + - Replace: + backspaces: 4 + insert: câm + - Replace: + backspaces: 4 + insert: cậm +- input: camar + display: cẩm + events: + - Replace: + backspaces: 4 + insert: câm + - Replace: + backspaces: 4 + insert: cẩm +- input: camax + display: cẫm + events: + - Replace: + backspaces: 4 + insert: câm + - Replace: + backspaces: 4 + insert: cẫm +- input: camaw + display: căm + events: + - Replace: + backspaces: 4 + insert: câm + - Replace: + backspaces: 4 + insert: căm +- input: camaa + display: câm + events: + - Replace: + backspaces: 4 + insert: câm +- input: cemee + display: cêm + events: + - Replace: + backspaces: 4 + insert: cêm +- input: comoo + display: côm + events: + - Replace: + backspaces: 4 + insert: côm +- input: comow + display: cơm + events: + - Replace: + backspaces: 4 + insert: côm + - Replace: + backspaces: 4 + insert: cơm +- input: cumuw + display: cumư + events: + - Replace: + backspaces: 5 + insert: cumư +- input: canaf + display: cần + events: + - Replace: + backspaces: 4 + insert: cân + - Replace: + backspaces: 4 + insert: cần +- input: canas + display: cấn + events: + - Replace: + backspaces: 4 + insert: cân + - Replace: + backspaces: 4 + insert: cấn +- input: canaj + display: cận + events: + - Replace: + backspaces: 4 + insert: cân + - Replace: + backspaces: 4 + insert: cận +- input: canar + display: cẩn + events: + - Replace: + backspaces: 4 + insert: cân + - Replace: + backspaces: 4 + insert: cẩn +- input: canax + display: cẫn + events: + - Replace: + backspaces: 4 + insert: cân + - Replace: + backspaces: 4 + insert: cẫn +- input: canaw + display: căn + events: + - Replace: + backspaces: 4 + insert: cân + - Replace: + backspaces: 4 + insert: căn +- input: canaa + display: cân + events: + - Replace: + backspaces: 4 + insert: cân +- input: cenee + display: cên + events: + - Replace: + backspaces: 4 + insert: cên +- input: conoo + display: côn + events: + - Replace: + backspaces: 4 + insert: côn +- input: conow + display: cơn + events: + - Replace: + backspaces: 4 + insert: côn + - Replace: + backspaces: 4 + insert: cơn +- input: cunuw + display: cunư + events: + - Replace: + backspaces: 5 + insert: cunư +- input: cangaf + display: cầng + events: + - Replace: + backspaces: 5 + insert: câng + - Replace: + backspaces: 5 + insert: cầng +- input: cangas + display: cấng + events: + - Replace: + backspaces: 5 + insert: câng + - Replace: + backspaces: 5 + insert: cấng +- input: cangaj + display: cậng + events: + - Replace: + backspaces: 5 + insert: câng + - Replace: + backspaces: 5 + insert: cậng +- input: cangar + display: cẩng + events: + - Replace: + backspaces: 5 + insert: câng + - Replace: + backspaces: 5 + insert: cẩng +- input: cangax + display: cẫng + events: + - Replace: + backspaces: 5 + insert: câng + - Replace: + backspaces: 5 + insert: cẫng +- input: cangaw + display: căng + events: + - Replace: + backspaces: 5 + insert: câng + - Replace: + backspaces: 5 + insert: căng +- input: cangaa + display: câng + events: + - Replace: + backspaces: 5 + insert: câng +- input: cengee + display: cêng + events: + - Replace: + backspaces: 5 + insert: cêng +- input: congoo + display: công + events: + - Replace: + backspaces: 5 + insert: công +- input: congow + display: cơng + events: + - Replace: + backspaces: 5 + insert: công + - Replace: + backspaces: 5 + insert: cơng +- input: cunguw + display: cungư + events: + - Replace: + backspaces: 6 + insert: cungư +- input: canhaf + display: cầnh + events: + - Replace: + backspaces: 5 + insert: cânh + - Replace: + backspaces: 5 + insert: cầnh +- input: canhas + display: cấnh + events: + - Replace: + backspaces: 5 + insert: cânh + - Replace: + backspaces: 5 + insert: cấnh +- input: canhaj + display: cậnh + events: + - Replace: + backspaces: 5 + insert: cânh + - Replace: + backspaces: 5 + insert: cậnh +- input: canhar + display: cẩnh + events: + - Replace: + backspaces: 5 + insert: cânh + - Replace: + backspaces: 5 + insert: cẩnh +- input: canhax + display: cẫnh + events: + - Replace: + backspaces: 5 + insert: cânh + - Replace: + backspaces: 5 + insert: cẫnh +- input: canhaw + display: cănh + events: + - Replace: + backspaces: 5 + insert: cânh + - Replace: + backspaces: 5 + insert: cănh +- input: canhaa + display: cânh + events: + - Replace: + backspaces: 5 + insert: cânh +- input: cenhee + display: cênh + events: + - Replace: + backspaces: 5 + insert: cênh +- input: conhoo + display: cônh + events: + - Replace: + backspaces: 5 + insert: cônh +- input: conhow + display: cơnh + events: + - Replace: + backspaces: 5 + insert: cônh + - Replace: + backspaces: 5 + insert: cơnh +- input: cunhuw + display: cunhư + events: + - Replace: + backspaces: 6 + insert: cunhư +- input: capaf + display: cầp + events: + - Replace: + backspaces: 4 + insert: câp + - Replace: + backspaces: 4 + insert: cầp +- input: capas + display: cấp + events: + - Replace: + backspaces: 4 + insert: câp + - Replace: + backspaces: 4 + insert: cấp +- input: capaj + display: cập + events: + - Replace: + backspaces: 4 + insert: câp + - Replace: + backspaces: 4 + insert: cập +- input: capar + display: cẩp + events: + - Replace: + backspaces: 4 + insert: câp + - Replace: + backspaces: 4 + insert: cẩp +- input: capax + display: cẫp + events: + - Replace: + backspaces: 4 + insert: câp + - Replace: + backspaces: 4 + insert: cẫp +- input: capaw + display: căp + events: + - Replace: + backspaces: 4 + insert: câp + - Replace: + backspaces: 4 + insert: căp +- input: capaa + display: câp + events: + - Replace: + backspaces: 4 + insert: câp +- input: cepee + display: cêp + events: + - Replace: + backspaces: 4 + insert: cêp +- input: copoo + display: côp + events: + - Replace: + backspaces: 4 + insert: côp +- input: copow + display: cơp + events: + - Replace: + backspaces: 4 + insert: côp + - Replace: + backspaces: 4 + insert: cơp +- input: cupuw + display: cupư + events: + - Replace: + backspaces: 5 + insert: cupư +- input: cataf + display: cầt + events: + - Replace: + backspaces: 4 + insert: cât + - Replace: + backspaces: 4 + insert: cầt +- input: catas + display: cất + events: + - Replace: + backspaces: 4 + insert: cât + - Replace: + backspaces: 4 + insert: cất +- input: cataj + display: cật + events: + - Replace: + backspaces: 4 + insert: cât + - Replace: + backspaces: 4 + insert: cật +- input: catar + display: cẩt + events: + - Replace: + backspaces: 4 + insert: cât + - Replace: + backspaces: 4 + insert: cẩt +- input: catax + display: cẫt + events: + - Replace: + backspaces: 4 + insert: cât + - Replace: + backspaces: 4 + insert: cẫt +- input: cataw + display: căt + events: + - Replace: + backspaces: 4 + insert: cât + - Replace: + backspaces: 4 + insert: căt +- input: cataa + display: cât + events: + - Replace: + backspaces: 4 + insert: cât +- input: cetee + display: cêt + events: + - Replace: + backspaces: 4 + insert: cêt +- input: cotoo + display: côt + events: + - Replace: + backspaces: 4 + insert: côt +- input: cotow + display: cơt + events: + - Replace: + backspaces: 4 + insert: côt + - Replace: + backspaces: 4 + insert: cơt +- input: cutuw + display: cutư + events: + - Replace: + backspaces: 5 + insert: cutư +- input: chaaf + display: chầ + events: + - Replace: + backspaces: 4 + insert: châ + - Replace: + backspaces: 4 + insert: chầ +- input: chaas + display: chấ + events: + - Replace: + backspaces: 4 + insert: châ + - Replace: + backspaces: 4 + insert: chấ +- input: chaaj + display: chậ + events: + - Replace: + backspaces: 4 + insert: châ + - Replace: + backspaces: 4 + insert: chậ +- input: chaar + display: chẩ + events: + - Replace: + backspaces: 4 + insert: châ + - Replace: + backspaces: 4 + insert: chẩ +- input: chaax + display: chẫ + events: + - Replace: + backspaces: 4 + insert: châ + - Replace: + backspaces: 4 + insert: chẫ +- input: chaaw + display: chă + events: + - Replace: + backspaces: 4 + insert: châ + - Replace: + backspaces: 4 + insert: chă +- input: chaaa + display: châ + events: + - Replace: + backspaces: 4 + insert: châ +- input: cheee + display: chê + events: + - Replace: + backspaces: 4 + insert: chê +- input: chooo + display: chô + events: + - Replace: + backspaces: 4 + insert: chô +- input: choow + display: chơ + events: + - Replace: + backspaces: 4 + insert: chô + - Replace: + backspaces: 4 + insert: chơ +- input: chuuw + display: chuư + events: + - Replace: + backspaces: 5 + insert: chuư +- input: chacaf + display: chầc + events: + - Replace: + backspaces: 5 + insert: châc + - Replace: + backspaces: 5 + insert: chầc +- input: chacas + display: chấc + events: + - Replace: + backspaces: 5 + insert: châc + - Replace: + backspaces: 5 + insert: chấc +- input: chacaj + display: chậc + events: + - Replace: + backspaces: 5 + insert: châc + - Replace: + backspaces: 5 + insert: chậc +- input: chacar + display: chẩc + events: + - Replace: + backspaces: 5 + insert: châc + - Replace: + backspaces: 5 + insert: chẩc +- input: chacax + display: chẫc + events: + - Replace: + backspaces: 5 + insert: châc + - Replace: + backspaces: 5 + insert: chẫc +- input: chacaw + display: chăc + events: + - Replace: + backspaces: 5 + insert: châc + - Replace: + backspaces: 5 + insert: chăc +- input: chacaa + display: châc + events: + - Replace: + backspaces: 5 + insert: châc +- input: checee + display: chêc + events: + - Replace: + backspaces: 5 + insert: chêc +- input: chocoo + display: chôc + events: + - Replace: + backspaces: 5 + insert: chôc +- input: chocow + display: chơc + events: + - Replace: + backspaces: 5 + insert: chôc + - Replace: + backspaces: 5 + insert: chơc +- input: chucuw + display: chucư + events: + - Replace: + backspaces: 6 + insert: chucư +- input: chachaf + display: chầch + events: + - Replace: + backspaces: 6 + insert: châch + - Replace: + backspaces: 6 + insert: chầch +- input: chachas + display: chấch + events: + - Replace: + backspaces: 6 + insert: châch + - Replace: + backspaces: 6 + insert: chấch +- input: chachaj + display: chậch + events: + - Replace: + backspaces: 6 + insert: châch + - Replace: + backspaces: 6 + insert: chậch +- input: chachar + display: chẩch + events: + - Replace: + backspaces: 6 + insert: châch + - Replace: + backspaces: 6 + insert: chẩch +- input: chachax + display: chẫch + events: + - Replace: + backspaces: 6 + insert: châch + - Replace: + backspaces: 6 + insert: chẫch +- input: chachaw + display: chăch + events: + - Replace: + backspaces: 6 + insert: châch + - Replace: + backspaces: 6 + insert: chăch +- input: chachaa + display: châch + events: + - Replace: + backspaces: 6 + insert: châch +- input: chechee + display: chêch + events: + - Replace: + backspaces: 6 + insert: chêch +- input: chochoo + display: chôch + events: + - Replace: + backspaces: 6 + insert: chôch +- input: chochow + display: chơch + events: + - Replace: + backspaces: 6 + insert: chôch + - Replace: + backspaces: 6 + insert: chơch +- input: chuchuw + display: chuchư + events: + - Replace: + backspaces: 7 + insert: chuchư +- input: chamaf + display: chầm + events: + - Replace: + backspaces: 5 + insert: châm + - Replace: + backspaces: 5 + insert: chầm +- input: chamas + display: chấm + events: + - Replace: + backspaces: 5 + insert: châm + - Replace: + backspaces: 5 + insert: chấm +- input: chamaj + display: chậm + events: + - Replace: + backspaces: 5 + insert: châm + - Replace: + backspaces: 5 + insert: chậm +- input: chamar + display: chẩm + events: + - Replace: + backspaces: 5 + insert: châm + - Replace: + backspaces: 5 + insert: chẩm +- input: chamax + display: chẫm + events: + - Replace: + backspaces: 5 + insert: châm + - Replace: + backspaces: 5 + insert: chẫm +- input: chamaw + display: chăm + events: + - Replace: + backspaces: 5 + insert: châm + - Replace: + backspaces: 5 + insert: chăm +- input: chamaa + display: châm + events: + - Replace: + backspaces: 5 + insert: châm +- input: chemee + display: chêm + events: + - Replace: + backspaces: 5 + insert: chêm +- input: chomoo + display: chôm + events: + - Replace: + backspaces: 5 + insert: chôm +- input: chomow + display: chơm + events: + - Replace: + backspaces: 5 + insert: chôm + - Replace: + backspaces: 5 + insert: chơm +- input: chumuw + display: chumư + events: + - Replace: + backspaces: 6 + insert: chumư +- input: chanaf + display: chần + events: + - Replace: + backspaces: 5 + insert: chân + - Replace: + backspaces: 5 + insert: chần +- input: chanas + display: chấn + events: + - Replace: + backspaces: 5 + insert: chân + - Replace: + backspaces: 5 + insert: chấn +- input: chanaj + display: chận + events: + - Replace: + backspaces: 5 + insert: chân + - Replace: + backspaces: 5 + insert: chận +- input: chanar + display: chẩn + events: + - Replace: + backspaces: 5 + insert: chân + - Replace: + backspaces: 5 + insert: chẩn +- input: chanax + display: chẫn + events: + - Replace: + backspaces: 5 + insert: chân + - Replace: + backspaces: 5 + insert: chẫn +- input: chanaw + display: chăn + events: + - Replace: + backspaces: 5 + insert: chân + - Replace: + backspaces: 5 + insert: chăn +- input: chanaa + display: chân + events: + - Replace: + backspaces: 5 + insert: chân +- input: chenee + display: chên + events: + - Replace: + backspaces: 5 + insert: chên +- input: chonoo + display: chôn + events: + - Replace: + backspaces: 5 + insert: chôn +- input: chonow + display: chơn + events: + - Replace: + backspaces: 5 + insert: chôn + - Replace: + backspaces: 5 + insert: chơn +- input: chunuw + display: chunư + events: + - Replace: + backspaces: 6 + insert: chunư +- input: changaf + display: chầng + events: + - Replace: + backspaces: 6 + insert: châng + - Replace: + backspaces: 6 + insert: chầng +- input: changas + display: chấng + events: + - Replace: + backspaces: 6 + insert: châng + - Replace: + backspaces: 6 + insert: chấng +- input: changaj + display: chậng + events: + - Replace: + backspaces: 6 + insert: châng + - Replace: + backspaces: 6 + insert: chậng +- input: changar + display: chẩng + events: + - Replace: + backspaces: 6 + insert: châng + - Replace: + backspaces: 6 + insert: chẩng +- input: changax + display: chẫng + events: + - Replace: + backspaces: 6 + insert: châng + - Replace: + backspaces: 6 + insert: chẫng +- input: changaw + display: chăng + events: + - Replace: + backspaces: 6 + insert: châng + - Replace: + backspaces: 6 + insert: chăng +- input: changaa + display: châng + events: + - Replace: + backspaces: 6 + insert: châng +- input: chengee + display: chêng + events: + - Replace: + backspaces: 6 + insert: chêng +- input: chongoo + display: chông + events: + - Replace: + backspaces: 6 + insert: chông +- input: chongow + display: chơng + events: + - Replace: + backspaces: 6 + insert: chông + - Replace: + backspaces: 6 + insert: chơng +- input: chunguw + display: chungư + events: + - Replace: + backspaces: 7 + insert: chungư +- input: chanhaf + display: chầnh + events: + - Replace: + backspaces: 6 + insert: chânh + - Replace: + backspaces: 6 + insert: chầnh +- input: chanhas + display: chấnh + events: + - Replace: + backspaces: 6 + insert: chânh + - Replace: + backspaces: 6 + insert: chấnh +- input: chanhaj + display: chậnh + events: + - Replace: + backspaces: 6 + insert: chânh + - Replace: + backspaces: 6 + insert: chậnh +- input: chanhar + display: chẩnh + events: + - Replace: + backspaces: 6 + insert: chânh + - Replace: + backspaces: 6 + insert: chẩnh +- input: chanhax + display: chẫnh + events: + - Replace: + backspaces: 6 + insert: chânh + - Replace: + backspaces: 6 + insert: chẫnh +- input: chanhaw + display: chănh + events: + - Replace: + backspaces: 6 + insert: chânh + - Replace: + backspaces: 6 + insert: chănh +- input: chanhaa + display: chânh + events: + - Replace: + backspaces: 6 + insert: chânh +- input: chenhee + display: chênh + events: + - Replace: + backspaces: 6 + insert: chênh +- input: chonhoo + display: chônh + events: + - Replace: + backspaces: 6 + insert: chônh +- input: chonhow + display: chơnh + events: + - Replace: + backspaces: 6 + insert: chônh + - Replace: + backspaces: 6 + insert: chơnh +- input: chunhuw + display: chunhư + events: + - Replace: + backspaces: 7 + insert: chunhư +- input: chapaf + display: chầp + events: + - Replace: + backspaces: 5 + insert: châp + - Replace: + backspaces: 5 + insert: chầp +- input: chapas + display: chấp + events: + - Replace: + backspaces: 5 + insert: châp + - Replace: + backspaces: 5 + insert: chấp +- input: chapaj + display: chập + events: + - Replace: + backspaces: 5 + insert: châp + - Replace: + backspaces: 5 + insert: chập +- input: chapar + display: chẩp + events: + - Replace: + backspaces: 5 + insert: châp + - Replace: + backspaces: 5 + insert: chẩp +- input: chapax + display: chẫp + events: + - Replace: + backspaces: 5 + insert: châp + - Replace: + backspaces: 5 + insert: chẫp +- input: chapaw + display: chăp + events: + - Replace: + backspaces: 5 + insert: châp + - Replace: + backspaces: 5 + insert: chăp +- input: chapaa + display: châp + events: + - Replace: + backspaces: 5 + insert: châp +- input: chepee + display: chêp + events: + - Replace: + backspaces: 5 + insert: chêp +- input: chopoo + display: chôp + events: + - Replace: + backspaces: 5 + insert: chôp +- input: chopow + display: chơp + events: + - Replace: + backspaces: 5 + insert: chôp + - Replace: + backspaces: 5 + insert: chơp +- input: chupuw + display: chupư + events: + - Replace: + backspaces: 6 + insert: chupư +- input: chataf + display: chầt + events: + - Replace: + backspaces: 5 + insert: chât + - Replace: + backspaces: 5 + insert: chầt +- input: chatas + display: chất + events: + - Replace: + backspaces: 5 + insert: chât + - Replace: + backspaces: 5 + insert: chất +- input: chataj + display: chật + events: + - Replace: + backspaces: 5 + insert: chât + - Replace: + backspaces: 5 + insert: chật +- input: chatar + display: chẩt + events: + - Replace: + backspaces: 5 + insert: chât + - Replace: + backspaces: 5 + insert: chẩt +- input: chatax + display: chẫt + events: + - Replace: + backspaces: 5 + insert: chât + - Replace: + backspaces: 5 + insert: chẫt +- input: chataw + display: chăt + events: + - Replace: + backspaces: 5 + insert: chât + - Replace: + backspaces: 5 + insert: chăt +- input: chataa + display: chât + events: + - Replace: + backspaces: 5 + insert: chât +- input: chetee + display: chêt + events: + - Replace: + backspaces: 5 + insert: chêt +- input: chotoo + display: chôt + events: + - Replace: + backspaces: 5 + insert: chôt +- input: chotow + display: chơt + events: + - Replace: + backspaces: 5 + insert: chôt + - Replace: + backspaces: 5 + insert: chơt +- input: chutuw + display: chutư + events: + - Replace: + backspaces: 6 + insert: chutư +- input: daaf + display: dầ + events: + - Replace: + backspaces: 3 + insert: dâ + - Replace: + backspaces: 3 + insert: dầ +- input: daas + display: dấ + events: + - Replace: + backspaces: 3 + insert: dâ + - Replace: + backspaces: 3 + insert: dấ +- input: daaj + display: dậ + events: + - Replace: + backspaces: 3 + insert: dâ + - Replace: + backspaces: 3 + insert: dậ +- input: daar + display: dẩ + events: + - Replace: + backspaces: 3 + insert: dâ + - Replace: + backspaces: 3 + insert: dẩ +- input: daax + display: dẫ + events: + - Replace: + backspaces: 3 + insert: dâ + - Replace: + backspaces: 3 + insert: dẫ +- input: daaw + display: dă + events: + - Replace: + backspaces: 3 + insert: dâ + - Replace: + backspaces: 3 + insert: dă +- input: daaa + display: dâ + events: + - Replace: + backspaces: 3 + insert: dâ +- input: deee + display: dê + events: + - Replace: + backspaces: 3 + insert: dê +- input: dooo + display: dô + events: + - Replace: + backspaces: 3 + insert: dô +- input: doow + display: dơ + events: + - Replace: + backspaces: 3 + insert: dô + - Replace: + backspaces: 3 + insert: dơ +- input: duuw + display: duư + events: + - Replace: + backspaces: 4 + insert: duư +- input: dacaf + display: dầc + events: + - Replace: + backspaces: 4 + insert: dâc + - Replace: + backspaces: 4 + insert: dầc +- input: dacas + display: dấc + events: + - Replace: + backspaces: 4 + insert: dâc + - Replace: + backspaces: 4 + insert: dấc +- input: dacaj + display: dậc + events: + - Replace: + backspaces: 4 + insert: dâc + - Replace: + backspaces: 4 + insert: dậc +- input: dacar + display: dẩc + events: + - Replace: + backspaces: 4 + insert: dâc + - Replace: + backspaces: 4 + insert: dẩc +- input: dacax + display: dẫc + events: + - Replace: + backspaces: 4 + insert: dâc + - Replace: + backspaces: 4 + insert: dẫc +- input: dacaw + display: dăc + events: + - Replace: + backspaces: 4 + insert: dâc + - Replace: + backspaces: 4 + insert: dăc +- input: dacaa + display: dâc + events: + - Replace: + backspaces: 4 + insert: dâc +- input: decee + display: dêc + events: + - Replace: + backspaces: 4 + insert: dêc +- input: docoo + display: dôc + events: + - Replace: + backspaces: 4 + insert: dôc +- input: docow + display: dơc + events: + - Replace: + backspaces: 4 + insert: dôc + - Replace: + backspaces: 4 + insert: dơc +- input: ducuw + display: ducư + events: + - Replace: + backspaces: 5 + insert: ducư +- input: dachaf + display: dầch + events: + - Replace: + backspaces: 5 + insert: dâch + - Replace: + backspaces: 5 + insert: dầch +- input: dachas + display: dấch + events: + - Replace: + backspaces: 5 + insert: dâch + - Replace: + backspaces: 5 + insert: dấch +- input: dachaj + display: dậch + events: + - Replace: + backspaces: 5 + insert: dâch + - Replace: + backspaces: 5 + insert: dậch +- input: dachar + display: dẩch + events: + - Replace: + backspaces: 5 + insert: dâch + - Replace: + backspaces: 5 + insert: dẩch +- input: dachax + display: dẫch + events: + - Replace: + backspaces: 5 + insert: dâch + - Replace: + backspaces: 5 + insert: dẫch +- input: dachaw + display: dăch + events: + - Replace: + backspaces: 5 + insert: dâch + - Replace: + backspaces: 5 + insert: dăch +- input: dachaa + display: dâch + events: + - Replace: + backspaces: 5 + insert: dâch +- input: dechee + display: dêch + events: + - Replace: + backspaces: 5 + insert: dêch +- input: dochoo + display: dôch + events: + - Replace: + backspaces: 5 + insert: dôch +- input: dochow + display: dơch + events: + - Replace: + backspaces: 5 + insert: dôch + - Replace: + backspaces: 5 + insert: dơch +- input: duchuw + display: duchư + events: + - Replace: + backspaces: 6 + insert: duchư +- input: damaf + display: dầm + events: + - Replace: + backspaces: 4 + insert: dâm + - Replace: + backspaces: 4 + insert: dầm +- input: damas + display: dấm + events: + - Replace: + backspaces: 4 + insert: dâm + - Replace: + backspaces: 4 + insert: dấm +- input: damaj + display: dậm + events: + - Replace: + backspaces: 4 + insert: dâm + - Replace: + backspaces: 4 + insert: dậm +- input: damar + display: dẩm + events: + - Replace: + backspaces: 4 + insert: dâm + - Replace: + backspaces: 4 + insert: dẩm +- input: damax + display: dẫm + events: + - Replace: + backspaces: 4 + insert: dâm + - Replace: + backspaces: 4 + insert: dẫm +- input: damaw + display: dăm + events: + - Replace: + backspaces: 4 + insert: dâm + - Replace: + backspaces: 4 + insert: dăm +- input: damaa + display: dâm + events: + - Replace: + backspaces: 4 + insert: dâm +- input: demee + display: dêm + events: + - Replace: + backspaces: 4 + insert: dêm +- input: domoo + display: dôm + events: + - Replace: + backspaces: 4 + insert: dôm +- input: domow + display: dơm + events: + - Replace: + backspaces: 4 + insert: dôm + - Replace: + backspaces: 4 + insert: dơm +- input: dumuw + display: dumư + events: + - Replace: + backspaces: 5 + insert: dumư +- input: danaf + display: dần + events: + - Replace: + backspaces: 4 + insert: dân + - Replace: + backspaces: 4 + insert: dần +- input: danas + display: dấn + events: + - Replace: + backspaces: 4 + insert: dân + - Replace: + backspaces: 4 + insert: dấn +- input: danaj + display: dận + events: + - Replace: + backspaces: 4 + insert: dân + - Replace: + backspaces: 4 + insert: dận +- input: danar + display: dẩn + events: + - Replace: + backspaces: 4 + insert: dân + - Replace: + backspaces: 4 + insert: dẩn +- input: danax + display: dẫn + events: + - Replace: + backspaces: 4 + insert: dân + - Replace: + backspaces: 4 + insert: dẫn +- input: danaw + display: dăn + events: + - Replace: + backspaces: 4 + insert: dân + - Replace: + backspaces: 4 + insert: dăn +- input: danaa + display: dân + events: + - Replace: + backspaces: 4 + insert: dân +- input: denee + display: dên + events: + - Replace: + backspaces: 4 + insert: dên +- input: donoo + display: dôn + events: + - Replace: + backspaces: 4 + insert: dôn +- input: donow + display: dơn + events: + - Replace: + backspaces: 4 + insert: dôn + - Replace: + backspaces: 4 + insert: dơn +- input: dunuw + display: dunư + events: + - Replace: + backspaces: 5 + insert: dunư +- input: dangaf + display: dầng + events: + - Replace: + backspaces: 5 + insert: dâng + - Replace: + backspaces: 5 + insert: dầng +- input: dangas + display: dấng + events: + - Replace: + backspaces: 5 + insert: dâng + - Replace: + backspaces: 5 + insert: dấng +- input: dangaj + display: dậng + events: + - Replace: + backspaces: 5 + insert: dâng + - Replace: + backspaces: 5 + insert: dậng +- input: dangar + display: dẩng + events: + - Replace: + backspaces: 5 + insert: dâng + - Replace: + backspaces: 5 + insert: dẩng +- input: dangax + display: dẫng + events: + - Replace: + backspaces: 5 + insert: dâng + - Replace: + backspaces: 5 + insert: dẫng +- input: dangaw + display: dăng + events: + - Replace: + backspaces: 5 + insert: dâng + - Replace: + backspaces: 5 + insert: dăng +- input: dangaa + display: dâng + events: + - Replace: + backspaces: 5 + insert: dâng +- input: dengee + display: dêng + events: + - Replace: + backspaces: 5 + insert: dêng +- input: dongoo + display: dông + events: + - Replace: + backspaces: 5 + insert: dông +- input: dongow + display: dơng + events: + - Replace: + backspaces: 5 + insert: dông + - Replace: + backspaces: 5 + insert: dơng +- input: dunguw + display: dungư + events: + - Replace: + backspaces: 6 + insert: dungư +- input: danhaf + display: dầnh + events: + - Replace: + backspaces: 5 + insert: dânh + - Replace: + backspaces: 5 + insert: dầnh +- input: danhas + display: dấnh + events: + - Replace: + backspaces: 5 + insert: dânh + - Replace: + backspaces: 5 + insert: dấnh +- input: danhaj + display: dậnh + events: + - Replace: + backspaces: 5 + insert: dânh + - Replace: + backspaces: 5 + insert: dậnh +- input: danhar + display: dẩnh + events: + - Replace: + backspaces: 5 + insert: dânh + - Replace: + backspaces: 5 + insert: dẩnh +- input: danhax + display: dẫnh + events: + - Replace: + backspaces: 5 + insert: dânh + - Replace: + backspaces: 5 + insert: dẫnh +- input: danhaw + display: dănh + events: + - Replace: + backspaces: 5 + insert: dânh + - Replace: + backspaces: 5 + insert: dănh +- input: danhaa + display: dânh + events: + - Replace: + backspaces: 5 + insert: dânh +- input: denhee + display: dênh + events: + - Replace: + backspaces: 5 + insert: dênh +- input: donhoo + display: dônh + events: + - Replace: + backspaces: 5 + insert: dônh +- input: donhow + display: dơnh + events: + - Replace: + backspaces: 5 + insert: dônh + - Replace: + backspaces: 5 + insert: dơnh +- input: dunhuw + display: dunhư + events: + - Replace: + backspaces: 6 + insert: dunhư +- input: dapaf + display: dầp + events: + - Replace: + backspaces: 4 + insert: dâp + - Replace: + backspaces: 4 + insert: dầp +- input: dapas + display: dấp + events: + - Replace: + backspaces: 4 + insert: dâp + - Replace: + backspaces: 4 + insert: dấp +- input: dapaj + display: dập + events: + - Replace: + backspaces: 4 + insert: dâp + - Replace: + backspaces: 4 + insert: dập +- input: dapar + display: dẩp + events: + - Replace: + backspaces: 4 + insert: dâp + - Replace: + backspaces: 4 + insert: dẩp +- input: dapax + display: dẫp + events: + - Replace: + backspaces: 4 + insert: dâp + - Replace: + backspaces: 4 + insert: dẫp +- input: dapaw + display: dăp + events: + - Replace: + backspaces: 4 + insert: dâp + - Replace: + backspaces: 4 + insert: dăp +- input: dapaa + display: dâp + events: + - Replace: + backspaces: 4 + insert: dâp +- input: depee + display: dêp + events: + - Replace: + backspaces: 4 + insert: dêp +- input: dopoo + display: dôp + events: + - Replace: + backspaces: 4 + insert: dôp +- input: dopow + display: dơp + events: + - Replace: + backspaces: 4 + insert: dôp + - Replace: + backspaces: 4 + insert: dơp +- input: dupuw + display: dupư + events: + - Replace: + backspaces: 5 + insert: dupư +- input: dataf + display: dầt + events: + - Replace: + backspaces: 4 + insert: dât + - Replace: + backspaces: 4 + insert: dầt +- input: datas + display: dất + events: + - Replace: + backspaces: 4 + insert: dât + - Replace: + backspaces: 4 + insert: dất +- input: dataj + display: dật + events: + - Replace: + backspaces: 4 + insert: dât + - Replace: + backspaces: 4 + insert: dật +- input: datar + display: dẩt + events: + - Replace: + backspaces: 4 + insert: dât + - Replace: + backspaces: 4 + insert: dẩt +- input: datax + display: dẫt + events: + - Replace: + backspaces: 4 + insert: dât + - Replace: + backspaces: 4 + insert: dẫt +- input: dataw + display: dăt + events: + - Replace: + backspaces: 4 + insert: dât + - Replace: + backspaces: 4 + insert: dăt +- input: dataa + display: dât + events: + - Replace: + backspaces: 4 + insert: dât +- input: detee + display: dêt + events: + - Replace: + backspaces: 4 + insert: dêt +- input: dotoo + display: dôt + events: + - Replace: + backspaces: 4 + insert: dôt +- input: dotow + display: dơt + events: + - Replace: + backspaces: 4 + insert: dôt + - Replace: + backspaces: 4 + insert: dơt +- input: dutuw + display: dutư + events: + - Replace: + backspaces: 5 + insert: dutư +- input: gaaf + display: gầ + events: + - Replace: + backspaces: 3 + insert: gâ + - Replace: + backspaces: 3 + insert: gầ +- input: gaas + display: gấ + events: + - Replace: + backspaces: 3 + insert: gâ + - Replace: + backspaces: 3 + insert: gấ +- input: gaaj + display: gậ + events: + - Replace: + backspaces: 3 + insert: gâ + - Replace: + backspaces: 3 + insert: gậ +- input: gaar + display: gẩ + events: + - Replace: + backspaces: 3 + insert: gâ + - Replace: + backspaces: 3 + insert: gẩ +- input: gaax + display: gẫ + events: + - Replace: + backspaces: 3 + insert: gâ + - Replace: + backspaces: 3 + insert: gẫ +- input: gaaw + display: gă + events: + - Replace: + backspaces: 3 + insert: gâ + - Replace: + backspaces: 3 + insert: gă +- input: gaaa + display: gâ + events: + - Replace: + backspaces: 3 + insert: gâ +- input: geee + display: gê + events: + - Replace: + backspaces: 3 + insert: gê +- input: gooo + display: gô + events: + - Replace: + backspaces: 3 + insert: gô +- input: goow + display: gơ + events: + - Replace: + backspaces: 3 + insert: gô + - Replace: + backspaces: 3 + insert: gơ +- input: guuw + display: guư + events: + - Replace: + backspaces: 4 + insert: guư +- input: ganaf + display: gần + events: + - Replace: + backspaces: 4 + insert: gân + - Replace: + backspaces: 4 + insert: gần +- input: ganas + display: gấn + events: + - Replace: + backspaces: 4 + insert: gân + - Replace: + backspaces: 4 + insert: gấn +- input: ganaj + display: gận + events: + - Replace: + backspaces: 4 + insert: gân + - Replace: + backspaces: 4 + insert: gận +- input: ganar + display: gẩn + events: + - Replace: + backspaces: 4 + insert: gân + - Replace: + backspaces: 4 + insert: gẩn +- input: ganax + display: gẫn + events: + - Replace: + backspaces: 4 + insert: gân + - Replace: + backspaces: 4 + insert: gẫn +- input: ganaw + display: găn + events: + - Replace: + backspaces: 4 + insert: gân + - Replace: + backspaces: 4 + insert: găn +- input: ganaa + display: gân + events: + - Replace: + backspaces: 4 + insert: gân +- input: genee + display: gên + events: + - Replace: + backspaces: 4 + insert: gên +- input: gonoo + display: gôn + events: + - Replace: + backspaces: 4 + insert: gôn +- input: gonow + display: gơn + events: + - Replace: + backspaces: 4 + insert: gôn + - Replace: + backspaces: 4 + insert: gơn +- input: gunuw + display: gunư + events: + - Replace: + backspaces: 5 + insert: gunư +- input: gangaf + display: gầng + events: + - Replace: + backspaces: 5 + insert: gâng + - Replace: + backspaces: 5 + insert: gầng +- input: gangas + display: gấng + events: + - Replace: + backspaces: 5 + insert: gâng + - Replace: + backspaces: 5 + insert: gấng +- input: gangaj + display: gậng + events: + - Replace: + backspaces: 5 + insert: gâng + - Replace: + backspaces: 5 + insert: gậng +- input: gangar + display: gẩng + events: + - Replace: + backspaces: 5 + insert: gâng + - Replace: + backspaces: 5 + insert: gẩng +- input: gangax + display: gẫng + events: + - Replace: + backspaces: 5 + insert: gâng + - Replace: + backspaces: 5 + insert: gẫng diff --git a/engine/tests/snapshots/snapshot_tests__vni_snapshots.snap b/engine/tests/snapshots/snapshot_tests__vni_snapshots.snap new file mode 100644 index 0000000..5117c94 --- /dev/null +++ b/engine/tests/snapshots/snapshot_tests__vni_snapshots.snap @@ -0,0 +1,3004 @@ +--- +source: engine/tests/snapshot_tests.rs +expression: cases +--- +- input: a1 + display: á + events: + - Replace: + backspaces: 2 + insert: á +- input: a2 + display: à + events: + - Replace: + backspaces: 2 + insert: à +- input: a3 + display: ả + events: + - Replace: + backspaces: 2 + insert: ả +- input: a4 + display: ã + events: + - Replace: + backspaces: 2 + insert: ã +- input: a5 + display: ạ + events: + - Replace: + backspaces: 2 + insert: ạ +- input: a6 + display: â + events: + - Replace: + backspaces: 2 + insert: â +- input: a8 + display: ă + events: + - Replace: + backspaces: 2 + insert: ă +- input: e6 + display: ê + events: + - Replace: + backspaces: 2 + insert: ê +- input: o6 + display: ô + events: + - Replace: + backspaces: 2 + insert: ô +- input: o7 + display: ơ + events: + - Replace: + backspaces: 2 + insert: ơ +- input: u7 + display: ư + events: + - Replace: + backspaces: 2 + insert: ư +- input: ac1 + display: ác + events: + - Replace: + backspaces: 3 + insert: ác +- input: ac2 + display: àc + events: + - Replace: + backspaces: 3 + insert: àc +- input: ac3 + display: ảc + events: + - Replace: + backspaces: 3 + insert: ảc +- input: ac4 + display: ãc + events: + - Replace: + backspaces: 3 + insert: ãc +- input: ac5 + display: ạc + events: + - Replace: + backspaces: 3 + insert: ạc +- input: ac6 + display: âc + events: + - Replace: + backspaces: 3 + insert: âc +- input: ac8 + display: ăc + events: + - Replace: + backspaces: 3 + insert: ăc +- input: ec6 + display: êc + events: + - Replace: + backspaces: 3 + insert: êc +- input: oc6 + display: ôc + events: + - Replace: + backspaces: 3 + insert: ôc +- input: oc7 + display: ơc + events: + - Replace: + backspaces: 3 + insert: ơc +- input: uc7 + display: ưc + events: + - Replace: + backspaces: 3 + insert: ưc +- input: am1 + display: ám + events: + - Replace: + backspaces: 3 + insert: ám +- input: am2 + display: àm + events: + - Replace: + backspaces: 3 + insert: àm +- input: am3 + display: ảm + events: + - Replace: + backspaces: 3 + insert: ảm +- input: am4 + display: ãm + events: + - Replace: + backspaces: 3 + insert: ãm +- input: am5 + display: ạm + events: + - Replace: + backspaces: 3 + insert: ạm +- input: am6 + display: âm + events: + - Replace: + backspaces: 3 + insert: âm +- input: am8 + display: ăm + events: + - Replace: + backspaces: 3 + insert: ăm +- input: em6 + display: êm + events: + - Replace: + backspaces: 3 + insert: êm +- input: om6 + display: ôm + events: + - Replace: + backspaces: 3 + insert: ôm +- input: om7 + display: ơm + events: + - Replace: + backspaces: 3 + insert: ơm +- input: um7 + display: ưm + events: + - Replace: + backspaces: 3 + insert: ưm +- input: an1 + display: án + events: + - Replace: + backspaces: 3 + insert: án +- input: an2 + display: àn + events: + - Replace: + backspaces: 3 + insert: àn +- input: an3 + display: ản + events: + - Replace: + backspaces: 3 + insert: ản +- input: an4 + display: ãn + events: + - Replace: + backspaces: 3 + insert: ãn +- input: an5 + display: ạn + events: + - Replace: + backspaces: 3 + insert: ạn +- input: an6 + display: ân + events: + - Replace: + backspaces: 3 + insert: ân +- input: an8 + display: ăn + events: + - Replace: + backspaces: 3 + insert: ăn +- input: en6 + display: ên + events: + - Replace: + backspaces: 3 + insert: ên +- input: on6 + display: ôn + events: + - Replace: + backspaces: 3 + insert: ôn +- input: on7 + display: ơn + events: + - Replace: + backspaces: 3 + insert: ơn +- input: un7 + display: ưn + events: + - Replace: + backspaces: 3 + insert: ưn +- input: ang1 + display: áng + events: + - Replace: + backspaces: 4 + insert: áng +- input: ang2 + display: àng + events: + - Replace: + backspaces: 4 + insert: àng +- input: ang3 + display: ảng + events: + - Replace: + backspaces: 4 + insert: ảng +- input: ang4 + display: ãng + events: + - Replace: + backspaces: 4 + insert: ãng +- input: ang5 + display: ạng + events: + - Replace: + backspaces: 4 + insert: ạng +- input: ang6 + display: âng + events: + - Replace: + backspaces: 4 + insert: âng +- input: ang8 + display: ăng + events: + - Replace: + backspaces: 4 + insert: ăng +- input: eng6 + display: êng + events: + - Replace: + backspaces: 4 + insert: êng +- input: ong6 + display: ông + events: + - Replace: + backspaces: 4 + insert: ông +- input: ong7 + display: ơng + events: + - Replace: + backspaces: 4 + insert: ơng +- input: ung7 + display: ưng + events: + - Replace: + backspaces: 4 + insert: ưng +- input: ap1 + display: áp + events: + - Replace: + backspaces: 3 + insert: áp +- input: ap2 + display: àp + events: + - Replace: + backspaces: 3 + insert: àp +- input: ap3 + display: ảp + events: + - Replace: + backspaces: 3 + insert: ảp +- input: ap4 + display: ãp + events: + - Replace: + backspaces: 3 + insert: ãp +- input: ap5 + display: ạp + events: + - Replace: + backspaces: 3 + insert: ạp +- input: ap6 + display: âp + events: + - Replace: + backspaces: 3 + insert: âp +- input: ap8 + display: ăp + events: + - Replace: + backspaces: 3 + insert: ăp +- input: ep6 + display: êp + events: + - Replace: + backspaces: 3 + insert: êp +- input: op6 + display: ôp + events: + - Replace: + backspaces: 3 + insert: ôp +- input: op7 + display: ơp + events: + - Replace: + backspaces: 3 + insert: ơp +- input: up7 + display: ưp + events: + - Replace: + backspaces: 3 + insert: ưp +- input: at1 + display: át + events: + - Replace: + backspaces: 3 + insert: át +- input: at2 + display: àt + events: + - Replace: + backspaces: 3 + insert: àt +- input: at3 + display: ảt + events: + - Replace: + backspaces: 3 + insert: ảt +- input: at4 + display: ãt + events: + - Replace: + backspaces: 3 + insert: ãt +- input: at5 + display: ạt + events: + - Replace: + backspaces: 3 + insert: ạt +- input: at6 + display: ât + events: + - Replace: + backspaces: 3 + insert: ât +- input: at8 + display: ăt + events: + - Replace: + backspaces: 3 + insert: ăt +- input: et6 + display: êt + events: + - Replace: + backspaces: 3 + insert: êt +- input: ot6 + display: ôt + events: + - Replace: + backspaces: 3 + insert: ôt +- input: ot7 + display: ơt + events: + - Replace: + backspaces: 3 + insert: ơt +- input: ut7 + display: ưt + events: + - Replace: + backspaces: 3 + insert: ưt +- input: ba1 + display: bá + events: + - Replace: + backspaces: 3 + insert: bá +- input: ba2 + display: bà + events: + - Replace: + backspaces: 3 + insert: bà +- input: ba3 + display: bả + events: + - Replace: + backspaces: 3 + insert: bả +- input: ba4 + display: bã + events: + - Replace: + backspaces: 3 + insert: bã +- input: ba5 + display: bạ + events: + - Replace: + backspaces: 3 + insert: bạ +- input: ba6 + display: bâ + events: + - Replace: + backspaces: 3 + insert: bâ +- input: ba8 + display: bă + events: + - Replace: + backspaces: 3 + insert: bă +- input: be6 + display: bê + events: + - Replace: + backspaces: 3 + insert: bê +- input: bo6 + display: bô + events: + - Replace: + backspaces: 3 + insert: bô +- input: bo7 + display: bơ + events: + - Replace: + backspaces: 3 + insert: bơ +- input: bu7 + display: bư + events: + - Replace: + backspaces: 3 + insert: bư +- input: bac1 + display: bác + events: + - Replace: + backspaces: 4 + insert: bác +- input: bac2 + display: bàc + events: + - Replace: + backspaces: 4 + insert: bàc +- input: bac3 + display: bảc + events: + - Replace: + backspaces: 4 + insert: bảc +- input: bac4 + display: bãc + events: + - Replace: + backspaces: 4 + insert: bãc +- input: bac5 + display: bạc + events: + - Replace: + backspaces: 4 + insert: bạc +- input: bac6 + display: bâc + events: + - Replace: + backspaces: 4 + insert: bâc +- input: bac8 + display: băc + events: + - Replace: + backspaces: 4 + insert: băc +- input: bec6 + display: bêc + events: + - Replace: + backspaces: 4 + insert: bêc +- input: boc6 + display: bôc + events: + - Replace: + backspaces: 4 + insert: bôc +- input: boc7 + display: bơc + events: + - Replace: + backspaces: 4 + insert: bơc +- input: buc7 + display: bưc + events: + - Replace: + backspaces: 4 + insert: bưc +- input: bach1 + display: bách + events: + - Replace: + backspaces: 5 + insert: bách +- input: bach2 + display: bàch + events: + - Replace: + backspaces: 5 + insert: bàch +- input: bach3 + display: bảch + events: + - Replace: + backspaces: 5 + insert: bảch +- input: bach4 + display: bãch + events: + - Replace: + backspaces: 5 + insert: bãch +- input: bach5 + display: bạch + events: + - Replace: + backspaces: 5 + insert: bạch +- input: bach6 + display: bâch + events: + - Replace: + backspaces: 5 + insert: bâch +- input: bach8 + display: băch + events: + - Replace: + backspaces: 5 + insert: băch +- input: bech6 + display: bêch + events: + - Replace: + backspaces: 5 + insert: bêch +- input: boch6 + display: bôch + events: + - Replace: + backspaces: 5 + insert: bôch +- input: boch7 + display: bơch + events: + - Replace: + backspaces: 5 + insert: bơch +- input: buch7 + display: bưch + events: + - Replace: + backspaces: 5 + insert: bưch +- input: bam1 + display: bám + events: + - Replace: + backspaces: 4 + insert: bám +- input: bam2 + display: bàm + events: + - Replace: + backspaces: 4 + insert: bàm +- input: bam3 + display: bảm + events: + - Replace: + backspaces: 4 + insert: bảm +- input: bam4 + display: bãm + events: + - Replace: + backspaces: 4 + insert: bãm +- input: bam5 + display: bạm + events: + - Replace: + backspaces: 4 + insert: bạm +- input: bam6 + display: bâm + events: + - Replace: + backspaces: 4 + insert: bâm +- input: bam8 + display: băm + events: + - Replace: + backspaces: 4 + insert: băm +- input: bem6 + display: bêm + events: + - Replace: + backspaces: 4 + insert: bêm +- input: bom6 + display: bôm + events: + - Replace: + backspaces: 4 + insert: bôm +- input: bom7 + display: bơm + events: + - Replace: + backspaces: 4 + insert: bơm +- input: bum7 + display: bưm + events: + - Replace: + backspaces: 4 + insert: bưm +- input: ban1 + display: bán + events: + - Replace: + backspaces: 4 + insert: bán +- input: ban2 + display: bàn + events: + - Replace: + backspaces: 4 + insert: bàn +- input: ban3 + display: bản + events: + - Replace: + backspaces: 4 + insert: bản +- input: ban4 + display: bãn + events: + - Replace: + backspaces: 4 + insert: bãn +- input: ban5 + display: bạn + events: + - Replace: + backspaces: 4 + insert: bạn +- input: ban6 + display: bân + events: + - Replace: + backspaces: 4 + insert: bân +- input: ban8 + display: băn + events: + - Replace: + backspaces: 4 + insert: băn +- input: ben6 + display: bên + events: + - Replace: + backspaces: 4 + insert: bên +- input: bon6 + display: bôn + events: + - Replace: + backspaces: 4 + insert: bôn +- input: bon7 + display: bơn + events: + - Replace: + backspaces: 4 + insert: bơn +- input: bun7 + display: bưn + events: + - Replace: + backspaces: 4 + insert: bưn +- input: bang1 + display: báng + events: + - Replace: + backspaces: 5 + insert: báng +- input: bang2 + display: bàng + events: + - Replace: + backspaces: 5 + insert: bàng +- input: bang3 + display: bảng + events: + - Replace: + backspaces: 5 + insert: bảng +- input: bang4 + display: bãng + events: + - Replace: + backspaces: 5 + insert: bãng +- input: bang5 + display: bạng + events: + - Replace: + backspaces: 5 + insert: bạng +- input: bang6 + display: bâng + events: + - Replace: + backspaces: 5 + insert: bâng +- input: bang8 + display: băng + events: + - Replace: + backspaces: 5 + insert: băng +- input: beng6 + display: bêng + events: + - Replace: + backspaces: 5 + insert: bêng +- input: bong6 + display: bông + events: + - Replace: + backspaces: 5 + insert: bông +- input: bong7 + display: bơng + events: + - Replace: + backspaces: 5 + insert: bơng +- input: bung7 + display: bưng + events: + - Replace: + backspaces: 5 + insert: bưng +- input: banh1 + display: bánh + events: + - Replace: + backspaces: 5 + insert: bánh +- input: banh2 + display: bành + events: + - Replace: + backspaces: 5 + insert: bành +- input: banh3 + display: bảnh + events: + - Replace: + backspaces: 5 + insert: bảnh +- input: banh4 + display: bãnh + events: + - Replace: + backspaces: 5 + insert: bãnh +- input: banh5 + display: bạnh + events: + - Replace: + backspaces: 5 + insert: bạnh +- input: banh6 + display: bânh + events: + - Replace: + backspaces: 5 + insert: bânh +- input: banh8 + display: bănh + events: + - Replace: + backspaces: 5 + insert: bănh +- input: benh6 + display: bênh + events: + - Replace: + backspaces: 5 + insert: bênh +- input: bonh6 + display: bônh + events: + - Replace: + backspaces: 5 + insert: bônh +- input: bonh7 + display: bơnh + events: + - Replace: + backspaces: 5 + insert: bơnh +- input: bunh7 + display: bưnh + events: + - Replace: + backspaces: 5 + insert: bưnh +- input: bap1 + display: báp + events: + - Replace: + backspaces: 4 + insert: báp +- input: bap2 + display: bàp + events: + - Replace: + backspaces: 4 + insert: bàp +- input: bap3 + display: bảp + events: + - Replace: + backspaces: 4 + insert: bảp +- input: bap4 + display: bãp + events: + - Replace: + backspaces: 4 + insert: bãp +- input: bap5 + display: bạp + events: + - Replace: + backspaces: 4 + insert: bạp +- input: bap6 + display: bâp + events: + - Replace: + backspaces: 4 + insert: bâp +- input: bap8 + display: băp + events: + - Replace: + backspaces: 4 + insert: băp +- input: bep6 + display: bêp + events: + - Replace: + backspaces: 4 + insert: bêp +- input: bop6 + display: bôp + events: + - Replace: + backspaces: 4 + insert: bôp +- input: bop7 + display: bơp + events: + - Replace: + backspaces: 4 + insert: bơp +- input: bup7 + display: bưp + events: + - Replace: + backspaces: 4 + insert: bưp +- input: bat1 + display: bát + events: + - Replace: + backspaces: 4 + insert: bát +- input: bat2 + display: bàt + events: + - Replace: + backspaces: 4 + insert: bàt +- input: bat3 + display: bảt + events: + - Replace: + backspaces: 4 + insert: bảt +- input: bat4 + display: bãt + events: + - Replace: + backspaces: 4 + insert: bãt +- input: bat5 + display: bạt + events: + - Replace: + backspaces: 4 + insert: bạt +- input: bat6 + display: bât + events: + - Replace: + backspaces: 4 + insert: bât +- input: bat8 + display: băt + events: + - Replace: + backspaces: 4 + insert: băt +- input: bet6 + display: bêt + events: + - Replace: + backspaces: 4 + insert: bêt +- input: bot6 + display: bôt + events: + - Replace: + backspaces: 4 + insert: bôt +- input: bot7 + display: bơt + events: + - Replace: + backspaces: 4 + insert: bơt +- input: but7 + display: bưt + events: + - Replace: + backspaces: 4 + insert: bưt +- input: ca1 + display: cá + events: + - Replace: + backspaces: 3 + insert: cá +- input: ca2 + display: cà + events: + - Replace: + backspaces: 3 + insert: cà +- input: ca3 + display: cả + events: + - Replace: + backspaces: 3 + insert: cả +- input: ca4 + display: cã + events: + - Replace: + backspaces: 3 + insert: cã +- input: ca5 + display: cạ + events: + - Replace: + backspaces: 3 + insert: cạ +- input: ca6 + display: câ + events: + - Replace: + backspaces: 3 + insert: câ +- input: ca8 + display: că + events: + - Replace: + backspaces: 3 + insert: că +- input: ce6 + display: cê + events: + - Replace: + backspaces: 3 + insert: cê +- input: co6 + display: cô + events: + - Replace: + backspaces: 3 + insert: cô +- input: co7 + display: cơ + events: + - Replace: + backspaces: 3 + insert: cơ +- input: cu7 + display: cư + events: + - Replace: + backspaces: 3 + insert: cư +- input: cac1 + display: các + events: + - Replace: + backspaces: 4 + insert: các +- input: cac2 + display: càc + events: + - Replace: + backspaces: 4 + insert: càc +- input: cac3 + display: cảc + events: + - Replace: + backspaces: 4 + insert: cảc +- input: cac4 + display: cãc + events: + - Replace: + backspaces: 4 + insert: cãc +- input: cac5 + display: cạc + events: + - Replace: + backspaces: 4 + insert: cạc +- input: cac6 + display: câc + events: + - Replace: + backspaces: 4 + insert: câc +- input: cac8 + display: căc + events: + - Replace: + backspaces: 4 + insert: căc +- input: cec6 + display: cêc + events: + - Replace: + backspaces: 4 + insert: cêc +- input: coc6 + display: côc + events: + - Replace: + backspaces: 4 + insert: côc +- input: coc7 + display: cơc + events: + - Replace: + backspaces: 4 + insert: cơc +- input: cuc7 + display: cưc + events: + - Replace: + backspaces: 4 + insert: cưc +- input: cach1 + display: cách + events: + - Replace: + backspaces: 5 + insert: cách +- input: cach2 + display: càch + events: + - Replace: + backspaces: 5 + insert: càch +- input: cach3 + display: cảch + events: + - Replace: + backspaces: 5 + insert: cảch +- input: cach4 + display: cãch + events: + - Replace: + backspaces: 5 + insert: cãch +- input: cach5 + display: cạch + events: + - Replace: + backspaces: 5 + insert: cạch +- input: cach6 + display: câch + events: + - Replace: + backspaces: 5 + insert: câch +- input: cach8 + display: căch + events: + - Replace: + backspaces: 5 + insert: căch +- input: cech6 + display: cêch + events: + - Replace: + backspaces: 5 + insert: cêch +- input: coch6 + display: côch + events: + - Replace: + backspaces: 5 + insert: côch +- input: coch7 + display: cơch + events: + - Replace: + backspaces: 5 + insert: cơch +- input: cuch7 + display: cưch + events: + - Replace: + backspaces: 5 + insert: cưch +- input: cam1 + display: cám + events: + - Replace: + backspaces: 4 + insert: cám +- input: cam2 + display: càm + events: + - Replace: + backspaces: 4 + insert: càm +- input: cam3 + display: cảm + events: + - Replace: + backspaces: 4 + insert: cảm +- input: cam4 + display: cãm + events: + - Replace: + backspaces: 4 + insert: cãm +- input: cam5 + display: cạm + events: + - Replace: + backspaces: 4 + insert: cạm +- input: cam6 + display: câm + events: + - Replace: + backspaces: 4 + insert: câm +- input: cam8 + display: căm + events: + - Replace: + backspaces: 4 + insert: căm +- input: cem6 + display: cêm + events: + - Replace: + backspaces: 4 + insert: cêm +- input: com6 + display: côm + events: + - Replace: + backspaces: 4 + insert: côm +- input: com7 + display: cơm + events: + - Replace: + backspaces: 4 + insert: cơm +- input: cum7 + display: cưm + events: + - Replace: + backspaces: 4 + insert: cưm +- input: can1 + display: cán + events: + - Replace: + backspaces: 4 + insert: cán +- input: can2 + display: càn + events: + - Replace: + backspaces: 4 + insert: càn +- input: can3 + display: cản + events: + - Replace: + backspaces: 4 + insert: cản +- input: can4 + display: cãn + events: + - Replace: + backspaces: 4 + insert: cãn +- input: can5 + display: cạn + events: + - Replace: + backspaces: 4 + insert: cạn +- input: can6 + display: cân + events: + - Replace: + backspaces: 4 + insert: cân +- input: can8 + display: căn + events: + - Replace: + backspaces: 4 + insert: căn +- input: cen6 + display: cên + events: + - Replace: + backspaces: 4 + insert: cên +- input: con6 + display: côn + events: + - Replace: + backspaces: 4 + insert: côn +- input: con7 + display: cơn + events: + - Replace: + backspaces: 4 + insert: cơn +- input: cun7 + display: cưn + events: + - Replace: + backspaces: 4 + insert: cưn +- input: cang1 + display: cáng + events: + - Replace: + backspaces: 5 + insert: cáng +- input: cang2 + display: càng + events: + - Replace: + backspaces: 5 + insert: càng +- input: cang3 + display: cảng + events: + - Replace: + backspaces: 5 + insert: cảng +- input: cang4 + display: cãng + events: + - Replace: + backspaces: 5 + insert: cãng +- input: cang5 + display: cạng + events: + - Replace: + backspaces: 5 + insert: cạng +- input: cang6 + display: câng + events: + - Replace: + backspaces: 5 + insert: câng +- input: cang8 + display: căng + events: + - Replace: + backspaces: 5 + insert: căng +- input: ceng6 + display: cêng + events: + - Replace: + backspaces: 5 + insert: cêng +- input: cong6 + display: công + events: + - Replace: + backspaces: 5 + insert: công +- input: cong7 + display: cơng + events: + - Replace: + backspaces: 5 + insert: cơng +- input: cung7 + display: cưng + events: + - Replace: + backspaces: 5 + insert: cưng +- input: canh1 + display: cánh + events: + - Replace: + backspaces: 5 + insert: cánh +- input: canh2 + display: cành + events: + - Replace: + backspaces: 5 + insert: cành +- input: canh3 + display: cảnh + events: + - Replace: + backspaces: 5 + insert: cảnh +- input: canh4 + display: cãnh + events: + - Replace: + backspaces: 5 + insert: cãnh +- input: canh5 + display: cạnh + events: + - Replace: + backspaces: 5 + insert: cạnh +- input: canh6 + display: cânh + events: + - Replace: + backspaces: 5 + insert: cânh +- input: canh8 + display: cănh + events: + - Replace: + backspaces: 5 + insert: cănh +- input: cenh6 + display: cênh + events: + - Replace: + backspaces: 5 + insert: cênh +- input: conh6 + display: cônh + events: + - Replace: + backspaces: 5 + insert: cônh +- input: conh7 + display: cơnh + events: + - Replace: + backspaces: 5 + insert: cơnh +- input: cunh7 + display: cưnh + events: + - Replace: + backspaces: 5 + insert: cưnh +- input: cap1 + display: cáp + events: + - Replace: + backspaces: 4 + insert: cáp +- input: cap2 + display: càp + events: + - Replace: + backspaces: 4 + insert: càp +- input: cap3 + display: cảp + events: + - Replace: + backspaces: 4 + insert: cảp +- input: cap4 + display: cãp + events: + - Replace: + backspaces: 4 + insert: cãp +- input: cap5 + display: cạp + events: + - Replace: + backspaces: 4 + insert: cạp +- input: cap6 + display: câp + events: + - Replace: + backspaces: 4 + insert: câp +- input: cap8 + display: căp + events: + - Replace: + backspaces: 4 + insert: căp +- input: cep6 + display: cêp + events: + - Replace: + backspaces: 4 + insert: cêp +- input: cop6 + display: côp + events: + - Replace: + backspaces: 4 + insert: côp +- input: cop7 + display: cơp + events: + - Replace: + backspaces: 4 + insert: cơp +- input: cup7 + display: cưp + events: + - Replace: + backspaces: 4 + insert: cưp +- input: cat1 + display: cát + events: + - Replace: + backspaces: 4 + insert: cát +- input: cat2 + display: càt + events: + - Replace: + backspaces: 4 + insert: càt +- input: cat3 + display: cảt + events: + - Replace: + backspaces: 4 + insert: cảt +- input: cat4 + display: cãt + events: + - Replace: + backspaces: 4 + insert: cãt +- input: cat5 + display: cạt + events: + - Replace: + backspaces: 4 + insert: cạt +- input: cat6 + display: cât + events: + - Replace: + backspaces: 4 + insert: cât +- input: cat8 + display: căt + events: + - Replace: + backspaces: 4 + insert: căt +- input: cet6 + display: cêt + events: + - Replace: + backspaces: 4 + insert: cêt +- input: cot6 + display: côt + events: + - Replace: + backspaces: 4 + insert: côt +- input: cot7 + display: cơt + events: + - Replace: + backspaces: 4 + insert: cơt +- input: cut7 + display: cưt + events: + - Replace: + backspaces: 4 + insert: cưt +- input: cha1 + display: chá + events: + - Replace: + backspaces: 4 + insert: chá +- input: cha2 + display: chà + events: + - Replace: + backspaces: 4 + insert: chà +- input: cha3 + display: chả + events: + - Replace: + backspaces: 4 + insert: chả +- input: cha4 + display: chã + events: + - Replace: + backspaces: 4 + insert: chã +- input: cha5 + display: chạ + events: + - Replace: + backspaces: 4 + insert: chạ +- input: cha6 + display: châ + events: + - Replace: + backspaces: 4 + insert: châ +- input: cha8 + display: chă + events: + - Replace: + backspaces: 4 + insert: chă +- input: che6 + display: chê + events: + - Replace: + backspaces: 4 + insert: chê +- input: cho6 + display: chô + events: + - Replace: + backspaces: 4 + insert: chô +- input: cho7 + display: chơ + events: + - Replace: + backspaces: 4 + insert: chơ +- input: chu7 + display: chư + events: + - Replace: + backspaces: 4 + insert: chư +- input: chac1 + display: chác + events: + - Replace: + backspaces: 5 + insert: chác +- input: chac2 + display: chàc + events: + - Replace: + backspaces: 5 + insert: chàc +- input: chac3 + display: chảc + events: + - Replace: + backspaces: 5 + insert: chảc +- input: chac4 + display: chãc + events: + - Replace: + backspaces: 5 + insert: chãc +- input: chac5 + display: chạc + events: + - Replace: + backspaces: 5 + insert: chạc +- input: chac6 + display: châc + events: + - Replace: + backspaces: 5 + insert: châc +- input: chac8 + display: chăc + events: + - Replace: + backspaces: 5 + insert: chăc +- input: chec6 + display: chêc + events: + - Replace: + backspaces: 5 + insert: chêc +- input: choc6 + display: chôc + events: + - Replace: + backspaces: 5 + insert: chôc +- input: choc7 + display: chơc + events: + - Replace: + backspaces: 5 + insert: chơc +- input: chuc7 + display: chưc + events: + - Replace: + backspaces: 5 + insert: chưc +- input: chach1 + display: chách + events: + - Replace: + backspaces: 6 + insert: chách +- input: chach2 + display: chàch + events: + - Replace: + backspaces: 6 + insert: chàch +- input: chach3 + display: chảch + events: + - Replace: + backspaces: 6 + insert: chảch +- input: chach4 + display: chãch + events: + - Replace: + backspaces: 6 + insert: chãch +- input: chach5 + display: chạch + events: + - Replace: + backspaces: 6 + insert: chạch +- input: chach6 + display: châch + events: + - Replace: + backspaces: 6 + insert: châch +- input: chach8 + display: chăch + events: + - Replace: + backspaces: 6 + insert: chăch +- input: chech6 + display: chêch + events: + - Replace: + backspaces: 6 + insert: chêch +- input: choch6 + display: chôch + events: + - Replace: + backspaces: 6 + insert: chôch +- input: choch7 + display: chơch + events: + - Replace: + backspaces: 6 + insert: chơch +- input: chuch7 + display: chưch + events: + - Replace: + backspaces: 6 + insert: chưch +- input: cham1 + display: chám + events: + - Replace: + backspaces: 5 + insert: chám +- input: cham2 + display: chàm + events: + - Replace: + backspaces: 5 + insert: chàm +- input: cham3 + display: chảm + events: + - Replace: + backspaces: 5 + insert: chảm +- input: cham4 + display: chãm + events: + - Replace: + backspaces: 5 + insert: chãm +- input: cham5 + display: chạm + events: + - Replace: + backspaces: 5 + insert: chạm +- input: cham6 + display: châm + events: + - Replace: + backspaces: 5 + insert: châm +- input: cham8 + display: chăm + events: + - Replace: + backspaces: 5 + insert: chăm +- input: chem6 + display: chêm + events: + - Replace: + backspaces: 5 + insert: chêm +- input: chom6 + display: chôm + events: + - Replace: + backspaces: 5 + insert: chôm +- input: chom7 + display: chơm + events: + - Replace: + backspaces: 5 + insert: chơm +- input: chum7 + display: chưm + events: + - Replace: + backspaces: 5 + insert: chưm +- input: chan1 + display: chán + events: + - Replace: + backspaces: 5 + insert: chán +- input: chan2 + display: chàn + events: + - Replace: + backspaces: 5 + insert: chàn +- input: chan3 + display: chản + events: + - Replace: + backspaces: 5 + insert: chản +- input: chan4 + display: chãn + events: + - Replace: + backspaces: 5 + insert: chãn +- input: chan5 + display: chạn + events: + - Replace: + backspaces: 5 + insert: chạn +- input: chan6 + display: chân + events: + - Replace: + backspaces: 5 + insert: chân +- input: chan8 + display: chăn + events: + - Replace: + backspaces: 5 + insert: chăn +- input: chen6 + display: chên + events: + - Replace: + backspaces: 5 + insert: chên +- input: chon6 + display: chôn + events: + - Replace: + backspaces: 5 + insert: chôn +- input: chon7 + display: chơn + events: + - Replace: + backspaces: 5 + insert: chơn +- input: chun7 + display: chưn + events: + - Replace: + backspaces: 5 + insert: chưn +- input: chang1 + display: cháng + events: + - Replace: + backspaces: 6 + insert: cháng +- input: chang2 + display: chàng + events: + - Replace: + backspaces: 6 + insert: chàng +- input: chang3 + display: chảng + events: + - Replace: + backspaces: 6 + insert: chảng +- input: chang4 + display: chãng + events: + - Replace: + backspaces: 6 + insert: chãng +- input: chang5 + display: chạng + events: + - Replace: + backspaces: 6 + insert: chạng +- input: chang6 + display: châng + events: + - Replace: + backspaces: 6 + insert: châng +- input: chang8 + display: chăng + events: + - Replace: + backspaces: 6 + insert: chăng +- input: cheng6 + display: chêng + events: + - Replace: + backspaces: 6 + insert: chêng +- input: chong6 + display: chông + events: + - Replace: + backspaces: 6 + insert: chông +- input: chong7 + display: chơng + events: + - Replace: + backspaces: 6 + insert: chơng +- input: chung7 + display: chưng + events: + - Replace: + backspaces: 6 + insert: chưng +- input: chanh1 + display: chánh + events: + - Replace: + backspaces: 6 + insert: chánh +- input: chanh2 + display: chành + events: + - Replace: + backspaces: 6 + insert: chành +- input: chanh3 + display: chảnh + events: + - Replace: + backspaces: 6 + insert: chảnh +- input: chanh4 + display: chãnh + events: + - Replace: + backspaces: 6 + insert: chãnh +- input: chanh5 + display: chạnh + events: + - Replace: + backspaces: 6 + insert: chạnh +- input: chanh6 + display: chânh + events: + - Replace: + backspaces: 6 + insert: chânh +- input: chanh8 + display: chănh + events: + - Replace: + backspaces: 6 + insert: chănh +- input: chenh6 + display: chênh + events: + - Replace: + backspaces: 6 + insert: chênh +- input: chonh6 + display: chônh + events: + - Replace: + backspaces: 6 + insert: chônh +- input: chonh7 + display: chơnh + events: + - Replace: + backspaces: 6 + insert: chơnh +- input: chunh7 + display: chưnh + events: + - Replace: + backspaces: 6 + insert: chưnh +- input: chap1 + display: cháp + events: + - Replace: + backspaces: 5 + insert: cháp +- input: chap2 + display: chàp + events: + - Replace: + backspaces: 5 + insert: chàp +- input: chap3 + display: chảp + events: + - Replace: + backspaces: 5 + insert: chảp +- input: chap4 + display: chãp + events: + - Replace: + backspaces: 5 + insert: chãp +- input: chap5 + display: chạp + events: + - Replace: + backspaces: 5 + insert: chạp +- input: chap6 + display: châp + events: + - Replace: + backspaces: 5 + insert: châp +- input: chap8 + display: chăp + events: + - Replace: + backspaces: 5 + insert: chăp +- input: chep6 + display: chêp + events: + - Replace: + backspaces: 5 + insert: chêp +- input: chop6 + display: chôp + events: + - Replace: + backspaces: 5 + insert: chôp +- input: chop7 + display: chơp + events: + - Replace: + backspaces: 5 + insert: chơp +- input: chup7 + display: chưp + events: + - Replace: + backspaces: 5 + insert: chưp +- input: chat1 + display: chát + events: + - Replace: + backspaces: 5 + insert: chát +- input: chat2 + display: chàt + events: + - Replace: + backspaces: 5 + insert: chàt +- input: chat3 + display: chảt + events: + - Replace: + backspaces: 5 + insert: chảt +- input: chat4 + display: chãt + events: + - Replace: + backspaces: 5 + insert: chãt +- input: chat5 + display: chạt + events: + - Replace: + backspaces: 5 + insert: chạt +- input: chat6 + display: chât + events: + - Replace: + backspaces: 5 + insert: chât +- input: chat8 + display: chăt + events: + - Replace: + backspaces: 5 + insert: chăt +- input: chet6 + display: chêt + events: + - Replace: + backspaces: 5 + insert: chêt +- input: chot6 + display: chôt + events: + - Replace: + backspaces: 5 + insert: chôt +- input: chot7 + display: chơt + events: + - Replace: + backspaces: 5 + insert: chơt +- input: chut7 + display: chưt + events: + - Replace: + backspaces: 5 + insert: chưt +- input: da1 + display: dá + events: + - Replace: + backspaces: 3 + insert: dá +- input: da2 + display: dà + events: + - Replace: + backspaces: 3 + insert: dà +- input: da3 + display: dả + events: + - Replace: + backspaces: 3 + insert: dả +- input: da4 + display: dã + events: + - Replace: + backspaces: 3 + insert: dã +- input: da5 + display: dạ + events: + - Replace: + backspaces: 3 + insert: dạ +- input: da6 + display: dâ + events: + - Replace: + backspaces: 3 + insert: dâ +- input: da8 + display: dă + events: + - Replace: + backspaces: 3 + insert: dă +- input: de6 + display: dê + events: + - Replace: + backspaces: 3 + insert: dê +- input: do6 + display: dô + events: + - Replace: + backspaces: 3 + insert: dô +- input: do7 + display: dơ + events: + - Replace: + backspaces: 3 + insert: dơ +- input: du7 + display: dư + events: + - Replace: + backspaces: 3 + insert: dư +- input: dac1 + display: dác + events: + - Replace: + backspaces: 4 + insert: dác +- input: dac2 + display: dàc + events: + - Replace: + backspaces: 4 + insert: dàc +- input: dac3 + display: dảc + events: + - Replace: + backspaces: 4 + insert: dảc +- input: dac4 + display: dãc + events: + - Replace: + backspaces: 4 + insert: dãc +- input: dac5 + display: dạc + events: + - Replace: + backspaces: 4 + insert: dạc +- input: dac6 + display: dâc + events: + - Replace: + backspaces: 4 + insert: dâc +- input: dac8 + display: dăc + events: + - Replace: + backspaces: 4 + insert: dăc +- input: dec6 + display: dêc + events: + - Replace: + backspaces: 4 + insert: dêc +- input: doc6 + display: dôc + events: + - Replace: + backspaces: 4 + insert: dôc +- input: doc7 + display: dơc + events: + - Replace: + backspaces: 4 + insert: dơc +- input: duc7 + display: dưc + events: + - Replace: + backspaces: 4 + insert: dưc +- input: dach1 + display: dách + events: + - Replace: + backspaces: 5 + insert: dách +- input: dach2 + display: dàch + events: + - Replace: + backspaces: 5 + insert: dàch +- input: dach3 + display: dảch + events: + - Replace: + backspaces: 5 + insert: dảch +- input: dach4 + display: dãch + events: + - Replace: + backspaces: 5 + insert: dãch +- input: dach5 + display: dạch + events: + - Replace: + backspaces: 5 + insert: dạch +- input: dach6 + display: dâch + events: + - Replace: + backspaces: 5 + insert: dâch +- input: dach8 + display: dăch + events: + - Replace: + backspaces: 5 + insert: dăch +- input: dech6 + display: dêch + events: + - Replace: + backspaces: 5 + insert: dêch +- input: doch6 + display: dôch + events: + - Replace: + backspaces: 5 + insert: dôch +- input: doch7 + display: dơch + events: + - Replace: + backspaces: 5 + insert: dơch +- input: duch7 + display: dưch + events: + - Replace: + backspaces: 5 + insert: dưch +- input: dam1 + display: dám + events: + - Replace: + backspaces: 4 + insert: dám +- input: dam2 + display: dàm + events: + - Replace: + backspaces: 4 + insert: dàm +- input: dam3 + display: dảm + events: + - Replace: + backspaces: 4 + insert: dảm +- input: dam4 + display: dãm + events: + - Replace: + backspaces: 4 + insert: dãm +- input: dam5 + display: dạm + events: + - Replace: + backspaces: 4 + insert: dạm +- input: dam6 + display: dâm + events: + - Replace: + backspaces: 4 + insert: dâm +- input: dam8 + display: dăm + events: + - Replace: + backspaces: 4 + insert: dăm +- input: dem6 + display: dêm + events: + - Replace: + backspaces: 4 + insert: dêm +- input: dom6 + display: dôm + events: + - Replace: + backspaces: 4 + insert: dôm +- input: dom7 + display: dơm + events: + - Replace: + backspaces: 4 + insert: dơm +- input: dum7 + display: dưm + events: + - Replace: + backspaces: 4 + insert: dưm +- input: dan1 + display: dán + events: + - Replace: + backspaces: 4 + insert: dán +- input: dan2 + display: dàn + events: + - Replace: + backspaces: 4 + insert: dàn +- input: dan3 + display: dản + events: + - Replace: + backspaces: 4 + insert: dản +- input: dan4 + display: dãn + events: + - Replace: + backspaces: 4 + insert: dãn +- input: dan5 + display: dạn + events: + - Replace: + backspaces: 4 + insert: dạn +- input: dan6 + display: dân + events: + - Replace: + backspaces: 4 + insert: dân +- input: dan8 + display: dăn + events: + - Replace: + backspaces: 4 + insert: dăn +- input: den6 + display: dên + events: + - Replace: + backspaces: 4 + insert: dên +- input: don6 + display: dôn + events: + - Replace: + backspaces: 4 + insert: dôn +- input: don7 + display: dơn + events: + - Replace: + backspaces: 4 + insert: dơn +- input: dun7 + display: dưn + events: + - Replace: + backspaces: 4 + insert: dưn +- input: dang1 + display: dáng + events: + - Replace: + backspaces: 5 + insert: dáng +- input: dang2 + display: dàng + events: + - Replace: + backspaces: 5 + insert: dàng +- input: dang3 + display: dảng + events: + - Replace: + backspaces: 5 + insert: dảng +- input: dang4 + display: dãng + events: + - Replace: + backspaces: 5 + insert: dãng +- input: dang5 + display: dạng + events: + - Replace: + backspaces: 5 + insert: dạng +- input: dang6 + display: dâng + events: + - Replace: + backspaces: 5 + insert: dâng +- input: dang8 + display: dăng + events: + - Replace: + backspaces: 5 + insert: dăng +- input: deng6 + display: dêng + events: + - Replace: + backspaces: 5 + insert: dêng +- input: dong6 + display: dông + events: + - Replace: + backspaces: 5 + insert: dông +- input: dong7 + display: dơng + events: + - Replace: + backspaces: 5 + insert: dơng +- input: dung7 + display: dưng + events: + - Replace: + backspaces: 5 + insert: dưng +- input: danh1 + display: dánh + events: + - Replace: + backspaces: 5 + insert: dánh +- input: danh2 + display: dành + events: + - Replace: + backspaces: 5 + insert: dành +- input: danh3 + display: dảnh + events: + - Replace: + backspaces: 5 + insert: dảnh +- input: danh4 + display: dãnh + events: + - Replace: + backspaces: 5 + insert: dãnh +- input: danh5 + display: dạnh + events: + - Replace: + backspaces: 5 + insert: dạnh +- input: danh6 + display: dânh + events: + - Replace: + backspaces: 5 + insert: dânh +- input: danh8 + display: dănh + events: + - Replace: + backspaces: 5 + insert: dănh +- input: denh6 + display: dênh + events: + - Replace: + backspaces: 5 + insert: dênh +- input: donh6 + display: dônh + events: + - Replace: + backspaces: 5 + insert: dônh +- input: donh7 + display: dơnh + events: + - Replace: + backspaces: 5 + insert: dơnh +- input: dunh7 + display: dưnh + events: + - Replace: + backspaces: 5 + insert: dưnh +- input: dap1 + display: dáp + events: + - Replace: + backspaces: 4 + insert: dáp +- input: dap2 + display: dàp + events: + - Replace: + backspaces: 4 + insert: dàp +- input: dap3 + display: dảp + events: + - Replace: + backspaces: 4 + insert: dảp +- input: dap4 + display: dãp + events: + - Replace: + backspaces: 4 + insert: dãp +- input: dap5 + display: dạp + events: + - Replace: + backspaces: 4 + insert: dạp +- input: dap6 + display: dâp + events: + - Replace: + backspaces: 4 + insert: dâp +- input: dap8 + display: dăp + events: + - Replace: + backspaces: 4 + insert: dăp +- input: dep6 + display: dêp + events: + - Replace: + backspaces: 4 + insert: dêp +- input: dop6 + display: dôp + events: + - Replace: + backspaces: 4 + insert: dôp +- input: dop7 + display: dơp + events: + - Replace: + backspaces: 4 + insert: dơp +- input: dup7 + display: dưp + events: + - Replace: + backspaces: 4 + insert: dưp +- input: dat1 + display: dát + events: + - Replace: + backspaces: 4 + insert: dát +- input: dat2 + display: dàt + events: + - Replace: + backspaces: 4 + insert: dàt +- input: dat3 + display: dảt + events: + - Replace: + backspaces: 4 + insert: dảt +- input: dat4 + display: dãt + events: + - Replace: + backspaces: 4 + insert: dãt +- input: dat5 + display: dạt + events: + - Replace: + backspaces: 4 + insert: dạt +- input: dat6 + display: dât + events: + - Replace: + backspaces: 4 + insert: dât +- input: dat8 + display: dăt + events: + - Replace: + backspaces: 4 + insert: dăt +- input: det6 + display: dêt + events: + - Replace: + backspaces: 4 + insert: dêt +- input: dot6 + display: dôt + events: + - Replace: + backspaces: 4 + insert: dôt +- input: dot7 + display: dơt + events: + - Replace: + backspaces: 4 + insert: dơt +- input: dut7 + display: dưt + events: + - Replace: + backspaces: 4 + insert: dưt +- input: ga1 + display: gá + events: + - Replace: + backspaces: 3 + insert: gá +- input: ga2 + display: gà + events: + - Replace: + backspaces: 3 + insert: gà +- input: ga3 + display: gả + events: + - Replace: + backspaces: 3 + insert: gả +- input: ga4 + display: gã + events: + - Replace: + backspaces: 3 + insert: gã +- input: ga5 + display: gạ + events: + - Replace: + backspaces: 3 + insert: gạ +- input: ga6 + display: gâ + events: + - Replace: + backspaces: 3 + insert: gâ +- input: ga8 + display: gă + events: + - Replace: + backspaces: 3 + insert: gă +- input: ge6 + display: gê + events: + - Replace: + backspaces: 3 + insert: gê +- input: go6 + display: gô + events: + - Replace: + backspaces: 3 + insert: gô +- input: go7 + display: gơ + events: + - Replace: + backspaces: 3 + insert: gơ +- input: gu7 + display: gư + events: + - Replace: + backspaces: 3 + insert: gư +- input: gan1 + display: gán + events: + - Replace: + backspaces: 4 + insert: gán +- input: gan2 + display: gàn + events: + - Replace: + backspaces: 4 + insert: gàn +- input: gan3 + display: gản + events: + - Replace: + backspaces: 4 + insert: gản +- input: gan4 + display: gãn + events: + - Replace: + backspaces: 4 + insert: gãn +- input: gan5 + display: gạn + events: + - Replace: + backspaces: 4 + insert: gạn +- input: gan6 + display: gân + events: + - Replace: + backspaces: 4 + insert: gân +- input: gan8 + display: găn + events: + - Replace: + backspaces: 4 + insert: găn +- input: gen6 + display: gên + events: + - Replace: + backspaces: 4 + insert: gên +- input: gon6 + display: gôn + events: + - Replace: + backspaces: 4 + insert: gôn +- input: gon7 + display: gơn + events: + - Replace: + backspaces: 4 + insert: gơn +- input: gun7 + display: gưn + events: + - Replace: + backspaces: 4 + insert: gưn +- input: gang1 + display: gáng + events: + - Replace: + backspaces: 5 + insert: gáng +- input: gang2 + display: gàng + events: + - Replace: + backspaces: 5 + insert: gàng +- input: gang3 + display: gảng + events: + - Replace: + backspaces: 5 + insert: gảng +- input: gang4 + display: gãng + events: + - Replace: + backspaces: 5 + insert: gãng +- input: gang5 + display: gạng + events: + - Replace: + backspaces: 5 + insert: gạng diff --git a/engine/tests/testdata/telex_inputs.json b/engine/tests/testdata/telex_inputs.json new file mode 100644 index 0000000..fe7f1ef --- /dev/null +++ b/engine/tests/testdata/telex_inputs.json @@ -0,0 +1,502 @@ +[ + "aaf", + "aas", + "aaj", + "aar", + "aax", + "aaw", + "aaa", + "eee", + "ooo", + "oow", + "uuw", + "acaf", + "acas", + "acaj", + "acar", + "acax", + "acaw", + "acaa", + "ecee", + "ocoo", + "ocow", + "ucuw", + "amaf", + "amas", + "amaj", + "amar", + "amax", + "amaw", + "amaa", + "emee", + "omoo", + "omow", + "umuw", + "anaf", + "anas", + "anaj", + "anar", + "anax", + "anaw", + "anaa", + "enee", + "onoo", + "onow", + "unuw", + "angaf", + "angas", + "angaj", + "angar", + "angax", + "angaw", + "angaa", + "engee", + "ongoo", + "ongow", + "unguw", + "apaf", + "apas", + "apaj", + "apar", + "apax", + "apaw", + "apaa", + "epee", + "opoo", + "opow", + "upuw", + "ataf", + "atas", + "ataj", + "atar", + "atax", + "ataw", + "ataa", + "etee", + "otoo", + "otow", + "utuw", + "baaf", + "baas", + "baaj", + "baar", + "baax", + "baaw", + "baaa", + "beee", + "booo", + "boow", + "buuw", + "bacaf", + "bacas", + "bacaj", + "bacar", + "bacax", + "bacaw", + "bacaa", + "becee", + "bocoo", + "bocow", + "bucuw", + "bachaf", + "bachas", + "bachaj", + "bachar", + "bachax", + "bachaw", + "bachaa", + "bechee", + "bochoo", + "bochow", + "buchuw", + "bamaf", + "bamas", + "bamaj", + "bamar", + "bamax", + "bamaw", + "bamaa", + "bemee", + "bomoo", + "bomow", + "bumuw", + "banaf", + "banas", + "banaj", + "banar", + "banax", + "banaw", + "banaa", + "benee", + "bonoo", + "bonow", + "bunuw", + "bangaf", + "bangas", + "bangaj", + "bangar", + "bangax", + "bangaw", + "bangaa", + "bengee", + "bongoo", + "bongow", + "bunguw", + "banhaf", + "banhas", + "banhaj", + "banhar", + "banhax", + "banhaw", + "banhaa", + "benhee", + "bonhoo", + "bonhow", + "bunhuw", + "bapaf", + "bapas", + "bapaj", + "bapar", + "bapax", + "bapaw", + "bapaa", + "bepee", + "bopoo", + "bopow", + "bupuw", + "bataf", + "batas", + "bataj", + "batar", + "batax", + "bataw", + "bataa", + "betee", + "botoo", + "botow", + "butuw", + "caaf", + "caas", + "caaj", + "caar", + "caax", + "caaw", + "caaa", + "ceee", + "cooo", + "coow", + "cuuw", + "cacaf", + "cacas", + "cacaj", + "cacar", + "cacax", + "cacaw", + "cacaa", + "cecee", + "cocoo", + "cocow", + "cucuw", + "cachaf", + "cachas", + "cachaj", + "cachar", + "cachax", + "cachaw", + "cachaa", + "cechee", + "cochoo", + "cochow", + "cuchuw", + "camaf", + "camas", + "camaj", + "camar", + "camax", + "camaw", + "camaa", + "cemee", + "comoo", + "comow", + "cumuw", + "canaf", + "canas", + "canaj", + "canar", + "canax", + "canaw", + "canaa", + "cenee", + "conoo", + "conow", + "cunuw", + "cangaf", + "cangas", + "cangaj", + "cangar", + "cangax", + "cangaw", + "cangaa", + "cengee", + "congoo", + "congow", + "cunguw", + "canhaf", + "canhas", + "canhaj", + "canhar", + "canhax", + "canhaw", + "canhaa", + "cenhee", + "conhoo", + "conhow", + "cunhuw", + "capaf", + "capas", + "capaj", + "capar", + "capax", + "capaw", + "capaa", + "cepee", + "copoo", + "copow", + "cupuw", + "cataf", + "catas", + "cataj", + "catar", + "catax", + "cataw", + "cataa", + "cetee", + "cotoo", + "cotow", + "cutuw", + "chaaf", + "chaas", + "chaaj", + "chaar", + "chaax", + "chaaw", + "chaaa", + "cheee", + "chooo", + "choow", + "chuuw", + "chacaf", + "chacas", + "chacaj", + "chacar", + "chacax", + "chacaw", + "chacaa", + "checee", + "chocoo", + "chocow", + "chucuw", + "chachaf", + "chachas", + "chachaj", + "chachar", + "chachax", + "chachaw", + "chachaa", + "chechee", + "chochoo", + "chochow", + "chuchuw", + "chamaf", + "chamas", + "chamaj", + "chamar", + "chamax", + "chamaw", + "chamaa", + "chemee", + "chomoo", + "chomow", + "chumuw", + "chanaf", + "chanas", + "chanaj", + "chanar", + "chanax", + "chanaw", + "chanaa", + "chenee", + "chonoo", + "chonow", + "chunuw", + "changaf", + "changas", + "changaj", + "changar", + "changax", + "changaw", + "changaa", + "chengee", + "chongoo", + "chongow", + "chunguw", + "chanhaf", + "chanhas", + "chanhaj", + "chanhar", + "chanhax", + "chanhaw", + "chanhaa", + "chenhee", + "chonhoo", + "chonhow", + "chunhuw", + "chapaf", + "chapas", + "chapaj", + "chapar", + "chapax", + "chapaw", + "chapaa", + "chepee", + "chopoo", + "chopow", + "chupuw", + "chataf", + "chatas", + "chataj", + "chatar", + "chatax", + "chataw", + "chataa", + "chetee", + "chotoo", + "chotow", + "chutuw", + "daaf", + "daas", + "daaj", + "daar", + "daax", + "daaw", + "daaa", + "deee", + "dooo", + "doow", + "duuw", + "dacaf", + "dacas", + "dacaj", + "dacar", + "dacax", + "dacaw", + "dacaa", + "decee", + "docoo", + "docow", + "ducuw", + "dachaf", + "dachas", + "dachaj", + "dachar", + "dachax", + "dachaw", + "dachaa", + "dechee", + "dochoo", + "dochow", + "duchuw", + "damaf", + "damas", + "damaj", + "damar", + "damax", + "damaw", + "damaa", + "demee", + "domoo", + "domow", + "dumuw", + "danaf", + "danas", + "danaj", + "danar", + "danax", + "danaw", + "danaa", + "denee", + "donoo", + "donow", + "dunuw", + "dangaf", + "dangas", + "dangaj", + "dangar", + "dangax", + "dangaw", + "dangaa", + "dengee", + "dongoo", + "dongow", + "dunguw", + "danhaf", + "danhas", + "danhaj", + "danhar", + "danhax", + "danhaw", + "danhaa", + "denhee", + "donhoo", + "donhow", + "dunhuw", + "dapaf", + "dapas", + "dapaj", + "dapar", + "dapax", + "dapaw", + "dapaa", + "depee", + "dopoo", + "dopow", + "dupuw", + "dataf", + "datas", + "dataj", + "datar", + "datax", + "dataw", + "dataa", + "detee", + "dotoo", + "dotow", + "dutuw", + "gaaf", + "gaas", + "gaaj", + "gaar", + "gaax", + "gaaw", + "gaaa", + "geee", + "gooo", + "goow", + "guuw", + "ganaf", + "ganas", + "ganaj", + "ganar", + "ganax", + "ganaw", + "ganaa", + "genee", + "gonoo", + "gonow", + "gunuw", + "gangaf", + "gangas", + "gangaj", + "gangar", + "gangax" +] \ No newline at end of file diff --git a/engine/tests/testdata/vni_inputs.json b/engine/tests/testdata/vni_inputs.json new file mode 100644 index 0000000..16fb597 --- /dev/null +++ b/engine/tests/testdata/vni_inputs.json @@ -0,0 +1,502 @@ +[ + "a1", + "a2", + "a3", + "a4", + "a5", + "a6", + "a8", + "e6", + "o6", + "o7", + "u7", + "ac1", + "ac2", + "ac3", + "ac4", + "ac5", + "ac6", + "ac8", + "ec6", + "oc6", + "oc7", + "uc7", + "am1", + "am2", + "am3", + "am4", + "am5", + "am6", + "am8", + "em6", + "om6", + "om7", + "um7", + "an1", + "an2", + "an3", + "an4", + "an5", + "an6", + "an8", + "en6", + "on6", + "on7", + "un7", + "ang1", + "ang2", + "ang3", + "ang4", + "ang5", + "ang6", + "ang8", + "eng6", + "ong6", + "ong7", + "ung7", + "ap1", + "ap2", + "ap3", + "ap4", + "ap5", + "ap6", + "ap8", + "ep6", + "op6", + "op7", + "up7", + "at1", + "at2", + "at3", + "at4", + "at5", + "at6", + "at8", + "et6", + "ot6", + "ot7", + "ut7", + "ba1", + "ba2", + "ba3", + "ba4", + "ba5", + "ba6", + "ba8", + "be6", + "bo6", + "bo7", + "bu7", + "bac1", + "bac2", + "bac3", + "bac4", + "bac5", + "bac6", + "bac8", + "bec6", + "boc6", + "boc7", + "buc7", + "bach1", + "bach2", + "bach3", + "bach4", + "bach5", + "bach6", + "bach8", + "bech6", + "boch6", + "boch7", + "buch7", + "bam1", + "bam2", + "bam3", + "bam4", + "bam5", + "bam6", + "bam8", + "bem6", + "bom6", + "bom7", + "bum7", + "ban1", + "ban2", + "ban3", + "ban4", + "ban5", + "ban6", + "ban8", + "ben6", + "bon6", + "bon7", + "bun7", + "bang1", + "bang2", + "bang3", + "bang4", + "bang5", + "bang6", + "bang8", + "beng6", + "bong6", + "bong7", + "bung7", + "banh1", + "banh2", + "banh3", + "banh4", + "banh5", + "banh6", + "banh8", + "benh6", + "bonh6", + "bonh7", + "bunh7", + "bap1", + "bap2", + "bap3", + "bap4", + "bap5", + "bap6", + "bap8", + "bep6", + "bop6", + "bop7", + "bup7", + "bat1", + "bat2", + "bat3", + "bat4", + "bat5", + "bat6", + "bat8", + "bet6", + "bot6", + "bot7", + "but7", + "ca1", + "ca2", + "ca3", + "ca4", + "ca5", + "ca6", + "ca8", + "ce6", + "co6", + "co7", + "cu7", + "cac1", + "cac2", + "cac3", + "cac4", + "cac5", + "cac6", + "cac8", + "cec6", + "coc6", + "coc7", + "cuc7", + "cach1", + "cach2", + "cach3", + "cach4", + "cach5", + "cach6", + "cach8", + "cech6", + "coch6", + "coch7", + "cuch7", + "cam1", + "cam2", + "cam3", + "cam4", + "cam5", + "cam6", + "cam8", + "cem6", + "com6", + "com7", + "cum7", + "can1", + "can2", + "can3", + "can4", + "can5", + "can6", + "can8", + "cen6", + "con6", + "con7", + "cun7", + "cang1", + "cang2", + "cang3", + "cang4", + "cang5", + "cang6", + "cang8", + "ceng6", + "cong6", + "cong7", + "cung7", + "canh1", + "canh2", + "canh3", + "canh4", + "canh5", + "canh6", + "canh8", + "cenh6", + "conh6", + "conh7", + "cunh7", + "cap1", + "cap2", + "cap3", + "cap4", + "cap5", + "cap6", + "cap8", + "cep6", + "cop6", + "cop7", + "cup7", + "cat1", + "cat2", + "cat3", + "cat4", + "cat5", + "cat6", + "cat8", + "cet6", + "cot6", + "cot7", + "cut7", + "cha1", + "cha2", + "cha3", + "cha4", + "cha5", + "cha6", + "cha8", + "che6", + "cho6", + "cho7", + "chu7", + "chac1", + "chac2", + "chac3", + "chac4", + "chac5", + "chac6", + "chac8", + "chec6", + "choc6", + "choc7", + "chuc7", + "chach1", + "chach2", + "chach3", + "chach4", + "chach5", + "chach6", + "chach8", + "chech6", + "choch6", + "choch7", + "chuch7", + "cham1", + "cham2", + "cham3", + "cham4", + "cham5", + "cham6", + "cham8", + "chem6", + "chom6", + "chom7", + "chum7", + "chan1", + "chan2", + "chan3", + "chan4", + "chan5", + "chan6", + "chan8", + "chen6", + "chon6", + "chon7", + "chun7", + "chang1", + "chang2", + "chang3", + "chang4", + "chang5", + "chang6", + "chang8", + "cheng6", + "chong6", + "chong7", + "chung7", + "chanh1", + "chanh2", + "chanh3", + "chanh4", + "chanh5", + "chanh6", + "chanh8", + "chenh6", + "chonh6", + "chonh7", + "chunh7", + "chap1", + "chap2", + "chap3", + "chap4", + "chap5", + "chap6", + "chap8", + "chep6", + "chop6", + "chop7", + "chup7", + "chat1", + "chat2", + "chat3", + "chat4", + "chat5", + "chat6", + "chat8", + "chet6", + "chot6", + "chot7", + "chut7", + "da1", + "da2", + "da3", + "da4", + "da5", + "da6", + "da8", + "de6", + "do6", + "do7", + "du7", + "dac1", + "dac2", + "dac3", + "dac4", + "dac5", + "dac6", + "dac8", + "dec6", + "doc6", + "doc7", + "duc7", + "dach1", + "dach2", + "dach3", + "dach4", + "dach5", + "dach6", + "dach8", + "dech6", + "doch6", + "doch7", + "duch7", + "dam1", + "dam2", + "dam3", + "dam4", + "dam5", + "dam6", + "dam8", + "dem6", + "dom6", + "dom7", + "dum7", + "dan1", + "dan2", + "dan3", + "dan4", + "dan5", + "dan6", + "dan8", + "den6", + "don6", + "don7", + "dun7", + "dang1", + "dang2", + "dang3", + "dang4", + "dang5", + "dang6", + "dang8", + "deng6", + "dong6", + "dong7", + "dung7", + "danh1", + "danh2", + "danh3", + "danh4", + "danh5", + "danh6", + "danh8", + "denh6", + "donh6", + "donh7", + "dunh7", + "dap1", + "dap2", + "dap3", + "dap4", + "dap5", + "dap6", + "dap8", + "dep6", + "dop6", + "dop7", + "dup7", + "dat1", + "dat2", + "dat3", + "dat4", + "dat5", + "dat6", + "dat8", + "det6", + "dot6", + "dot7", + "dut7", + "ga1", + "ga2", + "ga3", + "ga4", + "ga5", + "ga6", + "ga8", + "ge6", + "go6", + "go7", + "gu7", + "gan1", + "gan2", + "gan3", + "gan4", + "gan5", + "gan6", + "gan8", + "gen6", + "gon6", + "gon7", + "gun7", + "gang1", + "gang2", + "gang3", + "gang4", + "gang5" +] \ No newline at end of file diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh index 452d66d..45bcbcf 100644 --- a/packaging/appimage/build-appimage.sh +++ b/packaging/appimage/build-appimage.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Ensure cargo is in PATH (common for rustup installations) +# Ensure cargo is in PATH if ! command -v cargo &>/dev/null; then if [ -f "$HOME/.cargo/bin/cargo" ]; then export PATH="$HOME/.cargo/bin:$PATH" @@ -22,82 +22,47 @@ mkdir -p "$APPDIR/usr/share/applications" mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps" mkdir -p "$APPDIR/usr/share/doc/vietc" mkdir -p "$APPDIR/etc/vietc" +mkdir -p "$APPDIR/usr/lib/systemd/user" +mkdir -p "$APPDIR/usr/share/metainfo" # Build binaries echo "[1/5] Building binaries..." -cargo build --release +if [ ! -f "target/release/vietc" ]; then + cargo build --release + cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$PROJECT_ROOT" +fi echo " Built with x11 + wayland" - -cd "$SCRIPT_DIR" -cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$SCRIPT_DIR" -cd "$PROJECT_ROOT" - -# Copy binaries +# Copy binaries from deb-build if they exist, otherwise from target/release echo "[2/5] Installing binaries..." -cp target/release/vietc "$APPDIR/usr/bin/" -cp target/release/vietc-cli "$APPDIR/usr/bin/" -[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" +if [ -d "deb-build/usr/bin" ]; then + cp -r deb-build/usr/bin/* "$APPDIR/usr/bin/" +else + cp target/release/vietc "$APPDIR/usr/bin/" + cp target/release/vietc-cli "$APPDIR/usr/bin/" + [ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" +fi # Desktop integration echo "[3/5] Installing desktop integration..." -cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/" - -# Generate SVG icon -cat > "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF' - - - - - - - - - - - - - - - - - - - - - - - VN - -SVGEOF - -# Convert SVG to PNG if rsvg-convert available -if command -v rsvg-convert &>/dev/null; then - rsvg-convert -w 256 -h 256 "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.svg" \ - -o "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png" +if [ -f "deb-build/vietc.desktop" ]; then + cp deb-build/vietc.desktop "$APPDIR/usr/share/applications/" else - # Fallback: generate PNG via Python/Pillow - python3 -c " -from PIL import Image, ImageDraw -img = Image.new('RGBA', (256, 256), (0,0,0,0)) -draw = ImageDraw.Draw(img) -draw.ellipse([(20,20),(236,236)], fill=(218,29,37), outline=(180,20,30), width=4) -try: - from PIL import ImageFont - font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 80) -except: - font = ImageFont.load_default() -draw.text((128, 128), 'VN', fill=(255,255,255), font=font, anchor='mm') -img.save('$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc.png') -" 2>/dev/null || echo " PNG icon generation skipped (no Pillow)" + cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/" fi -# Copy icon to AppDir root for appimagetool -cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/vietc."{png,svg} "$APPDIR/" 2>/dev/null || true +# Icons +if [ -f "deb-build/vietc.svg" ]; then + cp deb-build/vietc.svg "$APPDIR/usr/share/icons/hicolor/256x256/apps/" + cp deb-build/vietc.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/" + cp deb-build/vietc.png "$APPDIR/" +fi # AppStream metadata -mkdir -p "$APPDIR/usr/share/metainfo" -cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML' +if [ -f "deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" ]; then + cp deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml "$APPDIR/usr/share/metainfo/" +else + cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML' io.github.anomalyco.vietc @@ -113,19 +78,36 @@ cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML Utility XML +fi # Config echo "[4/5] Installing config..." -# Use grab=true by default in the AppImage; falls back gracefully for non-root -sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml" -cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/" +if [ -f "deb-build/etc/vietc/config.toml" ]; then + cp deb-build/etc/vietc/config.toml "$APPDIR/etc/vietc/" +else + sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml" +fi + +# Docs +if [ -f "deb-build/usr/share/doc/vietc/README.md" ]; then + cp deb-build/usr/share/doc/vietc/README.md "$APPDIR/usr/share/doc/vietc/" +else + cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/" +fi # Systemd service -mkdir -p "$APPDIR/usr/lib/systemd/user" -cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/" +if [ -f "deb-build/usr/lib/systemd/user/vietc.service" ]; then + cp deb-build/usr/lib/systemd/user/vietc.service "$APPDIR/usr/lib/systemd/user/" +else + cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/" +fi # Desktop file in AppDir root -cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/" +if [ -f "deb-build/vietc.desktop" ]; then + cp deb-build/vietc.desktop "$APPDIR/" +else + cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/" +fi # Create custom AppRun script cat > "$APPDIR/AppRun" << 'EOF' @@ -135,8 +117,17 @@ HERE="$(dirname "$(readlink -f "${0}")")" # Export our bin dir on PATH so child processes can find sibling binaries export PATH="$HERE/usr/bin:$PATH" +# Build display env prefix for elevation commands. +# Capture from current user env (DISPLAY, XAUTHORITY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR) +# so they are available inside the root daemon. Without this, xdotool/xclip/wtype +# fail silently because sudo/pkexec strip display env vars. +ENV_PREFIX="env" +[ -n "$DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX DISPLAY=$DISPLAY" +[ -n "$XAUTHORITY" ] && ENV_PREFIX="$ENV_PREFIX XAUTHORITY=$XAUTHORITY" +[ -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" + # Start daemon (kill old non-root one first if we have root) -SUDO_CMD="" # Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy. # Only set WAYLAND_DISPLAY if the user actually has a Wayland session. @@ -149,7 +140,9 @@ if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; the fi if command -v pkexec >/dev/null && [ -z "$WAYLAND_DISPLAY" ]; then - SUDO_CMD="pkexec" + pkill -x vietc 2>/dev/null; sleep 0.5 + pkexec $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & + DAEMON_PID=$! elif [ -n "$WAYLAND_DISPLAY" ]; then password="" if command -v kdialog >/dev/null; then @@ -161,24 +154,12 @@ elif [ -n "$WAYLAND_DISPLAY" ]; then fi if [ -n "$password" ]; then pkill -x vietc 2>/dev/null; sleep 0.5 - echo "$password" | sudo -S env \ - XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \ - WAYLAND_DISPLAY="$WAYLAND_DISPLAY" \ - "$HERE/usr/bin/vietc" >/dev/null & + echo "$password" | sudo -S $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & DAEMON_PID=$! fi elif command -v sudo >/dev/null; then - SUDO_CMD="sudo" -fi - -if [ -n "$SUDO_CMD" ]; then pkill -x vietc 2>/dev/null; sleep 0.5 - if [ "$(id -u)" = "0" ]; then - # Already root: run daemon with stderr visible (stdout to /dev/null) - "$HERE/usr/bin/vietc" >/dev/null & - else - "$SUDO_CMD" "$HERE/usr/bin/vietc" >/dev/null & - fi + sudo $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & DAEMON_PID=$! fi @@ -212,7 +193,6 @@ echo "" # Auto build if appimagetool exists if [ -f "$SCRIPT_DIR/appimagetool" ]; then echo "=== Running appimagetool FUSE build ===" - # AppImage inside container/VM sometimes needs --appimage-extract-and-run if FUSE is not mounted ARCH=x86_64 "$SCRIPT_DIR/appimagetool" --appimage-extract-and-run "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage" elif command -v appimagetool &>/dev/null; then echo "=== Running system appimagetool ===" diff --git a/protocol/src/inject.rs b/protocol/src/inject.rs index 98ba6d7..e6e7a1e 100644 --- a/protocol/src/inject.rs +++ b/protocol/src/inject.rs @@ -15,11 +15,19 @@ pub struct KeyEvent { impl KeyEvent { pub fn press(code: u32, value: char) -> Self { - Self { code, value, action: KeyAction::Press } + Self { + code, + value, + action: KeyAction::Press, + } } pub fn release(code: u32, value: char) -> Self { - Self { code, value, action: KeyAction::Release } + Self { + code, + value, + action: KeyAction::Release, + } } pub fn is_press(&self) -> bool { @@ -64,6 +72,12 @@ pub trait KeyInjector { } self.send_string(text) } + + /// Record that Unicode text was pasted via clipboard (for future delete/backspace support) + fn update_pasted_text(&self, _text: &str) -> InjectResult { + // Stub implementation - actual text tracking happens in engine via OutputCommand::Type + InjectResult::Success + } } impl fmt::Display for InjectResult { diff --git a/protocol/src/uinput_monitor.rs b/protocol/src/uinput_monitor.rs index e02dac2..1259282 100644 --- a/protocol/src/uinput_monitor.rs +++ b/protocol/src/uinput_monitor.rs @@ -58,8 +58,7 @@ impl UinputInjector { ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64) .map_err(|e| format!("UI_DEV_SETUP failed: {}", e))?; - ioctl(fd, UI_DEV_CREATE, 0) - .map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?; + ioctl(fd, UI_DEV_CREATE, 0).map_err(|e| format!("UI_DEV_CREATE failed: {}", e))?; // Small delay for device to be ready std::thread::sleep(std::time::Duration::from_millis(10)); @@ -69,7 +68,10 @@ impl UinputInjector { fn send_uinput_event(&self, type_: u16, code: u16, value: i32) { let event = input_event { - time: timeval { tv_sec: 0, tv_usec: 0 }, + time: timeval { + tv_sec: 0, + tv_usec: 0, + }, type_, code, value, @@ -78,7 +80,33 @@ impl UinputInjector { unsafe { let ptr = &event as *const input_event as *const u8; let len = std::mem::size_of::(); - let _ = libc::write(self.file.as_raw_fd() as libc::c_int, ptr as *const libc::c_void, len); + let _ = libc::write( + self.file.as_raw_fd() as libc::c_int, + ptr as *const libc::c_void, + len, + ); + } + } + + fn send_key_stroke(&self, keycode: u16, shift: bool) { + if shift { + self.send_uinput_event(EV_KEY, 42, 1); // Shift press + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(2)); + } + + self.send_uinput_event(EV_KEY, keycode, 1); // Key press + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(2)); + + self.send_uinput_event(EV_KEY, keycode, 0); // Key release + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(2)); + + if shift { + self.send_uinput_event(EV_KEY, 42, 0); // Shift release + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(2)); } } } @@ -86,44 +114,95 @@ impl UinputInjector { impl KeyInjector for UinputInjector { fn send_backspace(&self) -> InjectResult { self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release - self.send_uinput_event(0, 0, 0); // EV_SYN + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(2)); + InjectResult::Success } fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult { self.send_uinput_event(EV_KEY, keycode, value); self.send_uinput_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_millis(2)); InjectResult::Success } fn send_char(&self, ch: char) -> InjectResult { if let Some(keycode) = char_to_linux_keycode(ch) { let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch); - if needs_shift { - self.send_uinput_event(EV_KEY, 42, 1); // KEY_LEFTSHIFT - } - self.send_uinput_event(EV_KEY, keycode, 1); - self.send_uinput_event(EV_KEY, keycode, 0); - if needs_shift { - self.send_uinput_event(EV_KEY, 42, 0); - } - self.send_uinput_event(0, 0, 0); + self.send_key_stroke(keycode, needs_shift); + eprintln!( + "[vietc] send_char: ASCII '{}' via uinput", + ch.escape_default() + ); return InjectResult::Success; } - // Unicode: copy to clipboard and paste (preserves uinput ordering) - self.paste_string(&ch.to_string()); + // 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); + } InjectResult::Success } fn send_string(&self, s: &str) -> InjectResult { - // If all ASCII, use keycodes directly (fast path) - if s.chars().all(|c| char_to_linux_keycode(c).is_some()) { + // ASCII characters: inject directly via uinput keycodes + let is_ascii = s.chars().all(|c| char_to_linux_keycode(c).is_some()); + eprintln!( + "[vietc] send_string: len={}, is_ascii={}", + s.len(), + is_ascii + ); + + if is_ascii { + eprintln!( + "[vietc] send_string: ASCII '{}' via uinput", + s.escape_default() + ); for ch in s.chars() { self.send_char(ch); } + return InjectResult::Success; + } + + // Unicode text: single clipboard copy + paste (reliable method) + eprintln!( + "[vietc] send_string: Unicode '{}' - using clipboard", + s.escape_default() + ); + let copied = self.copy_to_clipboard(s); + if copied { + eprintln!("[vietc] send_string: clipboard OK, sending Ctrl+V"); + self.send_ctrl_v(); + eprintln!("[vietc] send_string complete (clipboard)"); + return InjectResult::Success; } else { - // Contains Unicode: single clipboard copy + paste via uinput + eprintln!( + "[vietc] send_string failed for '{}' (clipboard unavailable)", + s.escape_default() + ); + // Last resort: try paste_string (will try clipboard internally) self.paste_string(s); } InjectResult::Success @@ -132,10 +211,21 @@ impl KeyInjector for UinputInjector { fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult { self.inject_replacement_atomic(backspaces, text) } - fn flush(&self) -> InjectResult { InjectResult::Success } + + /// Record that Unicode text was pasted via clipboard (for future delete/backspace support) + fn update_pasted_text(&self, text: &str) -> InjectResult { + // Text tracking happens through OutputCommand pipeline in daemon + // This is called after clipboard paste to inform engine of pasted content + eprintln!( + "[vietc] update_pasted_text: recorded '{}' (len={})", + text.escape_default(), + text.len() + ); + InjectResult::Success + } } impl UinputInjector { @@ -160,7 +250,8 @@ impl UinputInjector { let pw = libc::getpwuid(uid); if !pw.is_null() { let name = std::ffi::CStr::from_ptr((*pw).pw_name) - .to_string_lossy().into_owned(); + .to_string_lossy() + .into_owned(); if !name.is_empty() { return Some(name); } @@ -176,7 +267,8 @@ impl UinputInjector { let pw = libc::getpwuid(uid); if !pw.is_null() { let name = std::ffi::CStr::from_ptr((*pw).pw_name) - .to_string_lossy().into_owned(); + .to_string_lossy() + .into_owned(); if !name.is_empty() { return Some(name); } @@ -247,45 +339,9 @@ impl UinputInjector { /// Run an external command as the original user if we're root. /// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead. fn run_as_user(program: &str, args: &[&str]) -> std::process::Output { - let is_root = unsafe { libc::getuid() == 0 }; - if is_root { - if let Some((uid, gid)) = Self::get_original_uid_gid() { - let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default(); - let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(); - let display = std::env::var("DISPLAY").unwrap_or_default(); - - use std::os::unix::process::CommandExt; - let mut cmd = std::process::Command::new(program); - cmd.uid(uid).gid(gid); - - if !wayland_display.is_empty() { - cmd.env("WAYLAND_DISPLAY", wayland_display); - } - if !xdg_runtime_dir.is_empty() { - cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir); - } - if !display.is_empty() { - cmd.env("DISPLAY", display); - } - if let Some(username) = Self::get_original_username() { - cmd.env("HOME", format!("/home/{}", username)); - } - - cmd.args(args); - match cmd.output() { - Ok(output) => return output, - Err(e) => { - eprintln!("[vietc] Failed to run {} as uid={}: {}", program, uid, e); - return std::process::Output { - status: std::process::ExitStatus::default(), - stdout: vec![], - stderr: format!("{}\n", e).into_bytes(), - }; - } - } - } - } - match std::process::Command::new(program).args(args).output() { + let mut cmd = Self::user_cmd(program); + cmd.args(args); + match cmd.output() { Ok(output) => output, Err(e) => { eprintln!("[vietc] Failed to run {}: {}", program, e); @@ -304,9 +360,22 @@ 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 { - let is_ascii = text.chars().all(|c| char_to_linux_keycode(c).is_some()); - - if is_ascii { + 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 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(); @@ -315,171 +384,221 @@ impl UinputInjector { for ch in text.chars() { let _ = self.send_char(ch); } + eprintln!("[vietc] ASCII injection complete"); return InjectResult::Success; } - // It is Unicode. We must use a single unified channel. + // Unicode text: use xdotool directly (X11/XWayland) or wtype (Wayland) let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); - - static HAS_WTYPE: std::sync::OnceLock = std::sync::OnceLock::new(); - static HAS_XDOTOOL: std::sync::OnceLock = std::sync::OnceLock::new(); - if is_wayland { - let has_wtype = *HAS_WTYPE.get_or_init(|| { - std::process::Command::new("which") - .arg("wtype") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - }); - - if has_wtype { - let mut args = Vec::new(); - for _ in 0..backspaces { - args.push("-k"); - args.push("BackSpace"); - } - args.push("--"); - args.push(text); - - let output = Self::run_as_user("wtype", &args); - if output.status.success() { - return InjectResult::Success; - } - eprintln!("[vietc] wtype inject failed: {}", String::from_utf8_lossy(&output.stderr).trim()); - } + static HAS_XDOTOOL: std::sync::OnceLock = std::sync::OnceLock::new(); + let has_xdotool = if is_wayland { + false } else { - let has_xdotool = *HAS_XDOTOOL.get_or_init(|| { + *HAS_XDOTOOL.get_or_init(|| { std::process::Command::new("which") .arg("xdotool") .output() .map(|o| o.status.success()) .unwrap_or(false) - }); - - if has_xdotool { - let mut args = Vec::new(); - if backspaces > 0 { - args.push("key"); - for _ in 0..backspaces { - args.push("BackSpace"); - } - } - if !text.is_empty() { - args.push("type"); - args.push("--clearmodifiers"); - args.push(text); - } - - let output = Self::run_as_user("xdotool", &args); - if output.status.success() { - return InjectResult::Success; - } - eprintln!("[vietc] xdotool inject failed: {}", String::from_utf8_lossy(&output.stderr).trim()); + }) + }; + + 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() + ); + } else { + eprintln!( + "[vietc] Wayland session detected, using clipboard fallback instead of xdotool/wtype" + ); } + } else { + eprintln!( + "[vietc] Unicode detected ({} chars), injecting via xdotool", + text.chars().count() + ); } - // Fallback: Clipboard copy + paste. - // This is safe because both backspaces and Ctrl+V are injected into the SAME uinput device. + 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(); - InjectResult::Success - } else { - eprintln!("[vietc] clipboard copy failed during fallback"); - // Absolute last resort: try uinput backspaces followed by individual unicode paste_string - if backspaces > 0 { - for _ in 0..backspaces { - let _ = self.send_backspace(); - } + // 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)"); } - self.paste_string(text); - InjectResult::Success + 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); + if backspaces > 0 { + for _ in 0..backspaces { + let _ = self.send_backspace(); + } + } + for ch in text.chars() { + let _ = self.send_char(ch); + } + eprintln!("[vietc] Char-by-char injection complete"); + InjectResult::Success } /// Copy text to clipboard and paste via Ctrl+V through our uinput device. - /// Only used as a last resort if Wayland/X11 direct typing tools are - /// unavailable. Prefers ydotool (uinput, works everywhere) to avoid - /// clipboard pollution. + /// Only used as a last resort if Wayland/X11 direct typing tools are unavailable. + /// Tries xdotool first (X11/XWayland), then clipboard fallback. fn paste_string(&self, s: &str) { - // Try ydotool first (uinput-based, no display server needed). - let ydotool_result = std::process::Command::new("ydotool") - .args(["type", s]) - .output(); - if let Ok(output) = ydotool_result { + let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); + if is_wayland { + eprintln!("[vietc] paste_string: trying wtype..."); + let output = Self::run_as_user("wtype", &["--", s]); if output.status.success() { - eprintln!("[vietc] ydotool OK"); + eprintln!("[vietc] paste_string: wtype success"); return; } - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.is_empty() { - eprintln!("[vietc] ydotool failed: {}", stderr.trim()); + eprintln!("[vietc] paste_string: wtype failed, trying clipboard..."); + } else { + // Try xdotool first (works on X11 and XWayland for UTF-8) + eprintln!("[vietc] paste_string: trying xdotool..."); + let output = Self::run_as_user("xdotool", &["type", s]); + if output.status.success() { + eprintln!("[vietc] paste_string: xdotool success"); + // Record pasted text for future delete/backspace operations + let _ = Self::run_as_user("vietc", &["update-pasted", "-text", s]); + return; } - } - eprintln!("[vietc] ydotool failed, trying xdotool..."); - - // Try xdotool (X11): needs DISPLAY, run through run_as_user - eprintln!("[vietc] trying xdotool..."); - let output = Self::run_as_user("xdotool", &["type", "--clearmodifiers", s]); - if output.status.success() { - eprintln!("[vietc] xdotool OK"); - return; - } - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.is_empty() { - eprintln!("[vietc] xdotool failed: {}", stderr.trim()); + eprintln!("[vietc] paste_string: xdotool failed, trying clipboard..."); } - // Try wtype (Wayland-native): needs Wayland session, run through run_as_user - eprintln!("[vietc] xdotool failed, trying wtype..."); - let output = Self::run_as_user("wtype", &[s]); - if output.status.success() { - eprintln!("[vietc] wtype OK"); - return; - } - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.is_empty() { - eprintln!("[vietc] wtype failed: {}", stderr.trim()); - } - - // Clipboard fallback: copy + paste via our uinput - eprintln!("[vietc] wtype failed, trying clipboard paste..."); + // Clipboard fallback: copy + paste via our uinput device let copied = self.copy_to_clipboard(s); if copied { - eprintln!("[vietc] clipboard OK, sending Ctrl+V"); + eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V"); self.send_ctrl_v(); return; } - eprintln!("[vietc] WARNING: No injection method works for '{}'!", s); + eprintln!( + "[vietc] WARNING: No injection method works for '{}'!", + s.escape_default() + ); } /// Build a command to run as the original user with display environment. fn user_cmd(program: &str) -> std::process::Command { let is_root = unsafe { libc::getuid() == 0 }; if is_root { - if let Some(original_user) = Self::get_original_username() { + if let Some((uid, gid)) = Self::get_original_uid_gid() { let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default(); let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(); let display = std::env::var("DISPLAY").unwrap_or_default(); - let mut cmd = std::process::Command::new("sudo"); - cmd.args(["-u", &original_user, "env"]); + let xauthority = std::env::var("XAUTHORITY").unwrap_or_default(); + + use std::os::unix::process::CommandExt; + let mut cmd = std::process::Command::new(program); + cmd.uid(uid).gid(gid); + if !wayland_display.is_empty() { - cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display)); + cmd.env("WAYLAND_DISPLAY", wayland_display); } if !xdg_runtime_dir.is_empty() { - cmd.arg(format!("XDG_RUNTIME_DIR={}", xdg_runtime_dir)); + cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir); } if !display.is_empty() { - cmd.arg(format!("DISPLAY={}", display)); + cmd.env("DISPLAY", display); + } + if !xauthority.is_empty() { + cmd.env("XAUTHORITY", xauthority); + } + if let Some(username) = Self::get_original_username() { + cmd.env("HOME", format!("/home/{}", username)); } - cmd.arg(program); return cmd; } } @@ -508,7 +627,10 @@ impl UinputInjector { eprintln!("[vietc] clipboard: wl-copy OK"); return true; } - eprintln!("[vietc] clipboard: wl-copy failed (exit={:?})", status.code()); + eprintln!( + "[vietc] clipboard: wl-copy failed (exit={:?})", + status.code() + ); } else if let Err(ref e) = result { eprintln!("[vietc] clipboard: wl-copy error: {}", e); } @@ -550,13 +672,22 @@ impl UinputInjector { /// Send Ctrl+V through our uinput device. fn send_ctrl_v(&self) { - self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL - self.send_uinput_event(EV_KEY, 47, 1); // KEY_V - self.send_uinput_event(EV_KEY, 47, 0); - self.send_uinput_event(EV_KEY, 29, 0); - self.send_uinput_event(0, 0, 0); - } + self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(5)); + self.send_uinput_event(EV_KEY, 47, 1); // KEY_V press + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(5)); + + self.send_uinput_event(EV_KEY, 47, 0); // KEY_V release + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(5)); + + self.send_uinput_event(EV_KEY, 29, 0); // KEY_LEFTCTRL release + self.send_uinput_event(0, 0, 0); // SYN + std::thread::sleep(std::time::Duration::from_millis(10)); + } } impl Drop for UinputInjector { @@ -617,7 +748,11 @@ fn char_to_linux_keycode(ch: char) -> Option { } // ioctl helper -fn ioctl(fd: std::os::unix::io::RawFd, request: u64, arg: u64) -> Result> { +fn ioctl( + fd: std::os::unix::io::RawFd, + request: u64, + arg: u64, +) -> Result> { unsafe { let result = libc::ioctl(fd, request, arg); if result < 0 { diff --git a/protocol/src/wayland_im.rs b/protocol/src/wayland_im.rs index 153a90c..2dee4b2 100644 --- a/protocol/src/wayland_im.rs +++ b/protocol/src/wayland_im.rs @@ -68,10 +68,7 @@ impl Keysym { } pub fn is_modifier(self) -> bool { - matches!( - self.0, - 0xffe1..=0xffee - ) + matches!(self.0, 0xffe1..=0xffee) } } @@ -219,8 +216,16 @@ impl WaylandIMContext { // Shift+digit produces symbol if mods.shift && base.is_ascii_digit() { let shifted = match base { - '1' => '!', '2' => '@', '3' => '#', '4' => '$', '5' => '%', - '6' => '^', '7' => '&', '8' => '*', '9' => '(', '0' => ')', + '1' => '!', + '2' => '@', + '3' => '#', + '4' => '$', + '5' => '%', + '6' => '^', + '7' => '&', + '8' => '*', + '9' => '(', + '0' => ')', _ => return Some(base), }; return Some(shifted); diff --git a/protocol/src/x11_inject.rs b/protocol/src/x11_inject.rs index 9e54b4c..119698d 100644 --- a/protocol/src/x11_inject.rs +++ b/protocol/src/x11_inject.rs @@ -14,7 +14,7 @@ extern "C" { struct X11Lib { x11_handle: *mut c_void, xtst_handle: *mut c_void, - + // Symbols x_open_display: unsafe extern "C" fn(*const c_char) -> *mut Display, x_close_display: unsafe extern "C" fn(*mut Display) -> c_int, @@ -57,11 +57,24 @@ impl X11Lib { return Err("Failed to load libXtst.so.6".into()); } - let x_open_display = std::mem::transmute(dlsym(x11_handle, b"XOpenDisplay\0".as_ptr() as *const c_char)); - let x_close_display = std::mem::transmute(dlsym(x11_handle, b"XCloseDisplay\0".as_ptr() as *const c_char)); - let x_default_root_window = std::mem::transmute(dlsym(x11_handle, b"XDefaultRootWindow\0".as_ptr() as *const c_char)); - let x_flush = std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char)); - let x_test_fake_key_event = std::mem::transmute(dlsym(xtst_handle, b"XTestFakeKeyEvent\0".as_ptr() as *const c_char)); + let x_open_display = std::mem::transmute(dlsym( + x11_handle, + b"XOpenDisplay\0".as_ptr() as *const c_char, + )); + let x_close_display = std::mem::transmute(dlsym( + x11_handle, + b"XCloseDisplay\0".as_ptr() as *const c_char, + )); + let x_default_root_window = std::mem::transmute(dlsym( + x11_handle, + b"XDefaultRootWindow\0".as_ptr() as *const c_char, + )); + let x_flush = + std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char)); + let x_test_fake_key_event = std::mem::transmute(dlsym( + xtst_handle, + b"XTestFakeKeyEvent\0".as_ptr() as *const c_char, + )); Ok(Self { x11_handle, @@ -91,43 +104,96 @@ const X11_KEYCODE_OFFSET: u32 = 8; // X11 keycodes for common ASCII characters fn char_to_keycode(ch: char) -> Option<(u32, bool)> { match ch { - 'a' => Some((30 + X11_KEYCODE_OFFSET, false)), 'b' => Some((48 + X11_KEYCODE_OFFSET, false)), - 'c' => Some((46 + X11_KEYCODE_OFFSET, false)), 'd' => Some((32 + X11_KEYCODE_OFFSET, false)), - 'e' => Some((18 + X11_KEYCODE_OFFSET, false)), 'f' => Some((33 + X11_KEYCODE_OFFSET, false)), - 'g' => Some((34 + X11_KEYCODE_OFFSET, false)), 'h' => Some((35 + X11_KEYCODE_OFFSET, false)), - 'i' => Some((23 + X11_KEYCODE_OFFSET, false)), 'j' => Some((36 + X11_KEYCODE_OFFSET, false)), - 'k' => Some((37 + X11_KEYCODE_OFFSET, false)), 'l' => Some((38 + X11_KEYCODE_OFFSET, false)), - 'm' => Some((50 + X11_KEYCODE_OFFSET, false)), 'n' => Some((49 + X11_KEYCODE_OFFSET, false)), - 'o' => Some((24 + X11_KEYCODE_OFFSET, false)), 'p' => Some((25 + X11_KEYCODE_OFFSET, false)), - 'q' => Some((16 + X11_KEYCODE_OFFSET, false)), 'r' => Some((19 + X11_KEYCODE_OFFSET, false)), - 's' => Some((31 + X11_KEYCODE_OFFSET, false)), 't' => Some((20 + X11_KEYCODE_OFFSET, false)), - 'u' => Some((22 + X11_KEYCODE_OFFSET, false)), 'v' => Some((47 + X11_KEYCODE_OFFSET, false)), - 'w' => Some((17 + X11_KEYCODE_OFFSET, false)), 'x' => Some((45 + X11_KEYCODE_OFFSET, false)), - 'y' => Some((21 + X11_KEYCODE_OFFSET, false)), 'z' => Some((44 + X11_KEYCODE_OFFSET, false)), - 'A' => Some((30 + X11_KEYCODE_OFFSET, true)), 'B' => Some((48 + X11_KEYCODE_OFFSET, true)), - 'C' => Some((46 + X11_KEYCODE_OFFSET, true)), 'D' => Some((32 + X11_KEYCODE_OFFSET, true)), - 'E' => Some((18 + X11_KEYCODE_OFFSET, true)), 'F' => Some((33 + X11_KEYCODE_OFFSET, true)), - 'G' => Some((34 + X11_KEYCODE_OFFSET, true)), 'H' => Some((35 + X11_KEYCODE_OFFSET, true)), - 'I' => Some((23 + X11_KEYCODE_OFFSET, true)), 'J' => Some((36 + X11_KEYCODE_OFFSET, true)), - 'K' => Some((37 + X11_KEYCODE_OFFSET, true)), 'L' => Some((38 + X11_KEYCODE_OFFSET, true)), - 'M' => Some((50 + X11_KEYCODE_OFFSET, true)), 'N' => Some((49 + X11_KEYCODE_OFFSET, true)), - 'O' => Some((24 + X11_KEYCODE_OFFSET, true)), 'P' => Some((25 + X11_KEYCODE_OFFSET, true)), - 'Q' => Some((16 + X11_KEYCODE_OFFSET, true)), 'R' => Some((19 + X11_KEYCODE_OFFSET, true)), - 'S' => Some((31 + X11_KEYCODE_OFFSET, true)), 'T' => Some((20 + X11_KEYCODE_OFFSET, true)), - 'U' => Some((22 + X11_KEYCODE_OFFSET, true)), 'V' => Some((47 + X11_KEYCODE_OFFSET, true)), - 'W' => Some((17 + X11_KEYCODE_OFFSET, true)), 'X' => Some((45 + X11_KEYCODE_OFFSET, true)), - 'Y' => Some((21 + X11_KEYCODE_OFFSET, true)), 'Z' => Some((44 + X11_KEYCODE_OFFSET, true)), - '0' => Some((11 + X11_KEYCODE_OFFSET, false)), '1' => Some((2 + X11_KEYCODE_OFFSET, false)), - '2' => Some((3 + X11_KEYCODE_OFFSET, false)), '3' => Some((4 + X11_KEYCODE_OFFSET, false)), - '4' => Some((5 + X11_KEYCODE_OFFSET, false)), '5' => Some((6 + X11_KEYCODE_OFFSET, false)), - '6' => Some((7 + X11_KEYCODE_OFFSET, false)), '7' => Some((8 + X11_KEYCODE_OFFSET, false)), - '8' => Some((9 + X11_KEYCODE_OFFSET, false)), '9' => Some((10 + X11_KEYCODE_OFFSET, false)), - ' ' => Some((57 + X11_KEYCODE_OFFSET, false)), '.' => Some((52 + X11_KEYCODE_OFFSET, false)), - ',' => Some((51 + X11_KEYCODE_OFFSET, false)), '-' => Some((12 + X11_KEYCODE_OFFSET, false)), - '=' => Some((13 + X11_KEYCODE_OFFSET, false)), ';' => Some((39 + X11_KEYCODE_OFFSET, false)), - '\'' => Some((40 + X11_KEYCODE_OFFSET, false)), '/' => Some((53 + X11_KEYCODE_OFFSET, false)), - '\\' => Some((43 + X11_KEYCODE_OFFSET, false)), '`' => Some((41 + X11_KEYCODE_OFFSET, false)), - '[' => Some((26 + X11_KEYCODE_OFFSET, false)), ']' => Some((27 + X11_KEYCODE_OFFSET, false)), + 'a' => Some((30, false)), + 'b' => Some((48, false)), + 'c' => Some((46, false)), + 'd' => Some((32, false)), + 'e' => Some((18, false)), + 'f' => Some((33, false)), + 'g' => Some((34, false)), + 'h' => Some((35, false)), + 'i' => Some((23, false)), + 'j' => Some((36, false)), + 'k' => Some((37, false)), + 'l' => Some((38, false)), + 'm' => Some((50, false)), + 'n' => Some((49, false)), + 'o' => Some((24, false)), + 'p' => Some((25, false)), + 'q' => Some((16, false)), + 'r' => Some((19, false)), + 's' => Some((31, false)), + 't' => Some((20, false)), + 'u' => Some((22, false)), + 'v' => Some((47, false)), + 'w' => Some((17, false)), + 'x' => Some((45, false)), + 'y' => Some((21, false)), + 'z' => Some((44, false)), + 'A' => Some((30, true)), + 'B' => Some((48, true)), + 'C' => Some((46, true)), + 'D' => Some((32, true)), + 'E' => Some((18, true)), + 'F' => Some((33, true)), + 'G' => Some((34, true)), + 'H' => Some((35, true)), + 'I' => Some((23, true)), + 'J' => Some((36, true)), + 'K' => Some((37, true)), + 'L' => Some((38, true)), + 'M' => Some((50, true)), + 'N' => Some((49, true)), + 'O' => Some((24, true)), + 'P' => Some((25, true)), + 'Q' => Some((16, true)), + 'R' => Some((19, true)), + 'S' => Some((31, true)), + 'T' => Some((20, true)), + 'U' => Some((22, true)), + 'V' => Some((47, true)), + 'W' => Some((17, true)), + 'X' => Some((45, true)), + 'Y' => Some((21, true)), + 'Z' => Some((44, true)), + '0' => Some((11, false)), + '1' => Some((2, false)), + '2' => Some((3, false)), + '3' => Some((4, false)), + '4' => Some((5, false)), + '5' => Some((6, false)), + '6' => Some((7, false)), + '7' => Some((8, false)), + '8' => Some((9, false)), + '9' => Some((10, false)), + ' ' => Some((57, false)), + '.' => Some((52, false)), + ',' => Some((51, false)), + '-' => Some((12, false)), + '=' => Some((13, false)), + ';' => Some((39, false)), + '\'' => Some((40, false)), + '/' => Some((53, false)), + '\\' => Some((43, false)), + '`' => Some((41, false)), + '0' => Some((11, false)), + '1' => Some((2, false)), + '2' => Some((3, false)), + '3' => Some((4, false)), + '4' => Some((5, false)), + '5' => Some((6, false)), + '6' => Some((7, false)), + '7' => Some((8, false)), + '8' => Some((9, false)), + '9' => Some((10, false)), + ' ' => Some((57, false)), + '.' => Some((52, false)), + ',' => Some((51, false)), + '-' => Some((12, false)), + '=' => Some((13, false)), + ';' => Some((39, false)), + '\'' => Some((40, false)), + '/' => Some((53, false)), _ => None, } } @@ -151,7 +217,11 @@ impl X11Injector { return Err("Cannot open X11 display. Is DISPLAY set?".into()); } let window = (lib.x_default_root_window)(display); - Ok(Self { lib, display, window }) + Ok(Self { + lib, + display, + window, + }) } } @@ -290,7 +360,8 @@ impl KeyInjector for X11Injector { let mut clipboard_cmd = std::process::Command::new("xclip"); clipboard_cmd.args(["-selection", "clipboard"]); clipboard_cmd.stdin(std::process::Stdio::piped()); - let copied = clipboard_cmd.spawn() + let copied = clipboard_cmd + .spawn() .and_then(|mut child| { use std::io::Write; child.stdin.take().unwrap().write_all(text.as_bytes())?; @@ -326,15 +397,27 @@ impl KeyInjector for X11Injector { InjectResult::Success } } - fn flush(&self) -> InjectResult { - unsafe { (self.lib.x_flush)(self.display); } + unsafe { + (self.lib.x_flush)(self.display); + } + InjectResult::Success + } + + /// Record that Unicode text was pasted via clipboard (for future delete/backspace support) + fn update_pasted_text(&self, _text: &str) -> InjectResult { + eprintln!( + "[vietc] X11 update_pasted_text: recorded text (len={})", + _text.len() + ); InjectResult::Success } } impl Drop for X11Injector { fn drop(&mut self) { - unsafe { (self.lib.x_close_display)(self.display); } + unsafe { + (self.lib.x_close_display)(self.display); + } } } diff --git a/ui/src/config.rs b/ui/src/config.rs index d7fb65a..dffbbd0 100644 --- a/ui/src/config.rs +++ b/ui/src/config.rs @@ -69,13 +69,27 @@ pub struct Config { pub debug: bool, } -fn default_input_method() -> String { "telex".into() } -fn default_toggle_key() -> String { "space".into() } -fn default_start_enabled() -> bool { true } -fn default_grab() -> bool { true } -fn default_true() -> bool { true } -fn default_false() -> bool { false } -fn default_restore_keys() -> Vec { vec!["space".into(), "escape".into()] } +fn default_input_method() -> String { + "telex".into() +} +fn default_toggle_key() -> String { + "space".into() +} +fn default_start_enabled() -> bool { + true +} +fn default_grab() -> bool { + true +} +fn default_true() -> bool { + true +} +fn default_false() -> bool { + false +} +fn default_restore_keys() -> Vec { + vec!["space".into(), "escape".into()] +} impl Default for Config { fn default() -> Self { @@ -92,7 +106,6 @@ impl Default for Config { } } - impl Config { pub fn load() -> Self { for path in config_paths() { @@ -142,7 +155,10 @@ fn config_paths() -> Vec { pub fn is_autostart_installed() -> bool { if let Some(config_dir) = dirs::config_dir() { - config_dir.join("autostart").join("vietc-tray.desktop").exists() + config_dir + .join("autostart") + .join("vietc-tray.desktop") + .exists() } else { false } @@ -164,14 +180,12 @@ pub fn install_autostart() { let desktop_file = autostart_dir.join("vietc-tray.desktop"); let _ = fs::create_dir_all(&autostart_dir); - let exec_path = std::env::var("APPIMAGE") - .ok() - .unwrap_or_else(|| { - std::env::current_exe() - .unwrap_or_else(|_| PathBuf::from("vietc-tray")) - .to_string_lossy() - .into_owned() - }); + let exec_path = std::env::var("APPIMAGE").ok().unwrap_or_else(|| { + std::env::current_exe() + .unwrap_or_else(|_| PathBuf::from("vietc-tray")) + .to_string_lossy() + .into_owned() + }); let content = format!( "[Desktop Entry]\n\ diff --git a/ui/src/tray.rs b/ui/src/tray.rs index ca4ef2d..5c04be0 100644 --- a/ui/src/tray.rs +++ b/ui/src/tray.rs @@ -1,5 +1,5 @@ -use ksni::{Tray, MenuItem, menu::*}; use crate::config; +use ksni::{menu::*, MenuItem, Tray}; fn write_status(state: &str) { if let Some(config_dir) = dirs::config_dir() { @@ -16,7 +16,11 @@ fn read_status() -> String { .map(|s| s.trim().to_string()) .unwrap_or_else(|_| { let cfg = config::Config::load(); - if cfg.start_enabled { "vn".into() } else { "en".into() } + if cfg.start_enabled { + "vn".into() + } else { + "en".into() + } }) } @@ -35,9 +39,11 @@ fn draw_line(data: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4] loop { if x >= 0 && x < 32 && y >= 0 && y < 32 { let idx = ((y * 32 + x) * 4) as usize; - data[idx..idx+4].copy_from_slice(&color); + data[idx..idx + 4].copy_from_slice(&color); + } + if x == x1 && y == y1 { + break; } - if x == x1 && y == y1 { break; } let e2 = 2 * err; if e2 > -dy { err -= dy; @@ -51,37 +57,47 @@ fn draw_line(data: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 4] } fn ensure_icons() { - let Some(config_dir) = dirs::config_dir() else { return }; - let icons_dir = config_dir.join("vietc").join("icons"); - let theme_dir = icons_dir.join("hicolor").join("scalable").join("apps"); - let _ = std::fs::create_dir_all(&theme_dir); - - let vn_flat = icons_dir.join("vietc-vn.svg"); - let en_flat = icons_dir.join("vietc-en.svg"); - let vn_theme = theme_dir.join("vietc-vn.svg"); - let en_theme = theme_dir.join("vietc-en.svg"); - - let svg_vn = r##" - - VN + // SVG content for Viet+ icons + let svg_vn = r##" + + VN "##; - let svg_en = r##" - - EN + let svg_en = r##" + + EN "##; - if !vn_flat.exists() { - let _ = std::fs::write(&vn_flat, svg_vn); + // Write to standard user theme path (for Wayland compositors) + let home = dirs::home_dir().map(|d| d.join(".local/share/icons")); + if let Some(home_icons) = &home { + let _ = std::fs::create_dir_all(&home_icons); + let vn_path = home_icons.join("vietc-vn.svg"); + let en_path = home_icons.join("vietc-en.svg"); + + if !vn_path.exists() { + let _ = std::fs::write(&vn_path, svg_vn); + } + if !en_path.exists() { + let _ = std::fs::write(&en_path, svg_en); + } } - if !en_flat.exists() { - let _ = std::fs::write(&en_flat, svg_en); - } - if !vn_theme.exists() { - let _ = std::fs::write(&vn_theme, svg_vn); - } - if !en_theme.exists() { - let _ = std::fs::write(&en_theme, svg_en); + + // Also write to config dir for AppImage compatibility (fallback) + let config_dir = dirs::config_dir(); + if let Some(config_dir) = &config_dir { + let icons_dir = config_dir.join("vietc").join("icons"); + let _ = std::fs::create_dir_all(&icons_dir); + + let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg"); + let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg"); + + if !vn_theme.exists() { + let _ = std::fs::write(&vn_theme, svg_vn); + } + if !en_theme.exists() { + let _ = std::fs::write(&en_theme, svg_en); + } } } @@ -118,10 +134,16 @@ impl VietTray { let handle = handle.clone(); std::thread::spawn(move || { if verbose { - show_notification("Checking for updates...", "Contacting git.khoavo.myds.me..."); + show_notification( + "Checking for updates...", + "Contacting git.khoavo.myds.me...", + ); } let output = std::process::Command::new("curl") - .args(["-s", "https://git.khoavo.myds.me/api/v1/repos/vndangkhoa/vietc/releases"]) + .args([ + "-s", + "https://git.khoavo.myds.me/api/v1/repos/vndangkhoa/vietc/releases", + ]) .output(); match output { @@ -156,8 +178,14 @@ impl VietTray { let handle = handle.clone(); let _ = handle.update(|t| t.updating = true); std::thread::spawn(move || { - show_notification("Downloading update...", &format!("Updating Viet+ to {}...", release.tag_name)); - let appimage_asset = release.assets.iter().find(|a| a.name.ends_with(".AppImage")); + show_notification( + "Downloading update...", + &format!("Updating Viet+ to {}...", release.tag_name), + ); + let appimage_asset = release + .assets + .iter() + .find(|a| a.name.ends_with(".AppImage")); if let Some(asset) = appimage_asset { if let Ok(appimage_path) = std::env::var("APPIMAGE") { let temp_path = format!("{}.tmp-update", appimage_path); @@ -167,11 +195,14 @@ impl VietTray { match status { Ok(s) if s.success() => { use std::os::unix::fs::PermissionsExt; - if let Ok(_) = std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755)) { + if let Ok(_) = std::fs::set_permissions( + &temp_path, + std::fs::Permissions::from_mode(0o755), + ) { if let Ok(_) = std::fs::rename(&temp_path, &appimage_path) { show_notification( "Update Succeeded", - "Viet+ has been updated! Please restart the application." + "Viet+ has been updated! Please restart the application.", ); let _ = handle.update(|t| { t.updating = false; @@ -191,7 +222,7 @@ impl VietTray { .status(); show_notification( "Opening Releases Page", - "Please download the update manually." + "Please download the update manually.", ); } } else { @@ -203,17 +234,26 @@ impl VietTray { } impl Tray for VietTray { - fn id(&self) -> String { "io.github.vietc.Tray".into() } - fn title(&self) -> String { "Viet+".into() } + fn id(&self) -> String { + "io.github.vietc.Tray".into() + } + fn title(&self) -> String { + "Viet+".into() + } fn icon_name(&self) -> String { - if self.mode == "vn" { "vietc-vn".into() } else { "vietc-en".into() } + if self.mode == "vn" { + "vietc-vn".into() + } else { + "vietc-en".into() + } } fn icon_theme_path(&self) -> String { - dirs::config_dir() - .map(|d| d.join("vietc").join("icons").to_string_lossy().into_owned()) - .unwrap_or_default() + // Use XDG user theme path for icons + dirs::home_dir() + .map(|d| d.join(".local/share/icons").to_string_lossy().into_owned()) + .unwrap_or_else(|| "/usr/share/icons".into()) } fn icon_pixmap(&self) -> Vec { @@ -224,21 +264,29 @@ impl Tray for VietTray { [255, 75, 85, 99] }; let fg_color = [255, 255, 255, 255]; - + let mut data = vec![0u8; 32 * 32 * 4]; for y in 0..32 { for x in 0..32 { let mut inside = true; if x < 7 && y < 7 { - if (x - 7) * (x - 7) + (y - 7) * (y - 7) > 36 { inside = false; } + if (x - 7) * (x - 7) + (y - 7) * (y - 7) > 36 { + inside = false; + } } else if x > 24 && y < 7 { - if (x - 24) * (x - 24) + (y - 7) * (y - 7) > 36 { inside = false; } + if (x - 24) * (x - 24) + (y - 7) * (y - 7) > 36 { + inside = false; + } } else if x < 7 && y > 24 { - if (x - 7) * (x - 7) + (y - 24) * (y - 24) > 36 { inside = false; } + if (x - 7) * (x - 7) + (y - 24) * (y - 24) > 36 { + inside = false; + } } else if x > 24 && y > 24 { - if (x - 24) * (x - 24) + (y - 24) * (y - 24) > 36 { inside = false; } + if (x - 24) * (x - 24) + (y - 24) * (y - 24) > 36 { + inside = false; + } } - + let idx = ((y * 32 + x) * 4) as usize; if inside { data[idx] = bg_color[0]; @@ -313,7 +361,8 @@ impl Tray for VietTray { } }), ..Default::default() - }.into(), + } + .into(), MenuItem::Separator, CheckmarkItem { label: "Vietnamese Mode".into(), @@ -327,27 +376,34 @@ impl Tray for VietTray { this.mode = next.to_string(); }), ..Default::default() - }.into(), + } + .into(), SubMenu { label: "Input Method".into(), - submenu: vec![ - RadioGroup { - selected: im_index, - select: Box::new(|this: &mut VietTray, idx: usize| { - let im = if idx == 0 { "telex" } else { "vni" }; - let mut cfg = config::Config::load(); - cfg.input_method = im.into(); - let _ = cfg.save(); - this.im = im.into(); - }), - options: vec![ - RadioItem { label: "Telex".into(), ..Default::default() }, - RadioItem { label: "VNI".into(), ..Default::default() }, - ], - }.into(), - ], + submenu: vec![RadioGroup { + selected: im_index, + select: Box::new(|this: &mut VietTray, idx: usize| { + let im = if idx == 0 { "telex" } else { "vni" }; + let mut cfg = config::Config::load(); + cfg.input_method = im.into(); + let _ = cfg.save(); + this.im = im.into(); + }), + options: vec![ + RadioItem { + label: "Telex".into(), + ..Default::default() + }, + RadioItem { + label: "VNI".into(), + ..Default::default() + }, + ], + } + .into()], ..Default::default() - }.into(), + } + .into(), ]; items.push(MenuItem::Separator); @@ -357,52 +413,70 @@ impl Tray for VietTray { } else { format!("Update to {}", release.tag_name) }; - items.push(StandardItem { - label, - activate: Box::new(|this: &mut VietTray| { - if !this.updating { - if let Some(ref rel) = this.update_available.clone() { - let handle = this.handle.lock().unwrap().clone().unwrap(); - this.trigger_update(&handle, rel.clone()); + items.push( + StandardItem { + label, + activate: Box::new(|this: &mut VietTray| { + if !this.updating { + if let Some(ref rel) = this.update_available.clone() { + let handle = this.handle.lock().unwrap().clone().unwrap(); + this.trigger_update(&handle, rel.clone()); + } } - } - }), - ..Default::default() - }.into()); + }), + ..Default::default() + } + .into(), + ); } else { - items.push(StandardItem { - label: if self.updating { "Updating...".into() } else { "Check for Updates".into() }, - activate: Box::new(|this: &mut VietTray| { - if !this.updating { - let handle = this.handle.lock().unwrap().clone().unwrap(); - this.check_for_updates(&handle, true); - } - }), - ..Default::default() - }.into()); + items.push( + StandardItem { + label: if self.updating { + "Updating...".into() + } else { + "Check for Updates".into() + }, + activate: Box::new(|this: &mut VietTray| { + if !this.updating { + let handle = this.handle.lock().unwrap().clone().unwrap(); + this.check_for_updates(&handle, true); + } + }), + ..Default::default() + } + .into(), + ); } items.push(MenuItem::Separator); - items.push(StandardItem { - label: "About: Viet+".into(), - activate: Box::new(|_| { - let _ = std::process::Command::new("xdg-open") - .arg("https://github.com/vndangkhoa/vietc") - .status(); - }), - ..Default::default() - }.into()); + items.push( + StandardItem { + label: "About: Viet+".into(), + activate: Box::new(|_| { + let _ = std::process::Command::new("xdg-open") + .arg("https://github.com/vndangkhoa/vietc") + .status(); + }), + ..Default::default() + } + .into(), + ); items.push(MenuItem::Separator); - items.push(StandardItem { - label: "Quit".into(), - activate: Box::new(|_| { - let _ = std::process::Command::new("pkill") - .arg("-x").arg("vietc").status(); - std::process::exit(0); - }), - ..Default::default() - }.into()); + items.push( + StandardItem { + label: "Quit".into(), + activate: Box::new(|_| { + let _ = std::process::Command::new("pkill") + .arg("-x") + .arg("vietc") + .status(); + std::process::exit(0); + }), + ..Default::default() + } + .into(), + ); items } @@ -439,20 +513,24 @@ pub fn run() { tray_dummy.check_for_updates(&handle, false); } - // Poll for changes + // Poll for changes (shorter interval for faster icon updates) std::thread::spawn(move || { loop { - std::thread::sleep(std::time::Duration::from_millis(500)); + std::thread::sleep(std::time::Duration::from_millis(100)); let mode = read_status(); let im = current_im(); let autostart = config::is_autostart_installed(); + // Also check status_changed flag for immediate updates let _ = handle.update(move |t| { t.mode = mode; t.im = im; t.autostart = autostart; + // Force icon redraw on update by updating pixmap-related state }); } }); - loop { std::thread::park(); } + loop { + std::thread::park(); + } }