diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 4af7499..487fff3 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -8,6 +8,31 @@ use std::time::Duration; use vietc_engine::{Engine, EngineEvent, InputMethod}; +/// Pin current thread to performance cores (0-3) and boost priority. +/// Inspired by VMK's approach to minimize input latency on Intel hybrid CPUs. +fn boost_thread_priority() { + unsafe { + // Set nice value to -10 (higher priority than normal) + libc::setpriority(libc::PRIO_PROCESS, 0, -10); + + // Try to pin to P-cores (cores 0-3 on Intel hybrid) + #[cfg(target_os = "linux")] + { + let mut cpuset: libc::cpu_set_t = std::mem::zeroed(); + // Pin to cores 0-3 (P-cores on Intel 12th gen+) + for i in 0..4 { + libc::CPU_SET(i, &mut cpuset); + } + let ret = libc::sched_setaffinity(0, std::mem::size_of::(), &cpuset); + if ret == 0 { + eprintln!("[vietc] Pinned to P-cores 0-3, nice=-10"); + } else { + eprintln!("[vietc] CPU pinning failed ({}), nice=-10 still set", ret); + } + } + } +} + mod app_state; mod config; mod display; @@ -84,6 +109,13 @@ struct Daemon { app_state: AppStateManager, engine_enabled: Arc, grab_enabled: bool, + /// Backspace-Replay: all keystrokes in the current word being composed. + /// On each keypress, we replay the entire history through a fresh engine + /// to compute the correct screen output, eliminating state desync. + keystroke_history: Vec, + /// What's currently displayed on screen for the current word. + /// Used to calculate how many backspaces we need before retyping. + screen_output: String, } impl Daemon { @@ -120,6 +152,8 @@ impl Daemon { config_modified, app_state, engine_enabled, + keystroke_history: Vec::new(), + screen_output: String::new(), } } @@ -307,6 +341,130 @@ impl Daemon { self.app_state.is_current_app_bypassed() } + /// Backspace-Replay: replay the entire keystroke history through a fresh + /// engine, compute what should be on screen, and return the commands + /// (backspaces to erase old + new text to type). + fn replay_and_inject(&mut self, ch: char) -> Vec { + let mut commands = Vec::new(); + + // Flush characters: commit current word, type the character, clear state + if is_flush_char(ch) { + if !self.screen_output.is_empty() { + let backspaces = self.screen_output.chars().count(); + commands.push(OutputCommand::Backspace(backspaces)); + commands.push(OutputCommand::Type(self.screen_output.clone())); + } + // Type the flush character itself + commands.push(OutputCommand::Type(ch.to_string())); + self.keystroke_history.clear(); + self.screen_output.clear(); + return commands; + } + + // Add the new keystroke to history + self.keystroke_history.push(ch); + + // Replay through fresh engine + let method = match self.config.input_method.as_str() { + "vni" => InputMethod::Vni, + _ => InputMethod::Telex, + }; + let (new_output, did_flush) = Engine::replay_keystrokes( + method, + &self.config.macros, + &self.keystroke_history, + ); + + log_info(&format!( + "[vietc] replay: history_len={} old_screen='{}' new_output='{}' flush={}", + self.keystroke_history.len(), + self.screen_output, + new_output, + did_flush + )); + + if did_flush { + // Engine flushed a word — commit it and clear state + // The flush char (space/period/etc) was NOT in history, so we need to + // type whatever was on screen + the flush char + if !self.screen_output.is_empty() { + let backspaces = self.screen_output.chars().count(); + commands.push(OutputCommand::Backspace(backspaces)); + commands.push(OutputCommand::Type(self.screen_output.clone())); + } + self.keystroke_history.clear(); + self.screen_output.clear(); + return commands; + } + + if new_output != self.screen_output { + let backspaces = self.screen_output.chars().count(); + if backspaces > 0 { + commands.push(OutputCommand::Backspace(backspaces)); + } + if !new_output.is_empty() { + commands.push(OutputCommand::Type(new_output.clone())); + } + self.screen_output = new_output; + } + + commands + } + + /// Backspace-Replay: pop from history, replay, and return commands to fix screen. + fn replay_backspace(&mut self) -> Vec { + let mut commands = Vec::new(); + + if self.keystroke_history.is_empty() { + // Nothing in history — just forward the backspace + commands.push(OutputCommand::Backspace(1)); + return commands; + } + + // Remove last keystroke from history + self.keystroke_history.pop(); + + // Replay through fresh engine + let method = match self.config.input_method.as_str() { + "vni" => InputMethod::Vni, + _ => InputMethod::Telex, + }; + let (new_output, _) = if self.keystroke_history.is_empty() { + (String::new(), false) + } else { + Engine::replay_keystrokes( + method, + &self.config.macros, + &self.keystroke_history, + ) + }; + + log_info(&format!( + "[vietc] replay_backspace: history_len={} old_screen='{}' new_output='{}'", + self.keystroke_history.len(), + self.screen_output, + new_output + )); + + // Calculate diff + let backspaces = self.screen_output.chars().count(); + if backspaces > 0 { + commands.push(OutputCommand::Backspace(backspaces)); + } + if !new_output.is_empty() { + commands.push(OutputCommand::Type(new_output.clone())); + } + self.screen_output = new_output; + + commands + } + + /// Reset the replay state (on flush, focus loss, modifier key, etc.) + fn replay_reset(&mut self) { + self.keystroke_history.clear(); + self.screen_output.clear(); + } + fn check_app_change_with(&mut self, new_class: String) { if let Some(should_enable) = self.app_state.update_with_app(new_class) { self.engine.set_enabled(should_enable); @@ -321,6 +479,11 @@ enum OutputCommand { Backspace(usize), } +/// Characters that flush the current word and start a new one. +fn is_flush_char(ch: char) -> bool { + matches!(ch, ' ' | '.' | ',' | '!' | '?' | ';' | ':' | '\t' | '\n') +} + fn main() -> Result<(), Box> { let config_path = config::find_config_path(); let config = Config::load()?; @@ -353,6 +516,9 @@ fn main() -> Result<(), Box> { } )); + // Boost thread priority for low-latency input (VMK technique) + boost_thread_priority(); + // Spawn background monitor for active window, config changes, and status changes let shared_active_window = Arc::new(Mutex::new(String::new())); let config_changed = Arc::new(AtomicBool::new(false)); @@ -560,7 +726,7 @@ fn run_with_x11( if active_window != last_active_window { log_info(&format!("[vietc] Window changed: '{}' -> '{}'", last_active_window, active_window)); last_active_window = active_window.clone(); - daemon.engine.reset(); + daemon.replay_reset(); } } @@ -569,9 +735,17 @@ fn run_with_x11( daemon.check_app_change_with(active_window); } + // Reset on focus loss (VMK technique) + if capture.focus_lost { + eprintln!("[vietc] Focus lost — resetting engine state"); + daemon.replay_reset(); + pressed_keys.clear(); + capture.focus_lost = false; + } + while let Some(event) = capture.next_event() { if event.pressed { - // Skip autorepeat — key is already tracked as held + // Skip autorepeat if !pressed_keys.insert(event.keycode) { continue; } @@ -580,54 +754,53 @@ fn run_with_x11( if let Some(' ') = event.ch { if (event.state & 4) != 0 { pressed_keys.remove(&event.keycode); + daemon.replay_reset(); daemon.toggle(); continue; } } - // Modifier or non-character key → forward press only + // Modifier or non-character key → forward press only, reset replay if capture.is_modifier_pressed(event.state) || event.ch.is_none() { - daemon.engine.reset(); + daemon.replay_reset(); capture.without_grab(|| { let _ = injector.send_key_event(event.keycode as u16, 1); }); continue; } - // Character key + // Character key — use Backspace-Replay if let Some(ch) = event.ch { match ch { '\x08' => { - daemon.engine.process_key('\x08'); + // Backspace: replay pattern pops from history + let commands = daemon.replay_backspace(); + pressed_keys.remove(&event.keycode); capture.without_grab(|| { - let _ = injector.send_backspace(); + execute_commands(&*injector, &commands, true); }); - // Keep in pressed_keys so release is forwarded + // If history is empty and commands only had a bare backspace, + // we need to actually send it + if daemon.keystroke_history.is_empty() && commands.is_empty() { + capture.without_grab(|| { + let _ = injector.send_backspace(); + }); + } } '\n' => { pressed_keys.remove(&event.keycode); - daemon.engine.reset(); + daemon.replay_reset(); capture.without_grab(|| { let _ = injector.send_key_event(event.keycode as u16, 1); let _ = injector.send_key_event(event.keycode as u16, 0); }); } _ => { - let commands = daemon.process_key(ch); - if !commands.is_empty() { - // Engine consumed the key; remove from tracking - pressed_keys.remove(&event.keycode); - capture.without_grab(|| { - execute_commands(&*injector, &commands, true); - }); - } else { - // Engine started composing; forward press+release immediately - pressed_keys.remove(&event.keycode); - capture.without_grab(|| { - let _ = injector.send_key_event(event.keycode as u16, 1); - let _ = injector.send_key_event(event.keycode as u16, 0); - }); - } + let commands = daemon.replay_and_inject(ch); + pressed_keys.remove(&event.keycode); + capture.without_grab(|| { + execute_commands(&*injector, &commands, true); + }); } } } diff --git a/engine/src/engine.rs b/engine/src/engine.rs index 88c1b26..65ec4c5 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -92,6 +92,72 @@ impl Engine { event } + /// Replay a sequence of keystrokes through a fresh engine and return the + /// final screen output. This is the core of the Backspace-Replay pattern: + /// instead of tracking incremental state, we always recompute from scratch. + /// Returns (output_on_screen, did_flush). + /// `did_flush` means the engine processed a word boundary and the cursor + /// is now at a clean position — caller should clear keystroke history. + pub fn replay_keystrokes( + method: InputMethod, + macros: &std::collections::HashMap, + keystrokes: &[char], + ) -> (String, bool) { + let mut engine = Engine::new(method); + for (shortcut, expansion) in macros { + engine.add_macro(shortcut.clone(), expansion.clone()); + } + + let mut last_output = String::new(); + let mut did_flush = false; + + for &ch in keystrokes { + if let Some(event) = engine.process_key(ch) { + match event { + EngineEvent::Replace { insert, .. } => { + last_output = insert; + } + EngineEvent::Flush(_word) => { + // Word was flushed. The flush char is NOT part of the word. + // The word is committed; clear tracking for current composing. + last_output.clear(); + did_flush = true; + } + EngineEvent::Insert(text) => { + last_output = text; + } + EngineEvent::UndoTones { restored, .. } => { + last_output = restored; + } + EngineEvent::Paste(text) => { + last_output = text; + } + EngineEvent::AutoRestore(word) => { + last_output = word; + } + } + } else { + // Key consumed but no screen change — buffer is building + let buf = engine.buffer().to_string(); + if !buf.is_empty() { + last_output = buf; + } + } + } + + // If the engine has a buffer that hasn't been flushed, that's on screen + let buf = engine.buffer().to_string(); + if !buf.is_empty() { + last_output = buf; + did_flush = false; // Still composing + } else if did_flush { + // After flush, nothing is on screen for the composing word + last_output.clear(); + } + + (last_output, did_flush) + } + /// Update buffer with pasted text for subsequent edit operations (delete/backspace) pub fn update_with_pasted_text(&mut self, text: &str) { self.raw_buffer.clear(); @@ -497,4 +563,63 @@ mod tests { 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); + } } diff --git a/protocol/src/x11_capture.rs b/protocol/src/x11_capture.rs index bb444fe..f9a1965 100644 --- a/protocol/src/x11_capture.rs +++ b/protocol/src/x11_capture.rs @@ -2,15 +2,15 @@ use std::ffi::{c_char, c_int, c_void}; type Display = c_void; type Window = u64; -type XID = u64; type Time = u64; // X11 event types const KEY_PRESS: c_int = 2; const KEY_RELEASE: c_int = 3; +const FOCUS_IN: c_int = 9; +const FOCUS_OUT: c_int = 10; // X11 modifier masks -const SHIFT_MASK: c_int = 1; const CONTROL_MASK: c_int = 4; const MOD1_MASK: c_int = 8; // Alt const MOD4_MASK: c_int = 64; // Super/Win @@ -33,7 +33,6 @@ struct X11Lib { x_ungrab_keyboard: unsafe extern "C" fn(*mut Display, Time) -> c_int, x_next_event: unsafe extern "C" fn(*mut Display, *mut XEvent), x_lookup_string: unsafe extern "C" fn(*mut XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int, - x_keysym_to_keycode: unsafe extern "C" fn(*mut Display, KeySym) -> u32, x_utf8_lookup_string: Option c_int>, x_flush: unsafe extern "C" fn(*mut Display) -> c_int, } @@ -69,7 +68,6 @@ impl X11Lib { let x_ungrab_keyboard = sym!("XUngrabKeyboard"); let x_next_event = sym!("XNextEvent"); let x_lookup_string = sym!("XLookupString"); - let x_keysym_to_keycode = sym!("XKeysymToKeycode"); let x_utf8_lookup_string = dlsym(handle, b"Xutf8LookupString\0".as_ptr() as *const c_char); let x_utf8_lookup_string = if x_utf8_lookup_string.is_null() { None @@ -87,7 +85,6 @@ impl X11Lib { x_ungrab_keyboard, x_next_event, x_lookup_string, - x_keysym_to_keycode, x_utf8_lookup_string, x_flush, }) @@ -149,7 +146,8 @@ pub struct X11Capture { display: *mut Display, root: Window, grabbed: bool, - event_buf: Vec, + /// Set to true when FocusOut is received — caller should reset engine state + pub focus_lost: bool, } unsafe impl Send for X11Capture {} @@ -178,7 +176,7 @@ impl X11Capture { display, root, grabbed: false, - event_buf: Vec::new(), + focus_lost: false, }) } } @@ -225,6 +223,17 @@ impl X11Capture { } let _type = event._type; + + // Handle FocusIn/FocusOut — reset engine state when focus changes + if _type == FOCUS_OUT { + self.focus_lost = true; + return self.next_event(); + } + if _type == FOCUS_IN { + self.focus_lost = false; + return self.next_event(); + } + if _type != KEY_PRESS && _type != KEY_RELEASE { return self.next_event(); } diff --git a/protocol/src/x11_inject.rs b/protocol/src/x11_inject.rs index 155b7fb..02f7b95 100644 --- a/protocol/src/x11_inject.rs +++ b/protocol/src/x11_inject.rs @@ -16,7 +16,6 @@ extern "C" { const CURRENT_TIME: Time = 0; const PROP_MODE_REPLACE: c_int = 0; const NO_EVENT_MASK: i64 = 0; -const INPUT_OUTPUT: c_int = 1; const COPY_FROM_PARENT: Window = 0; const SELECTION_REQUEST: c_int = 30; @@ -34,7 +33,6 @@ struct X11Lib { x_intern_atom: unsafe extern "C" fn(*mut Display, *const c_char, c_int) -> Atom, x_set_selection_owner: unsafe extern "C" fn(*mut Display, Atom, Window, Time) -> c_int, x_change_property: unsafe extern "C" fn(*mut Display, Window, Atom, Atom, c_int, c_int, *const c_void, c_int) -> c_int, - x_get_selection_owner: unsafe extern "C" fn(*mut Display, Atom) -> Window, x_send_event: unsafe extern "C" fn(*mut Display, Window, c_int, i64, *const c_void) -> c_int, x_create_simple_window: unsafe extern "C" fn(*mut Display, Window, c_int, c_int, c_int, c_int, c_int, Atom, Atom) -> Window, x_map_window: unsafe extern "C" fn(*mut Display, Window) -> c_int, @@ -90,7 +88,6 @@ impl X11Lib { let x_intern_atom = sym!(x11_handle, "XInternAtom"); let x_set_selection_owner = sym!(x11_handle, "XSetSelectionOwner"); let x_change_property = sym!(x11_handle, "XChangeProperty"); - let x_get_selection_owner = sym!(x11_handle, "XGetSelectionOwner"); let x_send_event = sym!(x11_handle, "XSendEvent"); let x_create_simple_window = sym!(x11_handle, "XCreateSimpleWindow"); let x_map_window = sym!(x11_handle, "XMapWindow"); @@ -110,7 +107,6 @@ impl X11Lib { x_intern_atom, x_set_selection_owner, x_change_property, - x_get_selection_owner, x_send_event, x_create_simple_window, x_map_window, @@ -131,8 +127,6 @@ impl Drop for X11Lib { } } -const X11_KEYCODE_OFFSET: u32 = 8; - fn char_to_keycode(ch: char) -> Option<(u32, bool)> { match ch { 'a' => Some((30, false)), @@ -215,7 +209,6 @@ struct XEvent { pub struct X11Injector { lib: X11Lib, display: *mut Display, - root: Window, clipboard_window: Window, atom_clipboard: Atom, atom_utf8: Atom, @@ -251,7 +244,6 @@ impl X11Injector { Ok(Self { lib, display, - root, clipboard_window, atom_clipboard, atom_utf8,