Fix TELEX ua-horn, word-spacing/control-key consumption, and clipboard preservation
Co-Authored-By: vndangkhoa <vonguyendangkhoa@gmail.com>
This commit is contained in:
parent
e34fbbc620
commit
a5bc2add40
4 changed files with 263 additions and 10 deletions
|
|
@ -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<char> {
|
|||
_ => 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<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 {}
|
||||
|
|
@ -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<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!((¤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.
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -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<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) {
|
||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||
if is_wayland {
|
||||
|
|
|
|||
Loading…
Reference in a new issue