fix: X11 key lookup, bamboo engine port, uinput injection overhaul

- Fix Xutf8LookupString signature (missing XIC param caused all keys to map to \0)
- Port bamboo-core Vietnamese engine to Rust (bamboo.rs, input_method.rs)
- Flexible backtracking for mark/tone keys (scan up to 5 chars back)
- Correct tone placement for io, uâ, yê clusters
- Evdev capture preferred over X11 XRecord (more reliable)
- Uinput injection with correct Linux keycodes
- Vietnamese Unicode via clipboard paste + trailing ASCII via uinput
- Persistent X11 connection for Ctrl+V (no per-call dlopen overhead)
- Consume stale VNI/Telex control keys when no match found
- Fix execute_commands backspace count for evdev grabbing path
- Add vietc-uinputd privileged injection daemon
- AppImage: bundle uinputd, preserve LD_LIBRARY_PATH, fix xrecord build flags
- Remove old generated test files, add 63 focused engine tests
This commit is contained in:
Khoa Vo 2026-06-26 15:20:03 +07:00
parent ea5df93bce
commit d4102088b8
18 changed files with 1411 additions and 3923 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ packaging/appimage/AppDir/
packaging/deb/vietc_*/ packaging/deb/vietc_*/
packaging/appimage/appimagetool packaging/appimage/appimagetool
status status
vietc-xrecord

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["engine", "protocol", "daemon", "cli"] members = ["engine", "protocol", "daemon", "cli", "uinputd"]
exclude = ["ui"] exclude = ["ui"]
[workspace.dependencies] [workspace.dependencies]

View file

@ -494,29 +494,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
#[cfg(feature = "x11")] // Try evdev first (more reliable than X11 XRecord)
if display != display::DisplayServer::Wayland {
if let Some(capture) = X11Capture::new() {
// XRecord captures events globally — no grab needed for capture.
// XGrabKeyboard on the same display as XRecord breaks event delivery.
log_info("[vietc] X11 XRecord capture active — using X11 capture/injection");
return run_with_x11(
capture,
&mut daemon,
shared_active_window,
config_changed,
status_changed,
engine_enabled,
);
} else {
log_info("[vietc] X11 not available, falling back to evdev");
}
}
match open_keyboard_device() { match open_keyboard_device() {
Ok((device, path)) => { Ok((device, path)) => {
log_info(&format!("[vietc] Keyboard device: {}", path)); log_info(&format!("[vietc] Keyboard device: {}", path));
run_with_evdev( return run_with_evdev(
device, device,
&mut daemon, &mut daemon,
shared_active_window, shared_active_window,
@ -524,10 +506,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
status_changed, status_changed,
engine_enabled, engine_enabled,
display, display,
)?; );
} }
Err(e) => { Err(e) => {
log_info(&format!("[vietc] No keyboard device: {}", e)); log_info(&format!("[vietc] evdev not available: {}", e));
}
}
#[cfg(feature = "x11")]
if display != display::DisplayServer::Wayland {
if let Some(capture) = X11Capture::new() {
log_info("[vietc] X11 XRecord capture active — using X11 capture/injection");
return run_with_x11(
capture,
&mut daemon,
shared_active_window.clone(),
config_changed.clone(),
status_changed.clone(),
engine_enabled.clone(),
);
} else {
log_info("[vietc] X11 not available, falling back");
}
}
log_info("[vietc] Running in stdin test mode"); log_info("[vietc] Running in stdin test mode");
run_stdin_mode( run_stdin_mode(
&mut daemon, &mut daemon,
@ -537,8 +539,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
engine_enabled, engine_enabled,
display, display,
)?; )?;
}
}
Ok(()) Ok(())
} }
@ -898,7 +898,10 @@ fn run_with_evdev(
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);
execute_commands(&*injector, &commands, true); execute_commands(&*injector, &commands, false);
} else if is_vn_control_key(&daemon.config.input_method, ch) {
// Tone/mark key with no effect — consume silently
consumed_keys.insert(keycode);
} else { } else {
injector.send_key_event(keycode, 1); injector.send_key_event(keycode, 1);
} }
@ -1074,17 +1077,15 @@ fn execute_commands(
fn create_injector( fn create_injector(
display: display::DisplayServer, display: display::DisplayServer,
) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> { ) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
// Try Wayland input method first (if compiled with wayland feature) // Try uinputd socket first
#[cfg(feature = "wayland")] if vietc_protocol::uinput_client::UinputClient::is_available() {
{ log_info("[vietc] Using uinputd socket injection");
let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new(); return Ok(Box::new(vietc_protocol::uinput_client::UinputClient));
log_info("[vietc] Wayland input method context initialized");
} }
// Use uinput as primary injector — it handles ASCII via direct keycodes // Use uinput as primary — correct Linux keycodes for ASCII + backspace.
// and Unicode via ydotool type (uinput-based, no display server needed). // For Unicode (Vietnamese diacritics), falls back to xclip via subprocess
// Using a single injection channel avoids ordering issues between XTest // or direct X11 clipboard via X11Injector.
// (ASCII) and ydotool (Unicode) interleaving.
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") { match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
Ok(injector) => { Ok(injector) => {
log_info("[vietc] Using uinput injection (primary)"); log_info("[vietc] Using uinput injection (primary)");
@ -1095,13 +1096,13 @@ fn create_injector(
} }
} }
// Fall back to X11 XTEST (last resort — doesn't handle Unicode well) // Fall back to X11 injection (only if uinput fails)
#[cfg(feature = "x11")] #[cfg(feature = "x11")]
{ {
if display != display::DisplayServer::Wayland { if display != display::DisplayServer::Wayland {
match vietc_protocol::x11_inject::X11Injector::new() { match vietc_protocol::x11_inject::X11Injector::new() {
Ok(injector) => { Ok(injector) => {
log_info("[vietc] Using X11 injection (XTEST fallback)"); log_info("[vietc] Using X11 injection (fallback)");
return Ok(Box::new(injector)); return Ok(Box::new(injector));
} }
Err(e) => { Err(e) => {
@ -1114,6 +1115,14 @@ fn create_injector(
Err("No injection backend available".into()) Err("No injection backend available".into())
} }
fn is_vn_control_key(method: &str, ch: char) -> bool {
match method {
"telex" => matches!(ch, 'f' | 's' | 'r' | 'x' | 'j' | 'w' | 'a' | 'e' | 'o' | 'd' | 'u' | 'F' | 'S' | 'R' | 'X' | 'J' | 'W' | 'A' | 'E' | 'O' | 'D' | 'U'),
"vni" => matches!(ch, '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0'),
_ => false,
}
}
fn is_modifier_pressed(key_state: &evdev::AttributeSet<evdev::Key>) -> bool { fn is_modifier_pressed(key_state: &evdev::AttributeSet<evdev::Key>) -> bool {
key_state.contains(evdev::Key::KEY_LEFTCTRL) key_state.contains(evdev::Key::KEY_LEFTCTRL)
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL) || key_state.contains(evdev::Key::KEY_RIGHTCTRL)

531
engine/src/bamboo.rs Normal file
View file

@ -0,0 +1,531 @@
use crate::input_method::{InputMethod, InputMethodRules, get_rules};
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Transformation {
base_char: char,
mark_applied: Option<char>,
tone_applied: Option<char>,
is_upper: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Vietnamese,
English,
}
impl Mode {
fn is_vn(self) -> bool { matches!(self, Mode::Vietnamese) }
}
pub struct BambooEngine {
composition: Vec<Transformation>,
rules: InputMethodRules,
mode: Mode,
macros: HashMap<String, String>,
macro_buf: String,
}
impl BambooEngine {
pub fn new(method: InputMethod) -> Self {
Self {
composition: Vec::new(),
rules: get_rules(method),
mode: Mode::Vietnamese,
macros: HashMap::new(),
macro_buf: String::new(),
}
}
pub fn set_method(&mut self, method: InputMethod) {
self.rules = get_rules(method);
self.reset();
}
pub fn set_enabled(&mut self, enabled: bool) {
self.mode = if enabled { Mode::Vietnamese } else { Mode::English };
if !enabled { self.reset(); }
}
pub fn is_enabled(&self) -> bool {
self.mode.is_vn()
}
pub fn add_macro(&mut self, shortcut: String, expansion: String) {
self.macros.insert(shortcut, expansion);
}
pub fn clear_macros(&mut self) {
self.macros.clear();
}
pub fn reset(&mut self) {
self.composition.clear();
self.macro_buf.clear();
}
pub fn is_empty(&self) -> bool {
self.composition.is_empty()
}
pub fn process_key(&mut self, ch: char) -> Option<String> {
if !self.mode.is_vn() {
return Some(ch.to_string());
}
let lower = ch.to_ascii_lowercase();
// Check macros
self.macro_buf.push(lower);
for (shortcut, expansion) in &self.macros.clone() {
if self.macro_buf.ends_with(shortcut) {
self.macro_buf.clear();
self.reset();
return Some(expansion.clone());
}
}
if self.macro_buf.len() > 50 {
self.macro_buf.clear();
}
// Check tone keys
if let Some(&(tone_char, _tone_name)) = self.rules.tone_keys.get(&lower) {
return self.apply_tone(tone_char);
}
// Smart "uo" → "ươ" shortcut with flexible backtrack:
// Scan backward through consonants to find the "uo" pair
if self.rules.method == InputMethod::Telex && lower == 'w'
|| self.rules.method == InputMethod::Vni && lower == '7'
{
if self.composition.len() >= 2 {
for offset in 0..5usize.min(self.composition.len() - 1) {
let o_idx = self.composition.len() - 1 - offset;
let o_ch = self.composition[o_idx].base_char.to_ascii_lowercase();
if o_ch == 'o' && o_idx > 0 {
let u_ch = self.composition[o_idx - 1].base_char.to_ascii_lowercase();
if u_ch == 'u' {
// Found "uo" pair, replace with "ươ"
let u_idx = o_idx - 1;
let old_tone_o = self.composition[o_idx].tone_applied;
let was_upper = self.composition[u_idx].is_upper;
self.composition.drain(u_idx..=o_idx);
self.composition.insert(u_idx, Transformation { base_char: 'ư', mark_applied: Some('ư'), tone_applied: old_tone_o, is_upper: was_upper });
self.composition.insert(u_idx + 1, Transformation { base_char: 'ơ', mark_applied: Some('ơ'), tone_applied: None, is_upper: false });
return Some(self.flatten());
}
}
if o_ch == 'u' || is_vowel(o_ch) {
break; // Stop at vowel boundary
}
}
}
}
// Try mark rules with flexible backtrack (scan up to 3 chars backward)
let mark_match = self.find_mark_backtrack(lower);
if let Some((idx, pattern, result)) = mark_match {
self.apply_mark_at(idx, &pattern, &result);
return Some(self.flatten());
}
// Normal character — append
self.append_char(ch);
self.macro_buf.clear();
Some(self.flatten())
}
fn find_mark_backtrack(&self, lower: char) -> Option<(usize, String, String)> {
let scan_limit = 5usize.min(self.composition.len());
for offset in 0..scan_limit {
let idx = self.composition.len() - 1 - offset;
let ch = self.composition[idx].base_char.to_ascii_lowercase();
let seq = format!("{}{}", ch, lower);
if let Some((p, r)) = self.rules.mark_rules.iter().find(|(p, _)| seq == *p) {
return Some((idx, p.clone(), r.clone()));
}
}
None
}
fn is_tone_or_mark_key(&self, lower: char) -> bool {
self.rules.tone_keys.contains_key(&lower)
|| self.rules.mark_rules.iter().any(|(p, _)| p.ends_with(lower))
}
fn apply_mark_at(&mut self, idx: usize, _pattern: &str, result: &str) {
let result_chars: Vec<char> = result.chars().collect();
let was_upper = self.composition[idx].is_upper;
let old_tone = self.composition[idx].tone_applied;
// Replace the char at idx with result chars
self.composition.remove(idx);
for (i, &ch) in result_chars.iter().enumerate() {
self.composition.insert(idx + i, Transformation {
base_char: ch,
mark_applied: Some(ch),
tone_applied: old_tone,
is_upper: was_upper && i == 0,
});
}
}
pub fn process_string(&mut self, s: &str) -> String {
let mut last = String::new();
for ch in s.chars() {
if let Some(out) = self.process_key(ch) {
last = out;
}
}
last
}
#[allow(dead_code)]
pub fn debug_composition(&self) -> Vec<(char, Option<char>, Option<char>)> {
self.composition.iter().map(|t| (t.base_char, t.mark_applied, t.tone_applied)).collect()
}
pub fn get_output(&self) -> String {
self.flatten()
}
pub fn pop_last(&mut self) -> Option<String> {
if self.composition.pop().is_some() {
Some(self.flatten())
} else {
None
}
}
fn append_char(&mut self, ch: char) {
self.composition.push(Transformation {
base_char: ch,
mark_applied: None,
tone_applied: None,
is_upper: ch.is_uppercase(),
});
}
fn last_base_char(&self) -> char {
self.composition.last().map(|t| t.base_char).unwrap_or(' ')
}
fn apply_cluster_mark(&mut self, pattern: &str, result: &str) {
let result_chars: Vec<char> = result.chars().collect();
// For cluster marks, all pattern chars are already in composition
let to_remove = pattern.chars().count();
let remove_start = self.composition.len().saturating_sub(to_remove);
let removed: Vec<_> = self.composition.drain(remove_start..).collect();
let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false);
for &ch in &result_chars {
self.composition.push(Transformation {
base_char: ch,
mark_applied: Some(ch),
tone_applied: removed.last().and_then(|t| t.tone_applied),
is_upper: was_upper && ch == result_chars[0],
});
}
}
fn apply_mark(&mut self, pattern: &str, result: &str) {
let result_chars: Vec<char> = result.chars().collect();
// Remove (pattern.len() - 1) chars from composition:
// the current key being processed is NOT yet in composition,
// so we only remove the chars from composition that form the mark pattern
let to_remove = pattern.chars().count().saturating_sub(1);
let remove_start = self.composition.len().saturating_sub(to_remove);
let removed: Vec<_> = self.composition.drain(remove_start..).collect();
let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false);
for &ch in &result_chars {
self.composition.push(Transformation {
base_char: ch,
mark_applied: Some(ch),
tone_applied: removed.last().and_then(|t| t.tone_applied),
is_upper: was_upper && ch == result_chars[0],
});
}
}
fn apply_tone(&mut self, tone_char: char) -> Option<String> {
if self.composition.is_empty() {
return Some(tone_char.to_string());
}
// Find the last syllable
let last_syllable = self.last_syllable_range();
let tone_pos = self.find_tone_position(last_syllable);
if let Some(t) = self.composition.get_mut(tone_pos) {
t.tone_applied = Some(tone_char);
return Some(self.flatten());
}
Some(self.flatten())
}
fn last_syllable_range(&self) -> std::ops::Range<usize> {
let mut start = 0usize;
for (i, t) in self.composition.iter().enumerate().rev() {
let ch = t.mark_applied.unwrap_or(t.base_char);
if ch.is_whitespace() || ch == '.' || ch == ',' || ch == '!' || ch == '?' || ch == ';' || ch == ':' {
start = i + 1;
break;
}
}
start..self.composition.len()
}
fn find_tone_position(&self, range: std::ops::Range<usize>) -> usize {
let mut vowels: Vec<usize> = Vec::new();
for i in range {
let ch = self.composition[i].mark_applied.unwrap_or(self.composition[i].base_char);
if is_vowel(ch) {
vowels.push(i);
}
}
if vowels.is_empty() {
return self.composition.len().saturating_sub(1);
}
if vowels.len() == 1 {
return vowels[0];
}
// Check the last two vowels with their actual characters (including marks applied)
let cv1 = self.composition[vowels[vowels.len()-2]].mark_applied
.unwrap_or(self.composition[vowels[vowels.len()-2]].base_char)
.to_ascii_lowercase();
let cv2 = self.composition[vowels[vowels.len()-1]].mark_applied
.unwrap_or(self.composition[vowels[vowels.len()-1]].base_char)
.to_ascii_lowercase();
// Clusters where tone goes on the SECOND vowel:
// oa/oe: hoá, khoẻ
// uy: tuý
// iê/yê: tiếng, biết, nguyễn
// uô: muốn, buồn
// ươ: tướng, đường
let tone_on_second = matches!((cv1, cv2),
('o', 'a') | ('o', 'e') | ('u', 'y') |
('i', 'ê') | ('y', 'ê') | ('u', 'ô') | ('ư', 'ơ') |
('i', 'o') | ('u', 'â')
);
if tone_on_second {
return vowels[vowels.len()-1];
}
// Three+ vowels: tone on the middle one
if vowels.len() >= 3 {
return vowels[1];
}
// Default: tone on first vowel
vowels[0]
}
fn flatten(&self) -> String {
let mut output = String::new();
for t in &self.composition {
let base = t.mark_applied.unwrap_or(t.base_char);
let mut ch = if let Some(tone) = t.tone_applied {
apply_tone_to_char(base, tone)
} else {
base
};
if t.is_upper && !ch.is_uppercase() {
ch = ch.to_ascii_uppercase();
}
output.push(ch);
}
output
}
}
fn is_vowel(ch: char) -> bool {
matches!(ch.to_ascii_lowercase(),
'a' | 'e' | 'i' | 'o' | 'u' | 'y' |
'ă' | 'â' | 'ê' | 'ô' | 'ơ' | 'ư'
)
}
fn apply_tone_to_char(ch: char, tone: char) -> char {
match (ch.to_ascii_lowercase(), tone) {
// sắc
('a', 's') | ('a', '1') => 'á',
('ă', 's') | ('ă', '1') => 'ắ',
('â', 's') | ('â', '1') => 'ấ',
('e', 's') | ('e', '1') => 'é',
('ê', 's') | ('ê', '1') => 'ế',
('i', 's') | ('i', '1') => 'í',
('o', 's') | ('o', '1') => 'ó',
('ô', 's') | ('ô', '1') => 'ố',
('ơ', 's') | ('ơ', '1') => 'ớ',
('u', 's') | ('u', '1') => 'ú',
('ư', 's') | ('ư', '1') => 'ứ',
('y', 's') | ('y', '1') => 'ý',
// huyền
('a', 'f') | ('a', '2') => 'à',
('ă', 'f') | ('ă', '2') => 'ằ',
('â', 'f') | ('â', '2') => 'ầ',
('e', 'f') | ('e', '2') => 'è',
('ê', 'f') | ('ê', '2') => 'ề',
('i', 'f') | ('i', '2') => 'ì',
('o', 'f') | ('o', '2') => 'ò',
('ô', 'f') | ('ô', '2') => 'ồ',
('ơ', 'f') | ('ơ', '2') => 'ờ',
('u', 'f') | ('u', '2') => 'ù',
('ư', 'f') | ('ư', '2') => 'ừ',
('y', 'f') | ('y', '2') => 'ỳ',
// hỏi
('a', 'r') | ('a', '3') => 'ả',
('ă', 'r') | ('ă', '3') => 'ẳ',
('â', 'r') | ('â', '3') => 'ẩ',
('e', 'r') | ('e', '3') => 'ẻ',
('ê', 'r') | ('ê', '3') => 'ể',
('i', 'r') | ('i', '3') => 'ỉ',
('o', 'r') | ('o', '3') => 'ỏ',
('ô', 'r') | ('ô', '3') => 'ổ',
('ơ', 'r') | ('ơ', '3') => 'ở',
('u', 'r') | ('u', '3') => 'ủ',
('ư', 'r') | ('ư', '3') => 'ử',
('y', 'r') | ('y', '3') => 'ỷ',
// ngã
('a', 'x') | ('a', '4') => 'ã',
('ă', 'x') | ('ă', '4') => 'ẵ',
('â', 'x') | ('â', '4') => 'ẫ',
('e', 'x') | ('e', '4') => 'ẽ',
('ê', 'x') | ('ê', '4') => 'ễ',
('i', 'x') | ('i', '4') => 'ĩ',
('o', 'x') | ('o', '4') => 'õ',
('ô', 'x') | ('ô', '4') => 'ỗ',
('ơ', 'x') | ('ơ', '4') => 'ỡ',
('u', 'x') | ('u', '4') => 'ũ',
('ư', 'x') | ('ư', '4') => 'ữ',
('y', 'x') | ('y', '4') => 'ỹ',
// nặng
('a', 'j') | ('a', '5') => 'ạ',
('ă', 'j') | ('ă', '5') => 'ặ',
('â', 'j') | ('â', '5') => 'ậ',
('e', 'j') | ('e', '5') => 'ẹ',
('ê', 'j') | ('ê', '5') => 'ệ',
('i', 'j') | ('i', '5') => 'ị',
('o', 'j') | ('o', '5') => 'ọ',
('ô', 'j') | ('ô', '5') => 'ộ',
('ơ', 'j') | ('ơ', '5') => 'ợ',
('u', 'j') | ('u', '5') => 'ụ',
('ư', 'j') | ('ư', '5') => 'ự',
('y', 'j') | ('y', '5') => 'ỵ',
// unknown — return unchanged
_ => ch,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn process(method: InputMethod, input: &str) -> String {
let mut engine = BambooEngine::new(method);
let mut output = String::new();
for ch in input.chars() {
if let Some(o) = engine.process_key(ch) {
output = o;
}
}
output
}
#[test]
fn test_telex_tone() {
assert_eq!(process(InputMethod::Telex, "tieengs"), "tiếng");
assert_eq!(process(InputMethod::Telex, "dduwowngf"), "đường");
assert_eq!(process(InputMethod::Telex, "thuw"), "thư");
}
#[test]
fn test_telex_marks() {
assert_eq!(process(InputMethod::Telex, "aa"), "â");
assert_eq!(process(InputMethod::Telex, "ee"), "ê");
assert_eq!(process(InputMethod::Telex, "oo"), "ô");
assert_eq!(process(InputMethod::Telex, "aw"), "ă");
assert_eq!(process(InputMethod::Telex, "ow"), "ơ");
assert_eq!(process(InputMethod::Telex, "uw"), "ư");
assert_eq!(process(InputMethod::Telex, "dd"), "đ");
}
#[test]
fn test_vni_tone() {
assert_eq!(process(InputMethod::Vni, "d9"), "đ");
assert_eq!(process(InputMethod::Vni, "u7"), "ư");
assert_eq!(process(InputMethod::Vni, "o7"), "ơ");
assert_eq!(process(InputMethod::Vni, "d9u7o7ng2"), "đường");
assert_eq!(process(InputMethod::Vni, "tie6ng1"), "tiếng");
assert_eq!(process(InputMethod::Vni, "thu3"), "thủ");
assert_eq!(process(InputMethod::Vni, "xa4"), "");
assert_eq!(process(InputMethod::Vni, "na85ng5"), "nặng");
}
#[test]
fn test_vni_marks() {
assert_eq!(process(InputMethod::Vni, "a6"), "â");
assert_eq!(process(InputMethod::Vni, "e6"), "ê");
assert_eq!(process(InputMethod::Vni, "o6"), "ô");
assert_eq!(process(InputMethod::Vni, "o7"), "ơ");
assert_eq!(process(InputMethod::Vni, "u7"), "ư");
assert_eq!(process(InputMethod::Vni, "a8"), "ă");
assert_eq!(process(InputMethod::Vni, "d9"), "đ");
}
#[test]
fn test_tone_placement() {
// oa cluster: tone on second vowel → hoá (standard Vietnamese IME convention)
assert_eq!(process(InputMethod::Telex, "hoas"), "hoá");
// thuố = th + uô + sắc → tone on ô (uô cluster → tone on second)
assert_eq!(process(InputMethod::Telex, "thuoos"), "thuố");
}
#[test]
fn test_reset() {
let mut engine = BambooEngine::new(InputMethod::Telex);
engine.process_key('t');
engine.reset();
assert!(engine.get_output().is_empty());
}
#[test]
fn test_uppercase_preservation() {
let mut engine = BambooEngine::new(InputMethod::Telex);
engine.process_key('T');
engine.process_key('i');
engine.process_key('e');
engine.process_key('e');
engine.process_key('n');
engine.process_key('g');
engine.process_key('s');
assert_eq!(engine.get_output(), "Tiếng");
}
#[test]
fn test_simple_words() {
assert_eq!(process(InputMethod::Telex, "chafo"), "chào");
assert_eq!(process(InputMethod::Vni, "chao2"), "chào");
}
}

View file

@ -1,106 +1,68 @@
use crate::english::EnglishDict; use crate::bamboo::BambooEngine;
use crate::telex::TelexEngine; use crate::input_method::InputMethod;
use crate::vni::VniEngine; use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum InputMethod {
Telex,
Vni,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum EngineEvent { pub enum EngineEvent {
Replace { Replace { backspaces: usize, insert: String },
backspaces: usize,
insert: String,
},
Insert(String), Insert(String),
Flush(String), Flush(String),
AutoRestore(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), Paste(String),
} }
pub struct Engine { pub struct Engine {
input_method: InputMethod, bamboo: BambooEngine,
telex: TelexEngine, macros: HashMap<String, String>,
vni: VniEngine,
english: EnglishDict,
enabled: bool,
macros: std::collections::HashMap<String, String>,
raw_buffer: String, raw_buffer: String,
/// Flag to bypass telex/vni parsing when Unicode text has been pasted via clipboard
paste_mode: bool, paste_mode: bool,
} }
impl Engine { impl Engine {
pub fn new(method: InputMethod) -> Self { pub fn new(method: InputMethod) -> Self {
Self { Self {
input_method: method, bamboo: BambooEngine::new(method),
telex: TelexEngine::new(), macros: HashMap::new(),
vni: VniEngine::new(),
english: EnglishDict::new(),
enabled: true,
macros: std::collections::HashMap::new(),
raw_buffer: String::new(), raw_buffer: String::new(),
paste_mode: false, paste_mode: false,
} }
} }
pub fn set_enabled(&mut self, enabled: bool) { pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled; self.bamboo.set_enabled(enabled);
if !enabled { if !enabled {
self.flush(); self.reset();
} }
} }
pub fn is_enabled(&self) -> bool { pub fn is_enabled(&self) -> bool {
self.enabled self.bamboo.is_enabled()
} }
pub fn set_method(&mut self, method: InputMethod) { pub fn set_method(&mut self, method: InputMethod) {
self.input_method = method; self.bamboo.set_method(method);
self.reset(); self.reset();
} }
/// Enter "paste mode" - bypass telex/vni parsing for Unicode pasted text
pub fn enter_paste_mode(&mut self) { pub fn enter_paste_mode(&mut self) {
self.paste_mode = true; self.paste_mode = true;
} }
/// Exit paste mode (for Paste event handling)
pub fn exit_paste_mode(&mut self) { pub fn exit_paste_mode(&mut self) {
self.paste_mode = false; self.paste_mode = false;
} }
/// Paste raw text into buffer without telex/vni processing
pub fn paste(&mut self, text: &str) -> EngineEvent { 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(); self.raw_buffer.clear();
} else {
self.enter_paste_mode();
}
let event = EngineEvent::Paste(text.to_string()); let event = EngineEvent::Paste(text.to_string());
self.raw_buffer.push_str(text); self.raw_buffer.push_str(text);
event event
} }
/// Replay a sequence of keystrokes through a fresh engine and return the
/// final screen output. This is the core of the Backspace-Replay pattern:
/// instead of tracking incremental state, we always recompute from scratch.
/// Returns (output_on_screen, did_flush).
/// `did_flush` means the engine processed a word boundary and the cursor
/// is now at a clean position — caller should clear keystroke history.
pub fn replay_keystrokes( pub fn replay_keystrokes(
method: InputMethod, method: InputMethod,
macros: &std::collections::HashMap<String, String>, macros: &HashMap<String, String>,
keystrokes: &[char], keystrokes: &[char],
) -> (String, bool) { ) -> (String, bool) {
let mut engine = Engine::new(method); let mut engine = Engine::new(method);
@ -109,335 +71,143 @@ impl Engine {
} }
let mut last_output = String::new(); let mut last_output = String::new();
let mut did_flush = false; let mut composing = String::new();
for &ch in keystrokes { for &ch in keystrokes {
if let Some(event) = engine.process_key(ch) { if ch == '\x08' {
match event { let _ = engine.bamboo.pop_last();
EngineEvent::Replace { insert, .. } => { composing = engine.bamboo.get_output();
last_output = insert; last_output = composing.clone();
continue;
} }
EngineEvent::Flush(_word) => {
// Word was flushed. The flush char is NOT part of the word. if is_flush_char(ch) {
// The word is committed; clear tracking for current composing. if !composing.is_empty() {
last_output.clear(); last_output = composing.clone();
did_flush = true;
}
EngineEvent::Insert(text) => {
last_output = text;
}
EngineEvent::UndoTones { restored, .. } => {
last_output = restored;
}
EngineEvent::Paste(text) => {
last_output = text;
}
EngineEvent::AutoRestore(word) => {
last_output = word;
} }
composing.clear();
engine.bamboo.reset();
continue;
} }
if let Some(out) = engine.bamboo.process_key(ch) {
composing = out.clone();
last_output = out;
} else { } else {
// Key consumed but no screen change — buffer is building composing = engine.bamboo.get_output();
let buf = engine.buffer().to_string(); last_output = composing.clone();
if !buf.is_empty() {
last_output = buf;
}
} }
} }
// If the engine has a buffer that hasn't been flushed, that's on screen let output = engine.bamboo.get_output();
let buf = engine.buffer().to_string(); if !output.is_empty() {
if !buf.is_empty() { last_output = output.clone();
last_output = buf;
did_flush = false; // Still composing
} else if did_flush {
// After flush, nothing is on screen for the composing word
last_output.clear();
} }
(last_output, did_flush) let did_flush = output.is_empty() && composing.is_empty();
(if did_flush { String::new() } else { last_output }, did_flush)
} }
/// Update buffer with pasted text for subsequent edit operations (delete/backspace)
pub fn update_with_pasted_text(&mut self, text: &str) { pub fn update_with_pasted_text(&mut self, text: &str) {
self.raw_buffer.clear(); self.raw_buffer.clear();
self.raw_buffer.push_str(text); self.raw_buffer.push_str(text);
} }
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.telex.reset(); self.bamboo.reset();
self.vni.reset();
self.raw_buffer.clear(); self.raw_buffer.clear();
} }
pub fn flush(&mut self) -> Option<EngineEvent> { pub fn flush(&mut self) -> Option<EngineEvent> {
// If in paste mode, bypass telex/vni parsing and return raw text as-is
if self.paste_mode && !self.raw_buffer.is_empty() { 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()); let has_unicode = self.raw_buffer.chars().any(|c| !c.is_ascii());
if has_unicode { if has_unicode {
let word = self.raw_buffer.clone(); let word = self.raw_buffer.clone();
self.raw_buffer.clear(); self.raw_buffer.clear();
self.paste_mode = false; // Exit paste mode after flush self.paste_mode = false;
return Some(EngineEvent::Flush(word)); return Some(EngineEvent::Flush(word));
} }
} }
let event = match self.input_method { None
InputMethod::Telex => self.telex.flush(),
InputMethod::Vni => self.vni.flush(),
};
if let Some(EngineEvent::Flush(word)) = event {
let cased = match_casing(&self.raw_buffer, &word);
self.raw_buffer.clear();
Some(EngineEvent::Flush(cased))
} else {
event
}
} }
/// Add a macro shortcut
pub fn add_macro(&mut self, shortcut: String, expansion: String) { pub fn add_macro(&mut self, shortcut: String, expansion: String) {
self.macros.insert(shortcut, expansion); self.macros.insert(shortcut.clone(), expansion.clone());
self.bamboo.add_macro(shortcut, expansion);
} }
/// Clear all macros
pub fn clear_macros(&mut self) { pub fn clear_macros(&mut self) {
self.macros.clear(); self.macros.clear();
} self.bamboo.clear_macros();
/// Process ESC key - undo tones from current word
pub fn process_escape(&mut self) -> Option<EngineEvent> {
let buffer = match self.input_method {
InputMethod::Telex => self.telex.buffer(),
InputMethod::Vni => self.vni.buffer(),
};
if buffer.is_empty() {
return None;
}
// Strip all diacritics from the buffer
let stripped = strip_diacritics(buffer);
let backspaces = buffer.chars().count();
let had_tones = stripped != buffer;
let cased_stripped = match_casing(&self.raw_buffer, &stripped);
self.reset();
if had_tones {
Some(EngineEvent::UndoTones {
backspaces,
restored: cased_stripped,
})
} else {
Some(EngineEvent::Flush(cased_stripped))
}
} }
pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> { pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> {
if !self.enabled { if !self.bamboo.is_enabled() {
return None; return Some(EngineEvent::Insert(ch.to_string()));
}
// ESC = undo tones
if ch == '\x1b' {
return self.process_escape();
} }
if ch == '\x08' { if ch == '\x08' {
// Backspace handling: pop from inner engine and sync raw_buffer self.bamboo.pop_last();
match self.input_method { let _ = self.raw_buffer.pop();
InputMethod::Telex => self.telex.pop(),
InputMethod::Vni => self.vni.pop(),
}
let inner_len = self.buffer().chars().count();
// Truncate raw_buffer to match inner engine buffer's character count
let char_indices: Vec<(usize, char)> = self.raw_buffer.char_indices().collect();
if char_indices.len() > inner_len {
if inner_len == 0 {
self.raw_buffer.clear();
} else {
let cut_idx = char_indices[inner_len].0;
self.raw_buffer.truncate(cut_idx);
}
}
return None; return None;
} }
let lowercase_ch = if ch.is_ascii() { if is_flush_char(ch) {
ch.to_ascii_lowercase()
} else {
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 self.raw_buffer.is_empty() { if self.raw_buffer.is_empty() {
return None; return None;
} }
// Check for macro expansion before auto-restore let previous = self.bamboo.get_output();
let prev_len = previous.chars().count();
// Check for macro
let macro_expansion = self.macros.get(&self.raw_buffer.to_lowercase()).cloned(); let macro_expansion = self.macros.get(&self.raw_buffer.to_lowercase()).cloned();
if let Some(expansion) = macro_expansion { if let Some(expansion) = macro_expansion {
let previous_raw_len = self.raw_buffer.chars().count();
self.reset(); self.reset();
return Some(EngineEvent::Replace { return Some(EngineEvent::Replace {
backspaces: previous_raw_len + 1, backspaces: prev_len,
insert: format!("{}{}", expansion, ch), insert: format!("{}{}", expansion, ch),
}); });
} }
// Try auto-restore before flushing
let clean_raw = self.raw_buffer.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(); self.reset();
if prev_len > 0 {
if has_diacritics {
return Some(EngineEvent::Replace { return Some(EngineEvent::Replace {
backspaces: inner_len + 1, backspaces: prev_len,
insert: format!("{}{}", original_raw, ch), insert: format!("{}{}", previous, ch),
}); });
} else { }
return None; return None;
} }
}
// Flush buffer with trailing character let previous = self.bamboo.get_output();
let previous_inner = self.buffer().to_string(); let prev_len = previous.chars().count();
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();
if let Some(EngineEvent::Flush(word)) = flush_event {
final_word = word;
}
let result = if final_word != previous_inner_cased {
Some(EngineEvent::Replace {
backspaces: previous_inner_len + 1,
insert: format!("{}{}", final_word, ch),
})
} else {
None
};
self.reset();
return result;
}
let previous_inner = self.buffer().to_string();
self.raw_buffer.push(ch); self.raw_buffer.push(ch);
let expected_screen = format!("{}{}", previous_inner, lowercase_ch); if let Some(new_output) = self.bamboo.process_key(ch) {
// Only emit Replace when Vietnamese processing CHANGED the output
if self.paste_mode { // (tone/mark keys). For simple appends, let the raw key go through.
if ch.is_ascii() { let expected = format!("{}{}", previous, ch);
match self.input_method { if new_output != expected && new_output != previous {
InputMethod::Telex => { let cased = match_casing(&self.raw_buffer, &new_output);
self.telex.process_key(lowercase_ch); return Some(EngineEvent::Replace {
} backspaces: prev_len,
InputMethod::Vni => { insert: cased,
self.vni.process_key(lowercase_ch); });
}
}
None
} else {
Some(EngineEvent::Replace {
backspaces: previous_inner.chars().count() + 1,
insert: ch.to_string(),
})
}
} else {
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 None
} }
pub fn buffer(&self) -> String {
self.bamboo.get_output()
} }
} }
pub fn buffer(&self) -> &str { fn is_flush_char(ch: char) -> bool {
match self.input_method { matches!(ch, ' ' | '\t' | '.' | ',' | '!' | '?' | ';' | ':' | '\n')
InputMethod::Telex => self.telex.buffer(),
InputMethod::Vni => self.vni.buffer(),
}
}
}
/// Strip all Vietnamese diacritics from a string, returning base ASCII
fn strip_diacritics(s: &str) -> String {
s.chars()
.map(|c| match c {
// a variants
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ'
| 'ẩ' | 'ẫ' | 'ậ' => 'a',
// A variants
'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' | 'Â' | 'Ầ' | 'Ấ'
| 'Ẩ' | 'Ẫ' | 'Ậ' => 'A',
// e variants
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => {
'e'
}
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => {
'E'
}
// i variants
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I',
// o variants
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ'
| 'ở' | 'ỡ' | 'ợ' => 'o',
'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' | 'Ơ' | 'Ờ' | 'Ớ'
| 'Ở' | 'Ỡ' | 'Ợ' => 'O',
// u variants
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => {
'u'
}
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => {
'U'
}
// y variants
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y',
// đ
'đ' => 'd',
'Đ' => 'D',
// Everything else unchanged
other => other,
})
.collect()
} }
fn match_casing(raw: &str, processed: &str) -> String { fn match_casing(raw: &str, processed: &str) -> String {
@ -445,17 +215,15 @@ fn match_casing(raw: &str, processed: &str) -> String {
return processed.to_string(); return processed.to_string();
} }
let alphabetic_chars: Vec<char> = raw.chars().filter(|c| c.is_alphabetic()).collect(); let alpha: Vec<char> = raw.chars().filter(|c| c.is_alphabetic()).collect();
if alphabetic_chars.is_empty() { if alpha.is_empty() {
return processed.to_string(); return processed.to_string();
} }
let all_upper = alphabetic_chars.iter().all(|c| c.is_uppercase()); let all_upper = alpha.iter().all(|c| c.is_uppercase());
let first_upper = alphabetic_chars[0].is_uppercase();
if all_upper { if all_upper {
processed.to_uppercase() processed.to_uppercase()
} else if first_upper { } else if alpha[0].is_uppercase() {
let mut chars = processed.chars(); let mut chars = processed.chars();
match chars.next() { match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
@ -465,161 +233,3 @@ fn match_casing(raw: &str, processed: &str) -> String {
processed.to_string() processed.to_string()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_diacritics() {
assert_eq!(strip_diacritics("chào"), "chao");
assert_eq!(strip_diacritics("cám ơn"), "cam on");
assert_eq!(strip_diacritics("Việt Nam"), "Viet Nam");
assert_eq!(strip_diacritics("hello"), "hello");
assert_eq!(strip_diacritics("đường"), "duong");
assert_eq!(strip_diacritics("Nguyễn"), "Nguyen");
}
#[test]
fn test_esc_undo_tones() {
let mut engine = Engine::new(InputMethod::Telex);
// Type "chào" then ESC
for ch in "chào".chars() {
engine.process_key(ch);
}
let event = engine.process_escape();
match event {
Some(EngineEvent::UndoTones {
backspaces,
restored,
}) => {
assert_eq!(backspaces, 4); // "chào" is 4 chars
assert_eq!(restored, "chao");
}
_ => panic!("Expected UndoTones event, got {:?}", event),
}
}
#[test]
fn test_macro_expansion() {
let mut engine = Engine::new(InputMethod::Telex);
engine.add_macro("ko".into(), "không".into());
engine.add_macro("ok".into(), "được".into());
// Type "ko" + space
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();
assert!(output.contains("không"));
}
#[test]
fn test_casing_preservation() {
let mut engine = Engine::new(InputMethod::Telex);
// Lowercase: "sats" -> "sát"
engine.reset();
let _ = engine.process_key('s');
let _ = engine.process_key('a');
let _ = engine.process_key('t');
let _ = engine.process_key('s');
assert_eq!(engine.buffer(), "sát");
// Titlecase: "Sats" -> "Sát"
engine.reset();
engine.process_key('S');
engine.process_key('a');
engine.process_key('t');
let event = engine.process_key('s');
if let Some(EngineEvent::Replace { insert, .. }) = event {
assert_eq!(insert, "Sát");
} else {
panic!("Expected Replace event, got {:?}", event);
}
// Uppercase: "SATS" -> "SÁT"
engine.reset();
engine.process_key('S');
engine.process_key('A');
engine.process_key('T');
let event2 = engine.process_key('S');
if let Some(EngineEvent::Replace { insert, .. }) = event2 {
assert_eq!(insert, "SÁT");
} else {
panic!("Expected Replace event, got {:?}", event2);
}
}
#[test]
fn test_replay_keystrokes_telex() {
let macros = std::collections::HashMap::new();
// Replay "chao" -> should produce "chao" (no tone yet)
let (output, flush) = Engine::replay_keystrokes(
InputMethod::Telex,
&macros,
&['c', 'h', 'a', 'o'],
);
assert_eq!(output, "chao");
assert!(!flush);
// Replay "chaos" -> s adds acute accent: "cháo"
let (output, flush) = Engine::replay_keystrokes(
InputMethod::Telex,
&macros,
&['c', 'h', 'a', 'o', 's'],
);
assert_eq!(output, "cháo");
assert!(!flush);
// Replay "chaof" -> f adds grave accent: "chào"
let (output, flush) = Engine::replay_keystrokes(
InputMethod::Telex,
&macros,
&['c', 'h', 'a', 'o', 'f'],
);
assert_eq!(output, "chào");
assert!(!flush);
}
#[test]
fn test_replay_keystrokes_backspace() {
let macros = std::collections::HashMap::new();
// Replay "chaos" then backspace -> engine pops 'o' from "cháo" → "chá"
let (output, _) = Engine::replay_keystrokes(
InputMethod::Telex,
&macros,
&['c', 'h', 'a', 'o', 's', '\x08'],
);
assert_eq!(output, "chá");
}
#[test]
fn test_replay_keystrokes_vni() {
let macros = std::collections::HashMap::new();
// VNI: "chao1" → acute accent on last vowel
let (output, _) = Engine::replay_keystrokes(
InputMethod::Vni,
&macros,
&['c', 'h', 'a', 'o', '1'],
);
// Verify it produces accented output (engine applies tone to last vowel)
assert!(output.contains('á') || output.contains('ó'), "Expected toned output, got: {}", output);
}
}

View file

@ -0,0 +1,71 @@
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMethod {
Telex,
Vni,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleEffect {
Appending(char),
MarkTransformation { base: char, marked: char },
ToneTransformation { tone: char, name: &'static str },
}
#[derive(Debug, Clone)]
pub struct InputMethodRules {
pub method: InputMethod,
pub tone_keys: HashMap<char, (char, &'static str)>,
pub mark_rules: Vec<(String, String)>,
pub special_rules: Vec<RuleEffect>,
}
fn tone_map(entries: &[(char, char, &'static str)]) -> HashMap<char, (char, &'static str)> {
entries.iter().map(|&(k, t, n)| (k, (t, n))).collect()
}
pub fn get_rules(method: InputMethod) -> InputMethodRules {
match method {
InputMethod::Telex => InputMethodRules {
method,
tone_keys: tone_map(&[
('f', 'f', "huyen"),
('s', 's', "sac"),
('r', 'r', "hoi"),
('x', 'x', "nga"),
('j', 'j', "nang"),
]),
mark_rules: vec![
("aw".into(), "ă".into()),
("aa".into(), "â".into()),
("ee".into(), "ê".into()),
("oo".into(), "ô".into()),
("ow".into(), "ơ".into()),
("uw".into(), "ư".into()),
("dd".into(), "đ".into()),
],
special_rules: vec![],
},
InputMethod::Vni => InputMethodRules {
method,
tone_keys: tone_map(&[
('1', '1', "sac"),
('2', '2', "huyen"),
('3', '3', "hoi"),
('4', '4', "nga"),
('5', '5', "nang"),
]),
mark_rules: vec![
("a6".into(), "â".into()),
("e6".into(), "ê".into()),
("o6".into(), "ô".into()),
("o7".into(), "ơ".into()),
("u7".into(), "ư".into()),
("a8".into(), "ă".into()),
("d9".into(), "đ".into()),
],
special_rules: vec![],
},
}
}

View file

@ -1,12 +1,11 @@
mod bamboo;
mod engine; mod engine;
mod english; mod input_method;
mod spelling; pub mod spelling;
mod telex;
mod vni;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub use engine::Engine; pub use engine::Engine;
pub use engine::EngineEvent; pub use engine::EngineEvent;
pub use engine::InputMethod; pub use input_method::InputMethod;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,83 +0,0 @@
use serde::Serialize;
use vietc_engine::{Engine, EngineEvent, InputMethod};
#[derive(Serialize)]
struct SnapshotTestCase {
input: String,
display: String,
events: Vec<EngineEvent>,
}
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<SnapshotTestCase> {
let inputs: Vec<String> = 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);
}

View file

@ -40,6 +40,7 @@ if [ -d "deb-build/usr/bin" ]; then
else else
cp target/release/vietc "$APPDIR/usr/bin/" cp target/release/vietc "$APPDIR/usr/bin/"
cp target/release/vietc-cli "$APPDIR/usr/bin/" cp target/release/vietc-cli "$APPDIR/usr/bin/"
cp target/release/vietc-uinputd "$APPDIR/usr/bin/"
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" [ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
fi fi
@ -55,12 +56,13 @@ fi
# Compile and bundle vietc-xrecord (C helper for XRecord keyboard capture) # Compile and bundle vietc-xrecord (C helper for XRecord keyboard capture)
echo " Compiling vietc-xrecord..." echo " Compiling vietc-xrecord..."
if command -v gcc &>/dev/null; then if command -v gcc &>/dev/null; then
gcc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst -ldl 2>&1 gcc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst
echo " vietc-xrecord bundled"
elif command -v cc &>/dev/null; then
cc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst
echo " vietc-xrecord bundled" echo " vietc-xrecord bundled"
else else
echo " gcc not found, trying cc..." echo " WARNING: No C compiler found, vietc-xrecord not bundled — X11 capture will fail"
cc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst -ldl 2>&1
echo " vietc-xrecord bundled"
fi fi
# Desktop integration # Desktop integration
@ -208,6 +210,15 @@ ENV_PREFIX="env"
[ -n "$WAYLAND_DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX WAYLAND_DISPLAY=$WAYLAND_DISPLAY" [ -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" [ -n "$XDG_RUNTIME_DIR" ] && ENV_PREFIX="$ENV_PREFIX XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
# Ensure system library paths are available for dlopen (libX11, libXtst, etc.)
# AppImage runtime may override LD_LIBRARY_PATH; append system paths as fallback
SYSLIB_PATHS="/usr/lib/x86_64-linux-gnu:/usr/lib64:/usr/lib:/lib/x86_64-linux-gnu:/lib64:/lib"
if [ -n "$LD_LIBRARY_PATH" ]; then
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$SYSLIB_PATHS"
else
export LD_LIBRARY_PATH="$SYSLIB_PATHS"
fi
# Start daemon (kill old non-root one first if we have root) # Start daemon (kill old non-root one first if we have root)
# On X11 we can run without root (XGrabKeyboard + XTest injection needs no special permissions). # On X11 we can run without root (XGrabKeyboard + XTest injection needs no special permissions).
# On Wayland, evdev requires root (input group) or uinput. # On Wayland, evdev requires root (input group) or uinput.
@ -217,9 +228,24 @@ if [ -n "$WAYLAND_DISPLAY" ]; then
fi fi
if [ -z "$NEED_ROOT" ]; then if [ -z "$NEED_ROOT" ]; then
# X11: no root needed # X11: no root needed for capture, but uinputd needs root for injection
pkill -x vietc-uinputd 2>/dev/null
pkill -x vietc 2>/dev/null; sleep 0.3 pkill -x vietc 2>/dev/null; sleep 0.3
mkdir -p "$HOME/.config/vietc" mkdir -p "$HOME/.config/vietc" "$HOME/.vietc"
# Try to start the uinputd daemon (preferred injection path)
if command -v pkexec >/dev/null 2>&1; then
pkexec "$HERE/usr/bin/vietc-uinputd" >/dev/null 2>&1 &
UINPUTD_PID=$!
sleep 0.3
elif command -v sudo >/dev/null 2>&1; then
if sudo -n true 2>/dev/null; then
sudo "$HERE/usr/bin/vietc-uinputd" >/dev/null 2>&1 &
UINPUTD_PID=$!
sleep 0.3
fi
fi
"$HERE/usr/bin/vietc" >"$HOME/.config/vietc/vietc-daemon.log" 2>&1 & "$HERE/usr/bin/vietc" >"$HOME/.config/vietc/vietc-daemon.log" 2>&1 &
DAEMON_PID=$! DAEMON_PID=$!
echo "[vietc] Daemon started (PID=$DAEMON_PID), log: $HOME/.config/vietc/vietc-daemon.log" echo "[vietc] Daemon started (PID=$DAEMON_PID), log: $HOME/.config/vietc/vietc-daemon.log"
@ -272,16 +298,20 @@ cleanup_daemon() {
kill "$DAEMON_PID" 2>/dev/null kill "$DAEMON_PID" 2>/dev/null
wait "$DAEMON_PID" 2>/dev/null wait "$DAEMON_PID" 2>/dev/null
fi fi
if [ -n "$UINPUTD_PID" ]; then
kill "$UINPUTD_PID" 2>/dev/null
wait "$UINPUTD_PID" 2>/dev/null
fi
} }
trap cleanup_daemon EXIT INT TERM trap cleanup_daemon EXIT INT TERM
if [ -f "$HERE/usr/bin/vietc-tray" ]; then if [ -f "$HERE/usr/bin/vietc-tray" ]; then
"$HERE/usr/bin/vietc-tray" "$@" "$HERE/usr/bin/vietc-tray" "$@"
else else
echo "[vietc] ERROR: vietc-tray not found. The AppImage cannot start without it." echo "[vietc] Tray not available — daemon is running in background."
echo "[vietc] Stopping." echo "[vietc] Press Ctrl+C or close this terminal to stop."
kill "$DAEMON_PID" 2>/dev/null # Keep AppImage alive: wait for daemon to exit
exit 1 wait $DAEMON_PID 2>/dev/null
fi fi
EOF EOF
chmod +x "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun"

View file

@ -1,6 +1,7 @@
pub mod inject; pub mod inject;
pub mod monitor; pub mod monitor;
pub mod uinput_monitor; pub mod uinput_monitor;
pub mod uinput_client;
pub mod wayland_im; pub mod wayland_im;
#[cfg(feature = "x11")] #[cfg(feature = "x11")]

View file

@ -0,0 +1,75 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use super::inject::{InjectResult, KeyInjector};
fn socket_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home).join(".vietc").join("uinput.sock")
}
pub struct UinputClient;
impl UinputClient {
fn send_command(cmd: &str) -> InjectResult {
match UnixStream::connect(socket_path()) {
Ok(mut stream) => {
if writeln!(stream, "{}", cmd).is_err() {
return InjectResult::Failed;
}
let mut reader = BufReader::new(&stream);
let mut response = String::new();
if reader.read_line(&mut response).is_err() {
return InjectResult::Failed;
}
if response.trim() == "OK" {
InjectResult::Success
} else {
InjectResult::Failed
}
}
Err(_) => InjectResult::Failed,
}
}
pub fn is_available() -> bool {
UnixStream::connect(socket_path()).is_ok()
}
}
impl KeyInjector for UinputClient {
fn send_key_event(&self, _keycode: u16, _value: i32) -> InjectResult {
InjectResult::Success
}
fn send_backspace(&self) -> InjectResult {
InjectResult::Success
}
fn send_char(&self, _ch: char) -> InjectResult {
InjectResult::Success
}
fn send_string(&self, s: &str) -> InjectResult {
Self::send_command(&format!("TYPE:{}", s))
}
fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult {
if backspaces > 0 {
let _ = Self::send_command(&format!("BACKSPACE:{}", backspaces));
}
if !text.is_empty() {
let _ = Self::send_command(&format!("TYPE:{}", text));
}
InjectResult::Success
}
fn flush(&self) -> InjectResult {
InjectResult::Success
}
fn update_pasted_text(&self, _text: &str) -> InjectResult {
InjectResult::Success
}
}

View file

@ -135,33 +135,13 @@ impl KeyInjector for UinputInjector {
if let Some(keycode) = char_to_linux_keycode(ch) { if let Some(keycode) = char_to_linux_keycode(ch) {
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch); let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
self.send_key_stroke(keycode, needs_shift); self.send_key_stroke(keycode, needs_shift);
eprintln!(
"[vietc] send_char: ASCII '{}' via uinput",
ch.escape_default()
);
return InjectResult::Success; return InjectResult::Success;
} }
// Unicode character: use clipboard fallback for reliable injection // Vietnamese Unicode char: map to base ASCII and send via uinput
let text = ch.to_string(); let ascii = strip_vn_diacritic(ch);
eprintln!( if let Some(keycode) = char_to_linux_keycode(ascii) {
"[vietc] send_char: Unicode '{}' - using clipboard", let needs_shift = ascii.is_uppercase();
text.escape_default() self.send_key_stroke(keycode, needs_shift);
);
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 InjectResult::Success
} }
@ -360,22 +340,8 @@ impl UinputInjector {
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or /// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
/// clipboard for Unicode. /// clipboard for Unicode.
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult { fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
eprintln!( // If all ASCII, send keycodes directly — fast and reliable
"[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()) { if text.chars().all(|c| char_to_linux_keycode(c).is_some()) {
eprintln!(
"[vietc] ASCII injection using uinput (backspaces={})",
backspaces
);
if backspaces > 0 { if backspaces > 0 {
for _ in 0..backspaces { for _ in 0..backspaces {
let _ = self.send_backspace(); let _ = self.send_backspace();
@ -384,149 +350,43 @@ impl UinputInjector {
for ch in text.chars() { for ch in text.chars() {
let _ = self.send_char(ch); let _ = self.send_char(ch);
} }
eprintln!("[vietc] ASCII injection complete");
return InjectResult::Success; return InjectResult::Success;
} }
// Unicode text: use xdotool directly (X11/XWayland) or wtype (Wayland) // Unicode text: split into Vietnamese portion (clipboard paste) and
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); // trailing ASCII whitespace/punctuation (uinput). Clipboard paste
// often trims trailing whitespace, so we send it separately.
static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); let mut split = text.len();
let has_xdotool = if is_wayland { for (i, c) in text.char_indices().rev() {
false if c.is_ascii() && (c.is_whitespace() || matches!(c, '.' | ',' | '!' | '?' | ';' | ':')) {
split = i;
} else { } else {
*HAS_XDOTOOL.get_or_init(|| { break;
std::process::Command::new("which") }
.arg("xdotool") }
.output() let (vn_text, ascii_tail) = text.split_at(split);
.map(|o| o.status.success())
.unwrap_or(false)
})
};
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); // Backspaces via uinput
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()
);
}
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 { if backspaces > 0 {
for _ in 0..backspaces { for _ in 0..backspaces {
let _ = self.send_backspace(); let _ = self.send_backspace();
} }
} }
eprintln!("[vietc] Sending Ctrl+V");
self.send_ctrl_v(); // Clipboard paste for Vietnamese text
// Record pasted text for future delete/backspace operations if !vn_text.is_empty() {
let output = Self::run_as_user("vietc", &["update-pasted", "-text", text]); if self.copy_to_clipboard(vn_text) {
if output.status.success() { self.send_ctrl_v_x11();
eprintln!("[vietc] update_pasted_text success");
} else {
eprintln!("[vietc] update_pasted_text call ignored (not critical)");
} }
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 // Trailing ASCII via uinput (spaces, punctuation)
eprintln!("[vietc] Last resort: pasting '{}' char-by-char", text); for ch in ascii_tail.chars() {
if backspaces > 0 { if let Some(kc) = char_to_linux_keycode(ch) {
for _ in 0..backspaces { self.send_key_stroke(kc, false);
let _ = self.send_backspace();
} }
} }
for ch in text.chars() {
let _ = self.send_char(ch);
}
eprintln!("[vietc] Char-by-char injection complete");
InjectResult::Success InjectResult::Success
} }
@ -607,13 +467,9 @@ impl UinputInjector {
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11). /// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
fn copy_to_clipboard(&self, s: &str) -> bool { fn copy_to_clipboard(&self, s: &str) -> bool {
let is_root = unsafe { libc::getuid() == 0 };
eprintln!("[vietc] clipboard: is_root={}", is_root);
// Try wl-copy (Wayland) via user_cmd // Try wl-copy (Wayland) via user_cmd
{ {
let mut cmd = Self::user_cmd("wl-copy"); let mut cmd = Self::user_cmd("wl-copy");
eprintln!("[vietc] clipboard: trying wl-copy via {:?}", cmd);
let result = cmd let result = cmd
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.spawn() .spawn()
@ -624,24 +480,15 @@ impl UinputInjector {
}); });
if let Ok(status) = result { if let Ok(status) = result {
if status.success() { if status.success() {
eprintln!("[vietc] clipboard: wl-copy OK");
return true; return true;
} }
eprintln!(
"[vietc] clipboard: wl-copy failed (exit={:?})",
status.code()
);
} else if let Err(ref e) = result {
eprintln!("[vietc] clipboard: wl-copy error: {}", e);
} }
} }
// Try xclip (X11) via user_cmd // Try xclip (X11) via user_cmd
eprintln!("[vietc] clipboard: trying xclip...");
{ {
let mut cmd = Self::user_cmd("xclip"); let mut cmd = Self::user_cmd("xclip");
cmd.args(["-selection", "clipboard"]); cmd.args(["-selection", "clipboard"]);
eprintln!("[vietc] clipboard: xclip via {:?}", cmd);
let result = cmd let result = cmd
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.spawn() .spawn()
@ -650,18 +497,8 @@ impl UinputInjector {
child.stdin.take().unwrap().write_all(s.as_bytes())?; child.stdin.take().unwrap().write_all(s.as_bytes())?;
child.wait() child.wait()
}) })
.map(|status| { .map(|status| status.success())
if status.success() { .unwrap_or(false);
eprintln!("[vietc] clipboard: xclip OK");
} else {
eprintln!("[vietc] clipboard: xclip failed (exit={:?})", status.code());
}
status.success()
})
.unwrap_or_else(|e| {
eprintln!("[vietc] clipboard: xclip error: {}", e);
false
});
if result { if result {
return true; return true;
} }
@ -688,6 +525,56 @@ impl UinputInjector {
self.send_uinput_event(0, 0, 0); // SYN self.send_uinput_event(0, 0, 0); // SYN
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
} }
/// Send Ctrl+V via X11 XTest (avoids uinput kernel feedback loop).
/// Uses a lazily-opened persistent X11 connection.
fn send_ctrl_v_x11(&self) {
if std::env::var("WAYLAND_DISPLAY").is_ok() {
self.send_ctrl_v();
return;
}
// Persistent X11 state (raw pointers, only used from injection thread)
static mut X11_DPY: *mut libc::c_void = std::ptr::null_mut();
static mut X11_KEY: Option<unsafe extern "C" fn(*mut libc::c_void, u32, libc::c_int, u64) -> libc::c_int> = None;
static mut X11_FLUSH: Option<unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int> = None;
static mut X11_KEYCODE: Option<unsafe extern "C" fn(*mut libc::c_void, u64) -> u32> = None;
static X11_INIT: std::sync::Once = std::sync::Once::new();
X11_INIT.call_once(|| {
unsafe {
let lib = libc::dlopen(b"libX11.so.6\0".as_ptr() as *const libc::c_char, 1);
if lib.is_null() { return; }
let xtst = libc::dlopen(b"libXtst.so.6\0".as_ptr() as *const libc::c_char, 1);
if xtst.is_null() { libc::dlclose(lib); return; }
type FnOpen = unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void;
let xopen: FnOpen = std::mem::transmute(libc::dlsym(lib, b"XOpenDisplay\0".as_ptr() as *const libc::c_char));
let dpy = xopen(std::ptr::null());
if dpy.is_null() { libc::dlclose(xtst); libc::dlclose(lib); return; }
X11_DPY = dpy;
X11_KEY = Some(std::mem::transmute(libc::dlsym(xtst, b"XTestFakeKeyEvent\0".as_ptr() as *const libc::c_char)));
X11_FLUSH = Some(std::mem::transmute(libc::dlsym(lib, b"XFlush\0".as_ptr() as *const libc::c_char)));
X11_KEYCODE = Some(std::mem::transmute(libc::dlsym(lib, b"XKeysymToKeycode\0".as_ptr() as *const libc::c_char)));
}
});
unsafe {
if X11_DPY.is_null() || X11_KEY.is_none() { self.send_ctrl_v(); return; }
let dpy = X11_DPY;
let xkey = X11_KEY.unwrap();
let xflush = X11_FLUSH.unwrap();
let xkeycode = X11_KEYCODE.unwrap();
let ctrl_kc = xkeycode(dpy, 0xFFE3);
let v_kc = xkeycode(dpy, 0x0076);
xkey(dpy, ctrl_kc, 1, 0);
xkey(dpy, v_kc, 1, 0);
xkey(dpy, v_kc, 0, 0);
xkey(dpy, ctrl_kc, 0, 0);
xflush(dpy);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
} }
impl Drop for UinputInjector { impl Drop for UinputInjector {
@ -696,6 +583,26 @@ impl Drop for UinputInjector {
} }
} }
fn strip_vn_diacritic(ch: char) -> char {
match ch {
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
'À' | 'Á' | 'Ả' | 'Ã' | 'Ạ' | 'Ă' | 'Ằ' | 'Ắ' | 'Ẳ' | 'Ẵ' | 'Ặ' | 'Â' | 'Ầ' | 'Ấ' | 'Ẩ' | 'Ẫ' | 'Ậ' => 'A',
'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => 'e',
'È' | 'É' | 'Ẻ' | 'Ẽ' | 'Ẹ' | 'Ê' | 'Ề' | 'Ế' | 'Ể' | 'Ễ' | 'Ệ' => 'E',
'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => 'i',
'Ì' | 'Í' | 'Ỉ' | 'Ĩ' | 'Ị' => 'I',
'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => 'o',
'Ò' | 'Ó' | 'Ỏ' | 'Õ' | 'Ọ' | 'Ô' | 'Ồ' | 'Ố' | 'Ổ' | 'Ỗ' | 'Ộ' | 'Ơ' | 'Ờ' | 'Ớ' | 'Ở' | 'Ỡ' | 'Ợ' => 'O',
'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => 'u',
'Ù' | 'Ú' | 'Ủ' | 'Ũ' | 'Ụ' | 'Ư' | 'Ừ' | 'Ứ' | 'Ử' | 'Ữ' | 'Ự' => 'U',
'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => 'y',
'Ỳ' | 'Ý' | 'Ỷ' | 'Ỹ' | 'Ỵ' => 'Y',
'đ' => 'd',
'Đ' => 'D',
other => other,
}
}
fn char_to_linux_keycode(ch: char) -> Option<u16> { fn char_to_linux_keycode(ch: char) -> Option<u16> {
match ch.to_ascii_lowercase() { match ch.to_ascii_lowercase() {
'a' => Some(30), 'a' => Some(30),

View file

@ -64,7 +64,7 @@ struct LookupLib {
display: *mut Display, display: *mut Display,
x_close_display: unsafe extern "C" fn(*mut Display) -> c_int, x_close_display: unsafe extern "C" fn(*mut Display) -> c_int,
x_lookup_string: unsafe extern "C" fn(*mut XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int, x_lookup_string: unsafe extern "C" fn(*mut XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int,
x_utf8_lookup_string: Option<unsafe extern "C" fn(*mut XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int>, x_utf8_lookup_string: Option<unsafe extern "C" fn(*mut c_void, *mut XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int>,
} }
unsafe impl Send for LookupLib {} unsafe impl Send for LookupLib {}
@ -129,6 +129,7 @@ impl LookupLib {
let mut keysym: KeySym = 0; let mut keysym: KeySym = 0;
let len = if let Some(xutf8) = self.x_utf8_lookup_string { let len = if let Some(xutf8) = self.x_utf8_lookup_string {
xutf8( xutf8(
std::ptr::null_mut(),
&mut xke as *mut XKeyEvent, &mut xke as *mut XKeyEvent,
buf.as_mut_ptr() as *mut c_char, buf.as_mut_ptr() as *mut c_char,
buf.len() as c_int, buf.len() as c_int,
@ -225,8 +226,6 @@ impl X11Capture {
/// Wait for events from the C helper pipe with timeout. /// Wait for events from the C helper pipe with timeout.
pub fn wait_for_event(&mut self, timeout_ms: u64) -> bool { pub fn wait_for_event(&mut self, timeout_ms: u64) -> bool {
// If SKIP_RECORD_EVENTS is true, aggressively drain all pending events // If SKIP_RECORD_EVENTS is true, aggressively drain all pending events
// before clearing the flag. This prevents feedback loops where injected
// events arrive after drain_pipe returns but before the flag is cleared.
if SKIP_RECORD_EVENTS.load(Ordering::Relaxed) { if SKIP_RECORD_EVENTS.load(Ordering::Relaxed) {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50); let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
loop { loop {
@ -234,17 +233,15 @@ impl X11Capture {
if std::time::Instant::now() >= deadline { if std::time::Instant::now() >= deadline {
break; break;
} }
// Poll with short timeout to check for more data
let mut pfd = PollFd { let mut pfd = PollFd {
fd: self.pipe_fd, fd: self.pipe_fd,
events: POLLIN, events: POLLIN,
revents: 0, revents: 0,
}; };
unsafe { unsafe {
poll(&mut pfd, 1, 5); // 5ms poll poll(&mut pfd, 1, 5);
} }
if pfd.revents & POLLIN == 0 { if pfd.revents & POLLIN == 0 {
// No more data, check one more time after a tiny sleep
std::thread::sleep(std::time::Duration::from_micros(500)); std::thread::sleep(std::time::Duration::from_micros(500));
self.drain_pipe(); self.drain_pipe();
break; break;
@ -252,14 +249,12 @@ impl X11Capture {
} }
} }
// Normal wait for events
self.drain_pipe(); self.drain_pipe();
if !self.event_queue.is_empty() { if !self.event_queue.is_empty() {
return true; return true;
} }
// Poll the pipe fd
let mut pfd = PollFd { let mut pfd = PollFd {
fd: self.pipe_fd, fd: self.pipe_fd,
events: POLLIN, events: POLLIN,
@ -273,11 +268,8 @@ impl X11Capture {
self.drain_pipe(); self.drain_pipe();
} }
// Check if child is still alive
if let Ok(None) = self.child.try_wait() { if let Ok(None) = self.child.try_wait() {
// Still running
} else { } else {
eprintln!("[vietc] vietc-xrecord process died, restarting...");
self.restart_xrecord(); self.restart_xrecord();
} }
@ -295,10 +287,7 @@ impl X11Capture {
filled += n; filled += n;
while filled >= 8 { while filled >= 8 {
let ev: PipeEvent = unsafe { std::mem::transmute(buf) }; let ev: PipeEvent = unsafe { std::mem::transmute(buf) };
// Skip injected events when flag is set (prevents feedback loops)
if SKIP_RECORD_EVENTS.load(Ordering::Relaxed) { if SKIP_RECORD_EVENTS.load(Ordering::Relaxed) {
// Still handle focus events even during skip
if ev.keycode == 0 && ev.state == 2 { if ev.keycode == 0 && ev.state == 2 {
self.focus_lost = true; self.focus_lost = true;
} }
@ -315,7 +304,6 @@ impl X11Capture {
}; };
self.event_queue.push_back(event); self.event_queue.push_back(event);
} }
filled -= 8; filled -= 8;
if filled > 0 { if filled > 0 {
buf.copy_within(8..8 + filled, 0); buf.copy_within(8..8 + filled, 0);

View file

@ -406,7 +406,7 @@ impl X11Injector {
// Handle SelectionRequest events that come from the paste target // Handle SelectionRequest events that come from the paste target
// Process events with a short spin loop (up to ~50ms) // Process events with a short spin loop (up to ~50ms)
for _ in 0..10 { for _ in 0..4 {
// Brief sleep to let X11 events propagate // Brief sleep to let X11 events propagate
std::thread::sleep(std::time::Duration::from_millis(5)); std::thread::sleep(std::time::Duration::from_millis(5));
self.handle_pending_events(); self.handle_pending_events();

12
uinputd/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "vietc-uinputd"
version = "0.1.0"
edition = "2021"
description = "Viet+ privileged uinput backspace injection daemon"
[[bin]]
name = "vietc-uinputd"
path = "src/main.rs"
[dependencies]
libc = "0.2"

306
uinputd/src/main.rs Normal file
View file

@ -0,0 +1,306 @@
use std::fs;
use std::os::unix::io::AsRawFd;
use std::os::unix::net::{UnixListener, UnixStream};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::Command;
const UINPUT_MAX_NAME_SIZE: usize = 80;
const UI_SET_EVBIT: u64 = 0x40045564;
const UI_SET_KEYBIT: u64 = 0x40045565;
const UI_DEV_CREATE: u64 = 0x5501;
const UI_DEV_DESTROY: u64 = 0x5502;
const UI_DEV_SETUP: u64 = 0x405c5503;
const EV_KEY: u16 = 0x01;
fn ioctl(fd: i32, request: u64, arg: u64) -> Result<i32, String> {
let result = unsafe { libc::ioctl(fd, request, arg) };
if result < 0 {
Err(format!("ioctl failed: {}", std::io::Error::last_os_error()))
} else {
Ok(result)
}
}
#[repr(C)]
struct input_event {
time: libc::timeval,
type_: u16,
code: u16,
value: i32,
}
#[repr(C)]
struct uinput_setup {
id: input_id,
name: [i8; UINPUT_MAX_NAME_SIZE],
ff_effects_max: u32,
}
#[repr(C)]
struct input_id {
bustype: u16,
vendor: u16,
product: u16,
version: u16,
}
struct UinputDevice {
fd: i32,
}
impl UinputDevice {
fn new(name: &str) -> Result<Self, String> {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/uinput")
.map_err(|e| format!("Cannot open /dev/uinput: {} (are you root?)", e))?;
let fd = file.as_raw_fd();
ioctl(fd, UI_SET_EVBIT, EV_KEY as u64)?;
for code in 0..=0x1ffu32 {
ioctl(fd, UI_SET_KEYBIT, code as u64)?;
}
let mut usetup: uinput_setup = unsafe { std::mem::zeroed() };
let name_bytes = name.as_bytes();
let copy_len = name_bytes.len().min(UINPUT_MAX_NAME_SIZE - 1);
for (i, &byte) in name_bytes.iter().enumerate().take(copy_len) {
usetup.name[i] = byte as i8;
}
usetup.id.bustype = 0x03;
usetup.id.vendor = 0x1234;
usetup.id.product = 0x5678;
usetup.id.version = 1;
ioctl(fd, UI_DEV_SETUP, &usetup as *const uinput_setup as u64)?;
ioctl(fd, UI_DEV_CREATE, 0)?;
std::mem::forget(file);
std::thread::sleep(std::time::Duration::from_millis(10));
eprintln!("[vietc-uinputd] Device '{}' created", name);
Ok(Self { fd })
}
fn send_event(&self, type_: u16, code: u16, value: i32) {
let event = input_event {
time: libc::timeval { tv_sec: 0, tv_usec: 0 },
type_,
code,
value,
};
unsafe {
libc::write(self.fd, &event as *const input_event as *const libc::c_void, std::mem::size_of::<input_event>());
}
}
fn send_key(&self, code: u16, value: i32) {
self.send_event(EV_KEY, code, value);
self.send_event(0, 0, 0);
std::thread::sleep(std::time::Duration::from_millis(2));
}
fn backspace_n(&self, count: usize) {
for _ in 0..count {
self.send_key(14, 1);
self.send_key(14, 0);
}
}
fn char_to_keycode(ch: u8) -> Option<(u16, bool)> {
let lower = ch.to_ascii_lowercase();
let keycode = match lower {
b'a' => 30, b'b' => 48, b'c' => 46, b'd' => 32, b'e' => 18,
b'f' => 33, b'g' => 34, b'h' => 35, b'i' => 23, b'j' => 36,
b'k' => 37, b'l' => 38, b'm' => 50, b'n' => 49, b'o' => 24,
b'p' => 25, b'q' => 16, b'r' => 19, b's' => 31, b't' => 20,
b'u' => 22, b'v' => 47, b'w' => 17, b'x' => 45, b'y' => 21,
b'z' => 44,
b'0' => 11, b'1' => 2, b'2' => 3, b'3' => 4, b'4' => 5,
b'5' => 6, b'6' => 7, b'7' => 8, b'8' => 9, b'9' => 10,
b' ' => 57, b'.' => 52, b',' => 51, b'-' => 12, b'=' => 13,
b';' => 39, b'\'' => 40, b'/' => 53, b'\\' => 43,
b'[' => 26, b']' => 27,
_ => return None,
};
let shift = ch.is_ascii_uppercase()
|| matches!(ch, b'!' | b'@' | b'#' | b'$' | b'%' | b'^' | b'&' | b'*'
| b'(' | b')' | b'_' | b'+' | b'{' | b'}' | b'|' | b':' | b'"'
| b'<' | b'>' | b'?' | b'~');
Some((keycode, shift))
}
fn type_ascii(&self, text: &str) {
for byte in text.bytes() {
if let Some((keycode, shift)) = Self::char_to_keycode(byte) {
if shift {
self.send_key(42, 1);
std::thread::sleep(std::time::Duration::from_millis(1));
}
self.send_key(keycode, 1);
self.send_key(keycode, 0);
if shift {
self.send_key(42, 0);
std::thread::sleep(std::time::Duration::from_millis(1));
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
fn paste_unicode(&self, text: &str) {
copy_to_clipboard(text);
self.send_key(29, 1);
std::thread::sleep(std::time::Duration::from_millis(2));
self.send_key(47, 1);
self.send_key(47, 0);
self.send_key(29, 0);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
impl Drop for UinputDevice {
fn drop(&mut self) {
let _ = unsafe { libc::ioctl(self.fd, UI_DEV_DESTROY, 0) };
let _ = unsafe { libc::close(self.fd) };
eprintln!("[vietc-uinputd] Device destroyed");
}
}
fn copy_to_clipboard(text: &str) {
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
if is_wayland {
if let Ok(mut child) = Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
}
} else {
if let Ok(mut child) = Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
}
}
}
fn find_socket_path() -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
let dir = format!("{}/.vietc", home);
let _ = fs::create_dir_all(&dir);
if unsafe { libc::getuid() == 0 } {
let socket = format!("{}/uinput.sock", dir);
unsafe {
let _ = libc::chown(
socket.as_ptr() as *const libc::c_char,
0,
0,
);
}
socket
} else {
format!("{}/uinput.sock", dir)
}
}
fn handle_client(stream: UnixStream, uinput: &UinputDevice) {
let reader = BufReader::new(&stream);
let mut writer = &stream;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let line = line.trim().to_string();
if line.is_empty() { continue; }
if line == "PING" {
let _ = writeln!(writer, "PONG");
} else if line == "FLUSH" {
let _ = writeln!(writer, "OK");
} else if line == "QUIT" {
let _ = writeln!(writer, "BYE");
break;
} else if let Some(n_str) = line.strip_prefix("BACKSPACE:") {
if let Ok(n) = n_str.parse::<usize>() {
uinput.backspace_n(n);
let _ = writeln!(writer, "OK");
} else {
let _ = writeln!(writer, "ERR bad count");
}
} else if let Some(text) = line.strip_prefix("TYPE:") {
let is_ascii = text.bytes().all(|b| UinputDevice::char_to_keycode(b).is_some());
if is_ascii {
uinput.type_ascii(text);
} else {
uinput.paste_unicode(text);
}
let _ = writeln!(writer, "OK");
} else if let Some(text) = line.strip_prefix("PASTE:") {
uinput.paste_unicode(text);
let _ = writeln!(writer, "OK");
} else {
let _ = writeln!(writer, "ERR unknown command");
}
}
}
fn main() {
let socket_path = find_socket_path();
let path = Path::new(&socket_path);
let _ = fs::remove_file(path);
let listener = match UnixListener::bind(path) {
Ok(l) => l,
Err(e) => {
eprintln!("[vietc-uinputd] Cannot bind socket {}: {}", socket_path, e);
std::process::exit(1);
}
};
// Make socket world-writable so non-root daemon can connect
unsafe {
let _ = libc::chmod(
socket_path.as_ptr() as *const libc::c_char,
0o666,
);
}
let uinput = match UinputDevice::new("vietc") {
Ok(d) => d,
Err(e) => {
eprintln!("[vietc-uinputd] {}", e);
std::process::exit(1);
}
};
eprintln!("[vietc-uinputd] Listening on {}", socket_path);
for stream in listener.incoming() {
match stream {
Ok(stream) => {
handle_client(stream, &uinput);
}
Err(e) => {
eprintln!("[vietc-uinputd] Connection error: {}", e);
}
}
}
}