Merge pull request #5 from vndangkhoa/devin/1782475848-telex-spacing-clipboard

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: vndangkhoa <vonguyendangkhoa@gmail.com>
This commit is contained in:
vndangkhoa 2026-06-26 19:12:17 +07:00 committed by GitHub
commit 01ba0c7dde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 263 additions and 10 deletions

View file

@ -921,6 +921,7 @@ fn run_with_evdev(
if ch.is_ascii_alphabetic() && (shift ^ caps) { if ch.is_ascii_alphabetic() && (shift ^ caps) {
ch = ch.to_ascii_uppercase(); ch = ch.to_ascii_uppercase();
} }
let buf_before = daemon.engine.buffer().chars().count();
let commands = daemon.process_key(ch); let commands = daemon.process_key(ch);
if !commands.is_empty() { if !commands.is_empty() {
consumed_keys.insert(keycode); consumed_keys.insert(keycode);
@ -933,8 +934,15 @@ fn run_with_evdev(
} }
// Skip upcoming auto-repeat pile-up from injection delay // Skip upcoming auto-repeat pile-up from injection delay
skip_count = 3; skip_count = 3;
} else if is_vn_control_key(&daemon.config.input_method, ch) { } else if is_vn_control_key(&daemon.config.input_method, ch)
// Tone/mark key with no effect — consume silently && 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); consumed_keys.insert(keycode);
} else { } else {
injector.send_key_event(keycode, 1); injector.send_key_event(keycode, 1);
@ -1250,3 +1258,99 @@ fn key_to_char(key: evdev::Key) -> Option<char> {
_ => None, _ => 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<EngineEvent>) -> Vec<OutputCommand> {
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<char> = 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);
}
}

View file

@ -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) // 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] #[test]
fn test_telex_r_as_normal_char() { fn test_telex_r_as_normal_char() {
let mut e = BambooEngine::new(InputMethod::Telex); let mut e = BambooEngine::new(InputMethod::Telex);

View file

@ -18,6 +18,12 @@ const KEY_MAX: u32 = 0x1ff;
pub struct UinputInjector { pub struct UinputInjector {
file: File, 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<Option<String>>,
/// 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<Option<String>>,
} }
unsafe impl Send for UinputInjector {} unsafe impl Send for UinputInjector {}
@ -72,7 +78,11 @@ impl UinputInjector {
// Small delay for device to be ready // Small delay for device to be ready
std::thread::sleep(std::time::Duration::from_millis(10)); 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) { 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", "[vietc] send_string: Unicode '{}' - using clipboard",
s.escape_default() s.escape_default()
); );
let copied = self.copy_to_clipboard(s); let copied = self.paste_via_clipboard(s, false);
if copied { if copied {
eprintln!("[vietc] send_string: clipboard OK, sending Ctrl+V");
self.send_ctrl_v();
eprintln!("[vietc] send_string complete (clipboard)"); eprintln!("[vietc] send_string complete (clipboard)");
return InjectResult::Success; return InjectResult::Success;
} else { } else {
@ -365,13 +373,67 @@ impl UinputInjector {
if backspaces > 0 { if backspaces > 0 {
for _ in 0..backspaces { let _ = self.send_backspace(); } for _ in 0..backspaces { let _ = self.send_backspace(); }
} }
if self.copy_to_clipboard(text) { self.paste_via_clipboard(text, true);
self.send_ctrl_v_x11();
}
InjectResult::Success 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<String> {
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!((&current, &*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. /// 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. /// Only used as a last resort if Wayland/X11 direct typing tools are unavailable.
/// Tries xdotool first (X11/XWayland), then clipboard fallback. /// Tries xdotool first (X11/XWayland), then clipboard fallback.

View file

@ -47,6 +47,12 @@ struct input_id {
struct UinputDevice { struct UinputDevice {
fd: i32, 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<Option<String>>,
/// 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<Option<String>>,
} }
impl UinputDevice { impl UinputDevice {
@ -83,7 +89,11 @@ impl UinputDevice {
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
eprintln!("[vietc-uinputd] Device '{}' created", name); 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) { fn send_event(&self, type_: u16, code: u16, value: i32) {
@ -153,6 +163,19 @@ impl UinputDevice {
} }
fn paste_unicode(&self, text: &str) { 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!((&current, &*last), (Some(c), Some(l)) if c == l);
if !is_our_injection {
*self.saved_clipboard.lock().unwrap() = current;
}
}
copy_to_clipboard(text); copy_to_clipboard(text);
self.send_key(29, 1); self.send_key(29, 1);
std::thread::sleep(std::time::Duration::from_millis(2)); std::thread::sleep(std::time::Duration::from_millis(2));
@ -160,6 +183,13 @@ impl UinputDevice {
self.send_key(47, 0); self.send_key(47, 0);
self.send_key(29, 0); self.send_key(29, 0);
std::thread::sleep(std::time::Duration::from_millis(10)); 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<String> {
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) { fn copy_to_clipboard(text: &str) {
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
if is_wayland { if is_wayland {