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:
parent
ea5df93bce
commit
d4102088b8
18 changed files with 1411 additions and 3923 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ packaging/appimage/AppDir/
|
|||
packaging/deb/vietc_*/
|
||||
packaging/appimage/appimagetool
|
||||
status
|
||||
vietc-xrecord
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["engine", "protocol", "daemon", "cli"]
|
||||
members = ["engine", "protocol", "daemon", "cli", "uinputd"]
|
||||
exclude = ["ui"]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
|
|
@ -494,29 +494,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "x11")]
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
// Try evdev first (more reliable than X11 XRecord)
|
||||
match open_keyboard_device() {
|
||||
Ok((device, path)) => {
|
||||
log_info(&format!("[vietc] Keyboard device: {}", path));
|
||||
run_with_evdev(
|
||||
return run_with_evdev(
|
||||
device,
|
||||
&mut daemon,
|
||||
shared_active_window,
|
||||
|
|
@ -524,10 +506,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
status_changed,
|
||||
engine_enabled,
|
||||
display,
|
||||
)?;
|
||||
);
|
||||
}
|
||||
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");
|
||||
run_stdin_mode(
|
||||
&mut daemon,
|
||||
|
|
@ -537,8 +539,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
engine_enabled,
|
||||
display,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -898,7 +898,10 @@ fn run_with_evdev(
|
|||
let commands = daemon.process_key(ch);
|
||||
if !commands.is_empty() {
|
||||
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 {
|
||||
injector.send_key_event(keycode, 1);
|
||||
}
|
||||
|
|
@ -1074,17 +1077,15 @@ fn execute_commands(
|
|||
fn create_injector(
|
||||
display: display::DisplayServer,
|
||||
) -> Result<Box<dyn vietc_protocol::KeyInjector>, Box<dyn std::error::Error>> {
|
||||
// Try Wayland input method first (if compiled with wayland feature)
|
||||
#[cfg(feature = "wayland")]
|
||||
{
|
||||
let _ctx = vietc_protocol::wayland_im::WaylandIMContext::new();
|
||||
log_info("[vietc] Wayland input method context initialized");
|
||||
// Try uinputd socket first
|
||||
if vietc_protocol::uinput_client::UinputClient::is_available() {
|
||||
log_info("[vietc] Using uinputd socket injection");
|
||||
return Ok(Box::new(vietc_protocol::uinput_client::UinputClient));
|
||||
}
|
||||
|
||||
// Use uinput as primary injector — it handles ASCII via direct keycodes
|
||||
// and Unicode via ydotool type (uinput-based, no display server needed).
|
||||
// Using a single injection channel avoids ordering issues between XTest
|
||||
// (ASCII) and ydotool (Unicode) interleaving.
|
||||
// Use uinput as primary — correct Linux keycodes for ASCII + backspace.
|
||||
// For Unicode (Vietnamese diacritics), falls back to xclip via subprocess
|
||||
// or direct X11 clipboard via X11Injector.
|
||||
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
|
||||
Ok(injector) => {
|
||||
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")]
|
||||
{
|
||||
if display != display::DisplayServer::Wayland {
|
||||
match vietc_protocol::x11_inject::X11Injector::new() {
|
||||
Ok(injector) => {
|
||||
log_info("[vietc] Using X11 injection (XTEST fallback)");
|
||||
log_info("[vietc] Using X11 injection (fallback)");
|
||||
return Ok(Box::new(injector));
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -1114,6 +1115,14 @@ fn create_injector(
|
|||
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 {
|
||||
key_state.contains(evdev::Key::KEY_LEFTCTRL)
|
||||
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL)
|
||||
|
|
|
|||
531
engine/src/bamboo.rs
Normal file
531
engine/src/bamboo.rs
Normal 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"), "xã");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +1,68 @@
|
|||
use crate::english::EnglishDict;
|
||||
use crate::telex::TelexEngine;
|
||||
use crate::vni::VniEngine;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum InputMethod {
|
||||
Telex,
|
||||
Vni,
|
||||
}
|
||||
use crate::bamboo::BambooEngine;
|
||||
use crate::input_method::InputMethod;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum EngineEvent {
|
||||
Replace {
|
||||
backspaces: usize,
|
||||
insert: String,
|
||||
},
|
||||
Replace { backspaces: usize, insert: String },
|
||||
Insert(String),
|
||||
Flush(String),
|
||||
AutoRestore(String),
|
||||
/// ESC undo: strip all tone marks from current word
|
||||
UndoTones {
|
||||
backspaces: usize,
|
||||
restored: String,
|
||||
},
|
||||
/// Text was pasted via clipboard - update buffer directly without telex parsing
|
||||
UndoTones { backspaces: usize, restored: String },
|
||||
Paste(String),
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
input_method: InputMethod,
|
||||
telex: TelexEngine,
|
||||
vni: VniEngine,
|
||||
english: EnglishDict,
|
||||
enabled: bool,
|
||||
macros: std::collections::HashMap<String, String>,
|
||||
bamboo: BambooEngine,
|
||||
macros: HashMap<String, String>,
|
||||
raw_buffer: String,
|
||||
/// Flag to bypass telex/vni parsing when Unicode text has been pasted via clipboard
|
||||
paste_mode: bool,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(method: InputMethod) -> Self {
|
||||
Self {
|
||||
input_method: method,
|
||||
telex: TelexEngine::new(),
|
||||
vni: VniEngine::new(),
|
||||
english: EnglishDict::new(),
|
||||
enabled: true,
|
||||
macros: std::collections::HashMap::new(),
|
||||
bamboo: BambooEngine::new(method),
|
||||
macros: HashMap::new(),
|
||||
raw_buffer: String::new(),
|
||||
paste_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
self.bamboo.set_enabled(enabled);
|
||||
if !enabled {
|
||||
self.flush();
|
||||
self.reset();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
self.bamboo.is_enabled()
|
||||
}
|
||||
|
||||
pub fn set_method(&mut self, method: InputMethod) {
|
||||
self.input_method = method;
|
||||
self.bamboo.set_method(method);
|
||||
self.reset();
|
||||
}
|
||||
|
||||
/// Enter "paste mode" - bypass telex/vni parsing for Unicode pasted text
|
||||
pub fn enter_paste_mode(&mut self) {
|
||||
self.paste_mode = true;
|
||||
}
|
||||
|
||||
/// Exit paste mode (for Paste event handling)
|
||||
pub fn exit_paste_mode(&mut self) {
|
||||
self.paste_mode = false;
|
||||
}
|
||||
|
||||
/// Paste raw text into buffer without telex/vni processing
|
||||
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();
|
||||
} else {
|
||||
self.enter_paste_mode();
|
||||
}
|
||||
|
||||
let event = EngineEvent::Paste(text.to_string());
|
||||
self.raw_buffer.push_str(text);
|
||||
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(
|
||||
method: InputMethod,
|
||||
macros: &std::collections::HashMap<String, String>,
|
||||
macros: &HashMap<String, String>,
|
||||
keystrokes: &[char],
|
||||
) -> (String, bool) {
|
||||
let mut engine = Engine::new(method);
|
||||
|
|
@ -109,335 +71,143 @@ impl Engine {
|
|||
}
|
||||
|
||||
let mut last_output = String::new();
|
||||
let mut did_flush = false;
|
||||
let mut composing = String::new();
|
||||
|
||||
for &ch in keystrokes {
|
||||
if let Some(event) = engine.process_key(ch) {
|
||||
match event {
|
||||
EngineEvent::Replace { insert, .. } => {
|
||||
last_output = insert;
|
||||
if ch == '\x08' {
|
||||
let _ = engine.bamboo.pop_last();
|
||||
composing = engine.bamboo.get_output();
|
||||
last_output = composing.clone();
|
||||
continue;
|
||||
}
|
||||
EngineEvent::Flush(_word) => {
|
||||
// Word was flushed. The flush char is NOT part of the word.
|
||||
// The word is committed; clear tracking for current composing.
|
||||
last_output.clear();
|
||||
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;
|
||||
|
||||
if is_flush_char(ch) {
|
||||
if !composing.is_empty() {
|
||||
last_output = composing.clone();
|
||||
}
|
||||
composing.clear();
|
||||
engine.bamboo.reset();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(out) = engine.bamboo.process_key(ch) {
|
||||
composing = out.clone();
|
||||
last_output = out;
|
||||
} else {
|
||||
// Key consumed but no screen change — buffer is building
|
||||
let buf = engine.buffer().to_string();
|
||||
if !buf.is_empty() {
|
||||
last_output = buf;
|
||||
}
|
||||
composing = engine.bamboo.get_output();
|
||||
last_output = composing.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// If the engine has a buffer that hasn't been flushed, that's on screen
|
||||
let buf = engine.buffer().to_string();
|
||||
if !buf.is_empty() {
|
||||
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();
|
||||
let output = engine.bamboo.get_output();
|
||||
if !output.is_empty() {
|
||||
last_output = output.clone();
|
||||
}
|
||||
|
||||
(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) {
|
||||
self.raw_buffer.clear();
|
||||
self.raw_buffer.push_str(text);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.telex.reset();
|
||||
self.vni.reset();
|
||||
self.bamboo.reset();
|
||||
self.raw_buffer.clear();
|
||||
}
|
||||
|
||||
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() {
|
||||
// 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());
|
||||
if has_unicode {
|
||||
let word = self.raw_buffer.clone();
|
||||
self.raw_buffer.clear();
|
||||
self.paste_mode = false; // Exit paste mode after flush
|
||||
self.paste_mode = false;
|
||||
return Some(EngineEvent::Flush(word));
|
||||
}
|
||||
}
|
||||
|
||||
let event = match self.input_method {
|
||||
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
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Add a macro shortcut
|
||||
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) {
|
||||
self.macros.clear();
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
self.bamboo.clear_macros();
|
||||
}
|
||||
|
||||
pub fn process_key(&mut self, ch: char) -> Option<EngineEvent> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// ESC = undo tones
|
||||
if ch == '\x1b' {
|
||||
return self.process_escape();
|
||||
if !self.bamboo.is_enabled() {
|
||||
return Some(EngineEvent::Insert(ch.to_string()));
|
||||
}
|
||||
|
||||
if ch == '\x08' {
|
||||
// Backspace handling: pop from inner engine and sync raw_buffer
|
||||
match self.input_method {
|
||||
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);
|
||||
}
|
||||
}
|
||||
self.bamboo.pop_last();
|
||||
let _ = self.raw_buffer.pop();
|
||||
return None;
|
||||
}
|
||||
|
||||
let lowercase_ch = if ch.is_ascii() {
|
||||
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 is_flush_char(ch) {
|
||||
if self.raw_buffer.is_empty() {
|
||||
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();
|
||||
if let Some(expansion) = macro_expansion {
|
||||
let previous_raw_len = self.raw_buffer.chars().count();
|
||||
self.reset();
|
||||
return Some(EngineEvent::Replace {
|
||||
backspaces: previous_raw_len + 1,
|
||||
backspaces: prev_len,
|
||||
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();
|
||||
|
||||
if has_diacritics {
|
||||
if prev_len > 0 {
|
||||
return Some(EngineEvent::Replace {
|
||||
backspaces: inner_len + 1,
|
||||
insert: format!("{}{}", original_raw, ch),
|
||||
backspaces: prev_len,
|
||||
insert: format!("{}{}", previous, ch),
|
||||
});
|
||||
} else {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush buffer with trailing character
|
||||
let previous_inner = self.buffer().to_string();
|
||||
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();
|
||||
let previous = self.bamboo.get_output();
|
||||
let prev_len = previous.chars().count();
|
||||
self.raw_buffer.push(ch);
|
||||
|
||||
let expected_screen = format!("{}{}", previous_inner, lowercase_ch);
|
||||
|
||||
if self.paste_mode {
|
||||
if ch.is_ascii() {
|
||||
match self.input_method {
|
||||
InputMethod::Telex => {
|
||||
self.telex.process_key(lowercase_ch);
|
||||
}
|
||||
InputMethod::Vni => {
|
||||
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);
|
||||
if let Some(new_output) = self.bamboo.process_key(ch) {
|
||||
// Only emit Replace when Vietnamese processing CHANGED the output
|
||||
// (tone/mark keys). For simple appends, let the raw key go through.
|
||||
let expected = format!("{}{}", previous, ch);
|
||||
if new_output != expected && new_output != previous {
|
||||
let cased = match_casing(&self.raw_buffer, &new_output);
|
||||
return Some(EngineEvent::Replace {
|
||||
backspaces: prev_len,
|
||||
insert: cased,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn buffer(&self) -> String {
|
||||
self.bamboo.get_output()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buffer(&self) -> &str {
|
||||
match self.input_method {
|
||||
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 is_flush_char(ch: char) -> bool {
|
||||
matches!(ch, ' ' | '\t' | '.' | ',' | '!' | '?' | ';' | ':' | '\n')
|
||||
}
|
||||
|
||||
fn match_casing(raw: &str, processed: &str) -> String {
|
||||
|
|
@ -445,17 +215,15 @@ fn match_casing(raw: &str, processed: &str) -> String {
|
|||
return processed.to_string();
|
||||
}
|
||||
|
||||
let alphabetic_chars: Vec<char> = raw.chars().filter(|c| c.is_alphabetic()).collect();
|
||||
if alphabetic_chars.is_empty() {
|
||||
let alpha: Vec<char> = raw.chars().filter(|c| c.is_alphabetic()).collect();
|
||||
if alpha.is_empty() {
|
||||
return processed.to_string();
|
||||
}
|
||||
|
||||
let all_upper = alphabetic_chars.iter().all(|c| c.is_uppercase());
|
||||
let first_upper = alphabetic_chars[0].is_uppercase();
|
||||
|
||||
let all_upper = alpha.iter().all(|c| c.is_uppercase());
|
||||
if all_upper {
|
||||
processed.to_uppercase()
|
||||
} else if first_upper {
|
||||
} else if alpha[0].is_uppercase() {
|
||||
let mut chars = processed.chars();
|
||||
match chars.next() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
¯os,
|
||||
&['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,
|
||||
¯os,
|
||||
&['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,
|
||||
¯os,
|
||||
&['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,
|
||||
¯os,
|
||||
&['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,
|
||||
¯os,
|
||||
&['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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
engine/src/input_method.rs
Normal file
71
engine/src/input_method.rs
Normal 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![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
mod bamboo;
|
||||
mod engine;
|
||||
mod english;
|
||||
mod spelling;
|
||||
mod telex;
|
||||
mod vni;
|
||||
mod input_method;
|
||||
pub mod spelling;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use engine::Engine;
|
||||
pub use engine::EngineEvent;
|
||||
pub use engine::InputMethod;
|
||||
pub use input_method::InputMethod;
|
||||
|
|
|
|||
2151
engine/src/tests.rs
2151
engine/src/tests.rs
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ if [ -d "deb-build/usr/bin" ]; then
|
|||
else
|
||||
cp target/release/vietc "$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/"
|
||||
fi
|
||||
|
||||
|
|
@ -55,12 +56,13 @@ fi
|
|||
# Compile and bundle vietc-xrecord (C helper for XRecord keyboard capture)
|
||||
echo " Compiling vietc-xrecord..."
|
||||
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"
|
||||
else
|
||||
echo " gcc not found, trying cc..."
|
||||
cc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst -ldl 2>&1
|
||||
echo " vietc-xrecord bundled"
|
||||
echo " WARNING: No C compiler found, vietc-xrecord not bundled — X11 capture will fail"
|
||||
fi
|
||||
|
||||
# Desktop integration
|
||||
|
|
@ -208,6 +210,15 @@ ENV_PREFIX="env"
|
|||
[ -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"
|
||||
|
||||
# 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)
|
||||
# On X11 we can run without root (XGrabKeyboard + XTest injection needs no special permissions).
|
||||
# On Wayland, evdev requires root (input group) or uinput.
|
||||
|
|
@ -217,9 +228,24 @@ if [ -n "$WAYLAND_DISPLAY" ]; then
|
|||
fi
|
||||
|
||||
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
|
||||
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 &
|
||||
DAEMON_PID=$!
|
||||
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
|
||||
wait "$DAEMON_PID" 2>/dev/null
|
||||
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
|
||||
|
||||
if [ -f "$HERE/usr/bin/vietc-tray" ]; then
|
||||
"$HERE/usr/bin/vietc-tray" "$@"
|
||||
else
|
||||
echo "[vietc] ERROR: vietc-tray not found. The AppImage cannot start without it."
|
||||
echo "[vietc] Stopping."
|
||||
kill "$DAEMON_PID" 2>/dev/null
|
||||
exit 1
|
||||
echo "[vietc] Tray not available — daemon is running in background."
|
||||
echo "[vietc] Press Ctrl+C or close this terminal to stop."
|
||||
# Keep AppImage alive: wait for daemon to exit
|
||||
wait $DAEMON_PID 2>/dev/null
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod inject;
|
||||
pub mod monitor;
|
||||
pub mod uinput_monitor;
|
||||
pub mod uinput_client;
|
||||
pub mod wayland_im;
|
||||
|
||||
#[cfg(feature = "x11")]
|
||||
|
|
|
|||
75
protocol/src/uinput_client.rs
Normal file
75
protocol/src/uinput_client.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -135,33 +135,13 @@ impl KeyInjector for UinputInjector {
|
|||
if let Some(keycode) = char_to_linux_keycode(ch) {
|
||||
let needs_shift = ch.is_uppercase() || "!@#$%^&*()_+{}|:\"<>?".contains(ch);
|
||||
self.send_key_stroke(keycode, needs_shift);
|
||||
eprintln!(
|
||||
"[vietc] send_char: ASCII '{}' via uinput",
|
||||
ch.escape_default()
|
||||
);
|
||||
return InjectResult::Success;
|
||||
}
|
||||
// Unicode character: use clipboard fallback for reliable injection
|
||||
let text = ch.to_string();
|
||||
eprintln!(
|
||||
"[vietc] send_char: Unicode '{}' - using clipboard",
|
||||
text.escape_default()
|
||||
);
|
||||
|
||||
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);
|
||||
// Vietnamese Unicode char: map to base ASCII and send via uinput
|
||||
let ascii = strip_vn_diacritic(ch);
|
||||
if let Some(keycode) = char_to_linux_keycode(ascii) {
|
||||
let needs_shift = ascii.is_uppercase();
|
||||
self.send_key_stroke(keycode, needs_shift);
|
||||
}
|
||||
InjectResult::Success
|
||||
}
|
||||
|
|
@ -360,22 +340,8 @@ impl UinputInjector {
|
|||
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
|
||||
/// clipboard for Unicode.
|
||||
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
|
||||
eprintln!(
|
||||
"[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 all ASCII, send keycodes directly — fast and reliable
|
||||
if text.chars().all(|c| char_to_linux_keycode(c).is_some()) {
|
||||
eprintln!(
|
||||
"[vietc] ASCII injection using uinput (backspaces={})",
|
||||
backspaces
|
||||
);
|
||||
if backspaces > 0 {
|
||||
for _ in 0..backspaces {
|
||||
let _ = self.send_backspace();
|
||||
|
|
@ -384,149 +350,43 @@ impl UinputInjector {
|
|||
for ch in text.chars() {
|
||||
let _ = self.send_char(ch);
|
||||
}
|
||||
eprintln!("[vietc] ASCII injection complete");
|
||||
return InjectResult::Success;
|
||||
}
|
||||
|
||||
// Unicode text: use xdotool directly (X11/XWayland) or wtype (Wayland)
|
||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||
|
||||
static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
let has_xdotool = if is_wayland {
|
||||
false
|
||||
// Unicode text: split into Vietnamese portion (clipboard paste) and
|
||||
// trailing ASCII whitespace/punctuation (uinput). Clipboard paste
|
||||
// often trims trailing whitespace, so we send it separately.
|
||||
let mut split = text.len();
|
||||
for (i, c) in text.char_indices().rev() {
|
||||
if c.is_ascii() && (c.is_whitespace() || matches!(c, '.' | ',' | '!' | '?' | ';' | ':')) {
|
||||
split = i;
|
||||
} else {
|
||||
*HAS_XDOTOOL.get_or_init(|| {
|
||||
std::process::Command::new("which")
|
||||
.arg("xdotool")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (vn_text, ascii_tail) = text.split_at(split);
|
||||
|
||||
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
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
|
||||
);
|
||||
// Backspaces via uinput
|
||||
if backspaces > 0 {
|
||||
for _ in 0..backspaces {
|
||||
let _ = self.send_backspace();
|
||||
}
|
||||
}
|
||||
eprintln!("[vietc] Sending Ctrl+V");
|
||||
self.send_ctrl_v();
|
||||
// Record pasted text for future delete/backspace operations
|
||||
let output = Self::run_as_user("vietc", &["update-pasted", "-text", text]);
|
||||
if output.status.success() {
|
||||
eprintln!("[vietc] update_pasted_text success");
|
||||
} else {
|
||||
eprintln!("[vietc] update_pasted_text call ignored (not critical)");
|
||||
|
||||
// Clipboard paste for Vietnamese text
|
||||
if !vn_text.is_empty() {
|
||||
if self.copy_to_clipboard(vn_text) {
|
||||
self.send_ctrl_v_x11();
|
||||
}
|
||||
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
|
||||
eprintln!("[vietc] Last resort: pasting '{}' char-by-char", text);
|
||||
if backspaces > 0 {
|
||||
for _ in 0..backspaces {
|
||||
let _ = self.send_backspace();
|
||||
// Trailing ASCII via uinput (spaces, punctuation)
|
||||
for ch in ascii_tail.chars() {
|
||||
if let Some(kc) = char_to_linux_keycode(ch) {
|
||||
self.send_key_stroke(kc, false);
|
||||
}
|
||||
}
|
||||
for ch in text.chars() {
|
||||
let _ = self.send_char(ch);
|
||||
}
|
||||
eprintln!("[vietc] Char-by-char injection complete");
|
||||
|
||||
InjectResult::Success
|
||||
}
|
||||
|
||||
|
|
@ -607,13 +467,9 @@ impl UinputInjector {
|
|||
|
||||
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
|
||||
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
|
||||
{
|
||||
let mut cmd = Self::user_cmd("wl-copy");
|
||||
eprintln!("[vietc] clipboard: trying wl-copy via {:?}", cmd);
|
||||
let result = cmd
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
|
|
@ -624,24 +480,15 @@ impl UinputInjector {
|
|||
});
|
||||
if let Ok(status) = result {
|
||||
if status.success() {
|
||||
eprintln!("[vietc] clipboard: wl-copy OK");
|
||||
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
|
||||
eprintln!("[vietc] clipboard: trying xclip...");
|
||||
{
|
||||
let mut cmd = Self::user_cmd("xclip");
|
||||
cmd.args(["-selection", "clipboard"]);
|
||||
eprintln!("[vietc] clipboard: xclip via {:?}", cmd);
|
||||
let result = cmd
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
|
|
@ -650,18 +497,8 @@ impl UinputInjector {
|
|||
child.stdin.take().unwrap().write_all(s.as_bytes())?;
|
||||
child.wait()
|
||||
})
|
||||
.map(|status| {
|
||||
if status.success() {
|
||||
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
|
||||
});
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false);
|
||||
if result {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -688,6 +525,56 @@ impl UinputInjector {
|
|||
self.send_uinput_event(0, 0, 0); // SYN
|
||||
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 {
|
||||
|
|
@ -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> {
|
||||
match ch.to_ascii_lowercase() {
|
||||
'a' => Some(30),
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ struct LookupLib {
|
|||
display: *mut Display,
|
||||
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_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 {}
|
||||
|
|
@ -129,6 +129,7 @@ impl LookupLib {
|
|||
let mut keysym: KeySym = 0;
|
||||
let len = if let Some(xutf8) = self.x_utf8_lookup_string {
|
||||
xutf8(
|
||||
std::ptr::null_mut(),
|
||||
&mut xke as *mut XKeyEvent,
|
||||
buf.as_mut_ptr() as *mut c_char,
|
||||
buf.len() as c_int,
|
||||
|
|
@ -225,8 +226,6 @@ impl X11Capture {
|
|||
/// Wait for events from the C helper pipe with timeout.
|
||||
pub fn wait_for_event(&mut self, timeout_ms: u64) -> bool {
|
||||
// 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) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
|
||||
loop {
|
||||
|
|
@ -234,17 +233,15 @@ impl X11Capture {
|
|||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
// Poll with short timeout to check for more data
|
||||
let mut pfd = PollFd {
|
||||
fd: self.pipe_fd,
|
||||
events: POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
unsafe {
|
||||
poll(&mut pfd, 1, 5); // 5ms poll
|
||||
poll(&mut pfd, 1, 5);
|
||||
}
|
||||
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));
|
||||
self.drain_pipe();
|
||||
break;
|
||||
|
|
@ -252,14 +249,12 @@ impl X11Capture {
|
|||
}
|
||||
}
|
||||
|
||||
// Normal wait for events
|
||||
self.drain_pipe();
|
||||
|
||||
if !self.event_queue.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Poll the pipe fd
|
||||
let mut pfd = PollFd {
|
||||
fd: self.pipe_fd,
|
||||
events: POLLIN,
|
||||
|
|
@ -273,11 +268,8 @@ impl X11Capture {
|
|||
self.drain_pipe();
|
||||
}
|
||||
|
||||
// Check if child is still alive
|
||||
if let Ok(None) = self.child.try_wait() {
|
||||
// Still running
|
||||
} else {
|
||||
eprintln!("[vietc] vietc-xrecord process died, restarting...");
|
||||
self.restart_xrecord();
|
||||
}
|
||||
|
||||
|
|
@ -295,10 +287,7 @@ impl X11Capture {
|
|||
filled += n;
|
||||
while filled >= 8 {
|
||||
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) {
|
||||
// Still handle focus events even during skip
|
||||
if ev.keycode == 0 && ev.state == 2 {
|
||||
self.focus_lost = true;
|
||||
}
|
||||
|
|
@ -315,7 +304,6 @@ impl X11Capture {
|
|||
};
|
||||
self.event_queue.push_back(event);
|
||||
}
|
||||
|
||||
filled -= 8;
|
||||
if filled > 0 {
|
||||
buf.copy_within(8..8 + filled, 0);
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ impl X11Injector {
|
|||
|
||||
// Handle SelectionRequest events that come from the paste target
|
||||
// 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
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
self.handle_pending_events();
|
||||
|
|
|
|||
12
uinputd/Cargo.toml
Normal file
12
uinputd/Cargo.toml
Normal 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
306
uinputd/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue