Optimize typing performance and preserve casing on replaced syllables
This commit is contained in:
parent
5d7488ea7c
commit
5f8465783a
3 changed files with 202 additions and 45 deletions
|
|
@ -448,6 +448,7 @@ fn run_with_evdev(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let caps = is_caps_lock_on(&device);
|
||||||
let key_state = device.get_key_state().ok();
|
let key_state = device.get_key_state().ok();
|
||||||
let events = device.fetch_events()?;
|
let events = device.fetch_events()?;
|
||||||
last_event_time = std::time::Instant::now();
|
last_event_time = std::time::Instant::now();
|
||||||
|
|
@ -530,7 +531,13 @@ fn run_with_evdev(
|
||||||
if consumed_keys.contains(&keycode) {
|
if consumed_keys.contains(&keycode) {
|
||||||
consumed_keys.remove(&keycode);
|
consumed_keys.remove(&keycode);
|
||||||
}
|
}
|
||||||
if let Some(ch) = key_to_char(key) {
|
if let Some(mut ch) = key_to_char(key) {
|
||||||
|
let shift = is_modifier_held_shift(&key_state);
|
||||||
|
if ch.is_ascii_alphabetic() {
|
||||||
|
if shift ^ caps {
|
||||||
|
ch = ch.to_ascii_uppercase();
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
|
@ -745,6 +752,22 @@ fn is_modifier_pressed(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> b
|
||||||
|| key_state.contains(evdev::Key::KEY_RIGHTMETA)
|
|| key_state.contains(evdev::Key::KEY_RIGHTMETA)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_modifier_held_shift(key_state: &Option<evdev::AttributeSet<evdev::Key>>) -> bool {
|
||||||
|
let ks = match key_state {
|
||||||
|
Some(ks) => ks,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
ks.contains(evdev::Key::KEY_LEFTSHIFT) || ks.contains(evdev::Key::KEY_RIGHTSHIFT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_caps_lock_on(device: &evdev::Device) -> bool {
|
||||||
|
if let Ok(leds) = device.get_led_state() {
|
||||||
|
leds.contains(evdev::LedType::LED_CAPSL)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_toggle_combination_state(key_state: &Option<evdev::AttributeSet<evdev::Key>>, key: &str) -> bool {
|
fn is_toggle_combination_state(key_state: &Option<evdev::AttributeSet<evdev::Key>>, key: &str) -> bool {
|
||||||
let key_state = match key_state {
|
let key_state = match key_state {
|
||||||
Some(ks) => ks,
|
Some(ks) => ks,
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,16 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn flush(&mut self) -> Option<EngineEvent> {
|
pub fn flush(&mut self) -> Option<EngineEvent> {
|
||||||
match self.input_method {
|
let event = match self.input_method {
|
||||||
InputMethod::Telex => self.telex.flush(),
|
InputMethod::Telex => self.telex.flush(),
|
||||||
InputMethod::Vni => self.vni.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,15 +102,16 @@ impl Engine {
|
||||||
let stripped = strip_diacritics(buffer);
|
let stripped = strip_diacritics(buffer);
|
||||||
let backspaces = buffer.chars().count();
|
let backspaces = buffer.chars().count();
|
||||||
let had_tones = stripped != buffer;
|
let had_tones = stripped != buffer;
|
||||||
|
let cased_stripped = match_casing(&self.raw_buffer, &stripped);
|
||||||
self.reset();
|
self.reset();
|
||||||
|
|
||||||
if had_tones {
|
if had_tones {
|
||||||
Some(EngineEvent::UndoTones {
|
Some(EngineEvent::UndoTones {
|
||||||
backspaces,
|
backspaces,
|
||||||
restored: stripped,
|
restored: cased_stripped,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Some(EngineEvent::Flush(stripped))
|
Some(EngineEvent::Flush(cased_stripped))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,15 +145,21 @@ impl Engine {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ch == ' ' || ch == '\t' || ch == '.' || ch == ',' || ch == '!' || ch == '?'
|
let lowercase_ch = if ch.is_ascii() {
|
||||||
|| ch == ';' || ch == ':' || ch == '\n'
|
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
|
// Check for macro expansion before auto-restore
|
||||||
let macro_expansion = self.macros.get(&self.raw_buffer).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();
|
let previous_raw_len = self.raw_buffer.chars().count();
|
||||||
self.reset();
|
self.reset();
|
||||||
|
|
@ -180,13 +194,14 @@ impl Engine {
|
||||||
let previous_inner = self.buffer().to_string();
|
let previous_inner = self.buffer().to_string();
|
||||||
let previous_inner_len = previous_inner.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 flush_event = self.flush();
|
||||||
let mut final_word = previous_inner.clone();
|
let mut final_word = previous_inner_cased.clone();
|
||||||
if let Some(EngineEvent::Flush(word)) = flush_event {
|
if let Some(EngineEvent::Flush(word)) = flush_event {
|
||||||
final_word = word;
|
final_word = word;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = if final_word != previous_inner {
|
let result = if final_word != previous_inner_cased {
|
||||||
Some(EngineEvent::Replace {
|
Some(EngineEvent::Replace {
|
||||||
backspaces: previous_inner_len + 1,
|
backspaces: previous_inner_len + 1,
|
||||||
insert: format!("{}{}", final_word, ch),
|
insert: format!("{}{}", final_word, ch),
|
||||||
|
|
@ -204,17 +219,18 @@ impl Engine {
|
||||||
self.raw_buffer.push(ch);
|
self.raw_buffer.push(ch);
|
||||||
|
|
||||||
match self.input_method {
|
match self.input_method {
|
||||||
InputMethod::Telex => { self.telex.process_key(ch); }
|
InputMethod::Telex => { self.telex.process_key(lowercase_ch); }
|
||||||
InputMethod::Vni => { self.vni.process_key(ch); }
|
InputMethod::Vni => { self.vni.process_key(lowercase_ch); }
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_inner = self.buffer().to_string();
|
let new_inner = self.buffer().to_string();
|
||||||
let expected_screen = format!("{}{}", previous_inner, ch);
|
let expected_screen = format!("{}{}", previous_inner, lowercase_ch);
|
||||||
|
|
||||||
if new_inner != expected_screen {
|
if new_inner != expected_screen {
|
||||||
|
let cased_inner = match_casing(&self.raw_buffer, &new_inner);
|
||||||
Some(EngineEvent::Replace {
|
Some(EngineEvent::Replace {
|
||||||
backspaces: previous_inner.chars().count() + 1,
|
backspaces: previous_inner.chars().count() + 1,
|
||||||
insert: new_inner,
|
insert: cased_inner,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -265,6 +281,32 @@ fn strip_diacritics(s: &str) -> String {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn match_casing(raw: &str, processed: &str) -> String {
|
||||||
|
if raw.is_empty() || processed.is_empty() {
|
||||||
|
return processed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let alphabetic_chars: Vec<char> = raw.chars().filter(|c| c.is_alphabetic()).collect();
|
||||||
|
if alphabetic_chars.is_empty() {
|
||||||
|
return processed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_upper = alphabetic_chars.iter().all(|c| c.is_uppercase());
|
||||||
|
let first_upper = alphabetic_chars[0].is_uppercase();
|
||||||
|
|
||||||
|
if all_upper {
|
||||||
|
processed.to_uppercase()
|
||||||
|
} else if first_upper {
|
||||||
|
let mut chars = processed.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
None => processed.to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
processed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -318,4 +360,41 @@ mod tests {
|
||||||
|
|
||||||
assert!(output.contains("không"));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ impl UinputInjector {
|
||||||
|
|
||||||
if let Ok(content) = std::fs::read_to_string("/proc/self/loginuid") {
|
if let Ok(content) = std::fs::read_to_string("/proc/self/loginuid") {
|
||||||
if let Ok(uid) = content.trim().parse::<u32>() {
|
if let Ok(uid) = content.trim().parse::<u32>() {
|
||||||
|
if uid != 4294967295 {
|
||||||
unsafe {
|
unsafe {
|
||||||
let pw = libc::getpwuid(uid);
|
let pw = libc::getpwuid(uid);
|
||||||
if !pw.is_null() {
|
if !pw.is_null() {
|
||||||
|
|
@ -183,6 +184,7 @@ impl UinputInjector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(output) = std::process::Command::new("logname").output() {
|
if let Ok(output) = std::process::Command::new("logname").output() {
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
|
|
@ -196,34 +198,84 @@ impl UinputInjector {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get original non-root UID and GID when running as root.
|
||||||
|
fn get_original_uid_gid() -> Option<(u32, u32)> {
|
||||||
|
let is_root = unsafe { libc::getuid() == 0 };
|
||||||
|
if !is_root {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut target_uid = None;
|
||||||
|
|
||||||
|
if let Ok(uid_str) = std::env::var("SUDO_UID") {
|
||||||
|
if let Ok(uid) = uid_str.parse::<u32>() {
|
||||||
|
target_uid = Some(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_uid.is_none() {
|
||||||
|
if let Ok(uid_str) = std::env::var("PKEXEC_UID") {
|
||||||
|
if let Ok(uid) = uid_str.parse::<u32>() {
|
||||||
|
target_uid = Some(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_uid.is_none() {
|
||||||
|
if let Ok(content) = std::fs::read_to_string("/proc/self/loginuid") {
|
||||||
|
if let Ok(uid) = content.trim().parse::<u32>() {
|
||||||
|
if uid != 4294967295 {
|
||||||
|
target_uid = Some(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(uid) = target_uid {
|
||||||
|
unsafe {
|
||||||
|
let pw = libc::getpwuid(uid);
|
||||||
|
if !pw.is_null() {
|
||||||
|
let gid = (*pw).pw_gid;
|
||||||
|
return Some((uid, gid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Run an external command as the original user if we're root.
|
/// Run an external command as the original user if we're root.
|
||||||
/// Wayland tools (wtype, wl-copy) need the user's session, not root.
|
/// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead.
|
||||||
/// Uses explicit `env VAR=val` instead of `--preserve-env` for
|
|
||||||
/// compatibility with all sudo versions.
|
|
||||||
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
|
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
|
||||||
let is_root = unsafe { libc::getuid() == 0 };
|
let is_root = unsafe { libc::getuid() == 0 };
|
||||||
if is_root {
|
if is_root {
|
||||||
if let Some(original_user) = Self::get_original_username() {
|
if let Some((uid, gid)) = Self::get_original_uid_gid() {
|
||||||
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_default();
|
||||||
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default();
|
||||||
let display = std::env::var("DISPLAY").unwrap_or_default();
|
let display = std::env::var("DISPLAY").unwrap_or_default();
|
||||||
let mut cmd = std::process::Command::new("sudo");
|
|
||||||
cmd.args(["-u", &original_user, "env"]);
|
use std::os::unix::process::CommandExt;
|
||||||
|
let mut cmd = std::process::Command::new(program);
|
||||||
|
cmd.uid(uid).gid(gid);
|
||||||
|
|
||||||
if !wayland_display.is_empty() {
|
if !wayland_display.is_empty() {
|
||||||
cmd.arg(format!("WAYLAND_DISPLAY={}", wayland_display));
|
cmd.env("WAYLAND_DISPLAY", wayland_display);
|
||||||
}
|
}
|
||||||
if !xdg_runtime_dir.is_empty() {
|
if !xdg_runtime_dir.is_empty() {
|
||||||
cmd.arg(format!("XDG_RUNTIME_DIR={}", xdg_runtime_dir));
|
cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir);
|
||||||
}
|
}
|
||||||
if !display.is_empty() {
|
if !display.is_empty() {
|
||||||
cmd.arg(format!("DISPLAY={}", display));
|
cmd.env("DISPLAY", display);
|
||||||
}
|
}
|
||||||
cmd.arg(program);
|
if let Some(username) = Self::get_original_username() {
|
||||||
|
cmd.env("HOME", format!("/home/{}", username));
|
||||||
|
}
|
||||||
|
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
match cmd.output() {
|
match cmd.output() {
|
||||||
Ok(output) => return output,
|
Ok(output) => return output,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc] Failed to run sudo -u {} env ... {} {}: {}", original_user, program, args.join(" "), e);
|
eprintln!("[vietc] Failed to run {} as uid={}: {}", program, uid, e);
|
||||||
return std::process::Output {
|
return std::process::Output {
|
||||||
status: std::process::ExitStatus::default(),
|
status: std::process::ExitStatus::default(),
|
||||||
stdout: vec![],
|
stdout: vec![],
|
||||||
|
|
@ -231,8 +283,6 @@ impl UinputInjector {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
eprintln!("[vietc] Running as root but could not determine original user");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match std::process::Command::new(program).args(args).output() {
|
match std::process::Command::new(program).args(args).output() {
|
||||||
|
|
@ -271,13 +321,17 @@ impl UinputInjector {
|
||||||
// It is Unicode. We must use a single unified channel.
|
// It is Unicode. We must use a single unified channel.
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
|
|
||||||
|
static HAS_WTYPE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
static HAS_XDOTOOL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
if is_wayland {
|
if is_wayland {
|
||||||
// Under Wayland, we try to use `wtype` for both backspaces and text.
|
let has_wtype = *HAS_WTYPE.get_or_init(|| {
|
||||||
let has_wtype = std::process::Command::new("which")
|
std::process::Command::new("which")
|
||||||
.arg("wtype")
|
.arg("wtype")
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
if has_wtype {
|
if has_wtype {
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
|
|
@ -295,12 +349,13 @@ impl UinputInjector {
|
||||||
eprintln!("[vietc] wtype inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
|
eprintln!("[vietc] wtype inject failed: {}", String::from_utf8_lossy(&output.stderr).trim());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Under X11, we try to use `xdotool` for both backspaces and text.
|
let has_xdotool = *HAS_XDOTOOL.get_or_init(|| {
|
||||||
let has_xdotool = std::process::Command::new("which")
|
std::process::Command::new("which")
|
||||||
.arg("xdotool")
|
.arg("xdotool")
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
if has_xdotool {
|
if has_xdotool {
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue