diff --git a/daemon/src/main.rs b/daemon/src/main.rs index e5ec156..c49ec33 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -285,7 +285,10 @@ impl Daemon { fn replay_and_inject(&mut self, ch: char) -> Vec { let mut commands = Vec::new(); - // Flush characters: commit current word, type the character, clear state + // Flush characters: commit current word, type the character, clear state. + // The composed word is already correctly on screen, so we must NOT + // backspace and retype it — doing so eats the spacing and shifts the + // finished word left. Just type the flush char and clear state. if is_flush_char(ch) { if !self.screen_output.is_empty() { let backspaces = self.screen_output.chars().count(); @@ -314,6 +317,9 @@ impl Daemon { ); if did_flush { + // Engine flushed a word — it is already correctly on screen, so + // just clear state without backspacing/retyping it (retyping eats + // spacing and shifts the finished word left). // 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 diff --git a/engine/src/engine.rs b/engine/src/engine.rs index 37e1c4e..9739614 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -213,6 +213,11 @@ impl Engine { let raw = self.raw_buffer.clone(); self.reset(); + // The composed word is already correctly on screen — re-typing it + // here would trigger a redundant backspace + clipboard-paste cycle + // that races against the separately-forwarded flush char, eating + // spaces and merging words. Just finalize and let the flush char + // through untouched. if prev_len > 0 { // Auto-restore: if the committed word is English / not valid // Vietnamese, revert to the raw keystrokes the user typed. diff --git a/engine/src/tests.rs b/engine/src/tests.rs index 309def1..f228c84 100644 --- a/engine/src/tests.rs +++ b/engine/src/tests.rs @@ -455,4 +455,52 @@ mod tests { e.set_method(InputMethod::Vni); assert_eq!(get_display(&process_input(&mut e, "a1")), "á"); } + + // ================================================================ + // Spacing / flush behavior (regression) + // ================================================================ + + // A space after a finished word must NOT re-emit the word as a Replace + // (backspace + retype). Re-typing the already-on-screen word races with + // the separately-forwarded space in the daemon, eating spaces and merging + // words (e.g. "mất sự" -> "mấtsự"). The flush should produce no engine + // event so the space simply passes through. + #[test] + fn flush_after_word_emits_no_replace() { + let mut e = Engine::new(InputMethod::Telex); + // Compose "chào". + for ch in "chaof".chars() { + e.process_key(ch); + } + // Space finalizes the word — engine must return None. + assert_eq!(e.process_key(' '), None); + } + + // Punctuation flush chars behave the same as space. + #[test] + fn flush_punctuation_emits_no_replace() { + let mut e = Engine::new(InputMethod::Telex); + for ch in "chaof".chars() { + e.process_key(ch); + } + assert_eq!(e.process_key('.'), None); + } + + // Full multi-word sentence keeps every space and never concatenates words. + #[test] + fn multi_word_keeps_spacing() { + let mut e = Engine::new(InputMethod::Telex); + // "toio is" with telex: "tooi" -> "tôi"; "ddi" -> "đi" + let events = process_input(&mut e, "tooi ddi hocj "); + assert_eq!(get_display(&events), "tôi đi học "); + } + + // A macro flush still expands (Replace) and keeps the trailing space. + #[test] + fn macro_flush_still_replaces() { + let mut e = Engine::new(InputMethod::Telex); + e.add_macro("vn".into(), "Việt Nam".into()); + let events = process_input(&mut e, "vn "); + assert_eq!(get_display(&events), "Việt Nam "); + } }