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/deb/vietc_*/
|
||||||
packaging/appimage/appimagetool
|
packaging/appimage/appimagetool
|
||||||
status
|
status
|
||||||
|
vietc-xrecord
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
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::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,
|
|
||||||
¯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 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;
|
||||||
|
|
|
||||||
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
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
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) {
|
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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
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