From a5bc2add4072fdb4b527dd694f0dcb810321a616 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:10:48 +0000 Subject: [PATCH] Fix TELEX ua-horn, word-spacing/control-key consumption, and clipboard preservation Co-Authored-By: vndangkhoa --- daemon/src/main.rs | 108 ++++++++++++++++++++++++++++++++- engine/src/bamboo.rs | 41 +++++++++++++ protocol/src/uinput_monitor.rs | 76 ++++++++++++++++++++--- uinputd/src/main.rs | 48 ++++++++++++++- 4 files changed, 263 insertions(+), 10 deletions(-) diff --git a/daemon/src/main.rs b/daemon/src/main.rs index e68c170..378e351 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -921,6 +921,7 @@ fn run_with_evdev( if ch.is_ascii_alphabetic() && (shift ^ caps) { ch = ch.to_ascii_uppercase(); } + let buf_before = daemon.engine.buffer().chars().count(); let commands = daemon.process_key(ch); if !commands.is_empty() { consumed_keys.insert(keycode); @@ -933,8 +934,15 @@ fn run_with_evdev( } // Skip upcoming auto-repeat pile-up from injection delay skip_count = 3; - } else if is_vn_control_key(&daemon.config.input_method, ch) { - // Tone/mark key with no effect — consume silently + } else if is_vn_control_key(&daemon.config.input_method, ch) + && daemon.engine.buffer().chars().count() <= buf_before + { + // Tone/mark key truly absorbed with no effect (no + // literal character appended) — consume silently. + // When the key is instead kept as a literal base + // letter (e.g. leading "x", the "r" in "tr"), the + // buffer grows and we must forward it like any + // other character so it reaches the screen. consumed_keys.insert(keycode); } else { injector.send_key_event(keycode, 1); @@ -1250,3 +1258,99 @@ fn key_to_char(key: evdev::Key) -> Option { _ => None, } } + +#[cfg(test)] +mod grab_render_tests { + //! Models the grab-mode keystroke loop (the `value == 1` branch of + //! `run_with_evdev`) against a real engine, rendering the resulting + //! on-screen text. This exercises both the engine composition and the + //! daemon's decision of when to forward a raw key vs. consume it. + use super::*; + + fn event_to_commands(event: Option) -> Vec { + let mut commands = Vec::new(); + if let Some(event) = event { + match event { + EngineEvent::Flush(text) | EngineEvent::Insert(text) | EngineEvent::Paste(text) => { + commands.push(OutputCommand::Type(text)); + } + EngineEvent::AutoRestore(word) => { + commands.push(OutputCommand::Backspace(word.chars().count())); + commands.push(OutputCommand::Type(word)); + } + EngineEvent::Replace { backspaces, insert } => { + commands.push(OutputCommand::Backspace(backspaces)); + commands.push(OutputCommand::Type(insert)); + } + EngineEvent::UndoTones { backspaces, restored } => { + commands.push(OutputCommand::Backspace(backspaces)); + commands.push(OutputCommand::Type(restored)); + } + } + } + commands + } + + /// Render keystrokes exactly as the grab-mode loop would put them on screen. + fn render(method_str: &str, keys: &str) -> String { + let method = match method_str { + "vni" => InputMethod::Vni, + _ => InputMethod::Telex, + }; + let mut engine = Engine::new(method); + engine.set_enabled(true); + engine.set_auto_restore(true); + + let mut screen: Vec = Vec::new(); + for ch in keys.chars() { + let buf_before = engine.buffer().chars().count(); + let commands = event_to_commands(engine.process_key(ch)); + if !commands.is_empty() { + for cmd in &commands { + match cmd { + OutputCommand::Backspace(n) => { + for _ in 0..*n { + screen.pop(); + } + } + OutputCommand::Type(text) => screen.extend(text.chars()), + } + } + if is_flush_char(ch) { + screen.push(ch); + } + } else if is_vn_control_key(method_str, ch) + && engine.buffer().chars().count() <= buf_before + { + // consumed silently + } else { + screen.push(ch); + } + } + screen.into_iter().collect() + } + + #[test] + fn leading_control_letters_are_kept() { + // "x" tone key as a leading consonant must survive. + assert_eq!(render("telex", "xuaw"), "xưa"); + // "r" inside the "tr" initial cluster must not be eaten as a tone. + assert_eq!(render("telex", "trong"), "trong"); + // "r" as a real word-initial consonant. + assert_eq!(render("telex", "ruwngf"), "rừng"); + } + + #[test] + fn spaces_between_words_are_preserved() { + assert_eq!(render("telex", "Ngayf xuaw"), "Ngày xưa"); + assert_eq!(render("telex", "khu ruwngf raamj"), "khu rừng rậm"); + assert_eq!(render("telex", "con Voi raats"), "con Voi rất"); + } + + #[test] + fn full_sentence_renders_correctly() { + let keys = "Ngayf xuaw, trong mootj khu ruwngf raamj cos mootj con Voi raats hung duwx."; + let expected = "Ngày xưa, trong một khu rừng rậm có một con Voi rất hung dữ."; + assert_eq!(render("telex", keys), expected); + } +} diff --git a/engine/src/bamboo.rs b/engine/src/bamboo.rs index 4406c37..7ef2474 100644 --- a/engine/src/bamboo.rs +++ b/engine/src/bamboo.rs @@ -127,6 +127,30 @@ impl BambooEngine { } } } + + // Smart "ua" → "ưa": the horn goes on the u (xưa, chưa, mưa, lửa), + // not the breve on the a ("xuă" is not a valid syllable). Skip the + // "qu" glide case, where the u belongs to the initial consonant and + // the a takes the breve instead (quă → quăng). + if self.composition.len() >= 2 { + let a_idx = self.composition.len() - 1; + let u_idx = a_idx - 1; + let a_ch = self.composition[a_idx].base_char.to_ascii_lowercase(); + let u_ch = self.composition[u_idx].base_char.to_ascii_lowercase(); + let preceded_by_q = u_idx > 0 + && self.composition[u_idx - 1] + .base_char + .eq_ignore_ascii_case(&'q'); + if a_ch == 'a' + && u_ch == 'u' + && self.composition[u_idx].mark_applied.is_none() + && !preceded_by_q + { + self.composition[u_idx].base_char = 'ư'; + self.composition[u_idx].mark_applied = Some('ư'); + return Some(self.flatten()); + } + } } // Try mark rules with flexible backtrack" (scan up to 3 chars backward) @@ -580,6 +604,23 @@ fn test_telex_gios() { + #[test] + fn test_telex_ua_horn() { + // "w" after a "ua" cluster puts the horn on the u (ưa), it must not + // put the breve on the a ("xuă" is not a valid Vietnamese syllable). + assert_eq!(process(InputMethod::Telex, "xuaw"), "xưa"); + assert_eq!(process(InputMethod::Telex, "chuaw"), "chưa"); + assert_eq!(process(InputMethod::Telex, "muaw"), "mưa"); + assert_eq!(process(InputMethod::Telex, "Xuaw"), "Xưa"); + // With a following tone the horn target still carries the tone. + assert_eq!(process(InputMethod::Telex, "luawr"), "lửa"); + // "qu" glide exception: the u belongs to the initial, a takes the breve. + assert_eq!(process(InputMethod::Telex, "quawng"), "quăng"); + // VNI parity. + assert_eq!(process(InputMethod::Vni, "xua7"), "xưa"); + assert_eq!(process(InputMethod::Vni, "qua8ng"), "quăng"); + } + #[test] fn test_telex_r_as_normal_char() { let mut e = BambooEngine::new(InputMethod::Telex); diff --git a/protocol/src/uinput_monitor.rs b/protocol/src/uinput_monitor.rs index 2c84ded..d78e070 100644 --- a/protocol/src/uinput_monitor.rs +++ b/protocol/src/uinput_monitor.rs @@ -18,6 +18,12 @@ const KEY_MAX: u32 = 0x1ff; pub struct UinputInjector { file: File, + /// The user's real clipboard contents, saved before we overwrite the + /// clipboard to inject Unicode text, so we can restore it afterwards. + saved_clipboard: std::sync::Mutex>, + /// The last text we injected via the clipboard. Used to tell our own + /// injected text apart from text the user copied with Ctrl+C. + last_injected: std::sync::Mutex>, } unsafe impl Send for UinputInjector {} @@ -72,7 +78,11 @@ impl UinputInjector { // Small delay for device to be ready std::thread::sleep(std::time::Duration::from_millis(10)); - Ok(Self { file }) + Ok(Self { + file, + saved_clipboard: std::sync::Mutex::new(None), + last_injected: std::sync::Mutex::new(None), + }) } fn send_uinput_event(&self, type_: u16, code: u16, value: i32) { @@ -180,10 +190,8 @@ impl KeyInjector for UinputInjector { "[vietc] send_string: Unicode '{}' - using clipboard", s.escape_default() ); - let copied = self.copy_to_clipboard(s); + let copied = self.paste_via_clipboard(s, false); 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 { @@ -365,13 +373,67 @@ impl UinputInjector { if backspaces > 0 { for _ in 0..backspaces { let _ = self.send_backspace(); } } - if self.copy_to_clipboard(text) { - self.send_ctrl_v_x11(); - } + self.paste_via_clipboard(text, true); InjectResult::Success } + /// Read the user's current clipboard contents (wl-paste on Wayland, xclip + /// on X11). Returns None if no clipboard tool is available or it is empty. + fn read_clipboard(&self) -> Option { + let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); + let (prog, args): (&str, &[&str]) = if is_wayland { + ("wl-paste", &["-n"]) + } else { + ("xclip", &["-selection", "clipboard", "-o"]) + }; + let mut cmd = Self::user_cmd(prog); + cmd.args(args); + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + /// Inject Unicode `text` by placing it on the clipboard and sending Ctrl+V, + /// while preserving the user's own clipboard contents. Without this, every + /// Vietnamese word the user types would overwrite whatever they had copied + /// with Ctrl+C, so a subsequent Ctrl+V would paste the wrong thing. + /// + /// Returns whether the text was successfully copied to the clipboard. + fn paste_via_clipboard(&self, text: &str, use_x11_paste: bool) -> bool { + // Snapshot the clipboard. If it differs from what we last injected, the + // user changed it themselves (a real Ctrl+C), so remember it to restore. + let current = self.read_clipboard(); + { + let last = self.last_injected.lock().unwrap(); + let is_our_injection = matches!((¤t, &*last), (Some(c), Some(l)) if c == l); + if !is_our_injection { + *self.saved_clipboard.lock().unwrap() = current; + } + } + + if !self.copy_to_clipboard(text) { + return false; + } + if use_x11_paste { + self.send_ctrl_v_x11(); + } else { + self.send_ctrl_v(); + } + + // Restore the user's clipboard once the paste has been consumed. The + // extra delay gives the target application time to read our text from + // the clipboard before we overwrite it again. + std::thread::sleep(std::time::Duration::from_millis(40)); + let saved = self.saved_clipboard.lock().unwrap().clone(); + let restored = saved.unwrap_or_default(); + let _ = self.copy_to_clipboard(&restored); + *self.last_injected.lock().unwrap() = Some(restored); + true + } + /// 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. /// Tries xdotool first (X11/XWayland), then clipboard fallback. diff --git a/uinputd/src/main.rs b/uinputd/src/main.rs index 0938fde..0d5f1ab 100644 --- a/uinputd/src/main.rs +++ b/uinputd/src/main.rs @@ -47,6 +47,12 @@ struct input_id { struct UinputDevice { fd: i32, + /// The user's real clipboard contents, saved before we overwrite the + /// clipboard to paste Unicode text, so we can restore it afterwards. + saved_clipboard: std::sync::Mutex>, + /// The last text we injected via the clipboard, used to distinguish our + /// own paste content from text the user copied with Ctrl+C. + last_injected: std::sync::Mutex>, } impl UinputDevice { @@ -83,7 +89,11 @@ impl UinputDevice { std::thread::sleep(std::time::Duration::from_millis(10)); eprintln!("[vietc-uinputd] Device '{}' created", name); - Ok(Self { fd }) + Ok(Self { + fd, + saved_clipboard: std::sync::Mutex::new(None), + last_injected: std::sync::Mutex::new(None), + }) } fn send_event(&self, type_: u16, code: u16, value: i32) { @@ -153,6 +163,19 @@ impl UinputDevice { } fn paste_unicode(&self, text: &str) { + // Save the user's clipboard before we clobber it, unless what is on the + // clipboard is our own previously-injected text. This keeps Ctrl+C / + // Ctrl+V working: every Vietnamese word is pasted via the clipboard, so + // without restoring it the user's copied content would be lost. + let current = read_clipboard(); + { + let last = self.last_injected.lock().unwrap(); + let is_our_injection = matches!((¤t, &*last), (Some(c), Some(l)) if c == l); + if !is_our_injection { + *self.saved_clipboard.lock().unwrap() = current; + } + } + copy_to_clipboard(text); self.send_key(29, 1); std::thread::sleep(std::time::Duration::from_millis(2)); @@ -160,6 +183,13 @@ impl UinputDevice { self.send_key(47, 0); self.send_key(29, 0); std::thread::sleep(std::time::Duration::from_millis(10)); + + // Restore the user's clipboard after the paste has been consumed. + std::thread::sleep(std::time::Duration::from_millis(30)); + let saved = self.saved_clipboard.lock().unwrap().clone(); + let restored = saved.unwrap_or_default(); + copy_to_clipboard(&restored); + *self.last_injected.lock().unwrap() = Some(restored); } } @@ -171,6 +201,22 @@ impl Drop for UinputDevice { } } +fn read_clipboard() -> Option { + let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); + let output = if is_wayland { + Command::new("wl-paste").arg("-n").output() + } else { + Command::new("xclip") + .args(["-selection", "clipboard", "-o"]) + .output() + }; + let output = output.ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + fn copy_to_clipboard(text: &str) { let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); if is_wayland {