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) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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!((¤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.
|
/// 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.
|
||||||
|
|
|
||||||
|
|
@ -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!((¤t, &*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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue