diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 44ded91..2c1b02c 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -282,14 +282,11 @@ 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(); - 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(); @@ -311,14 +308,9 @@ impl Daemon { ); 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())); - } + // 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). self.keystroke_history.clear(); self.screen_output.clear(); return commands; diff --git a/engine/src/engine.rs b/engine/src/engine.rs index 13edb27..126af0c 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -172,13 +172,11 @@ impl Engine { } self.reset(); - if prev_len > 0 { - // Don't include flush char in insert — daemon forwards it separately - return Some(EngineEvent::Replace { - backspaces: prev_len, - insert: previous, - }); - } + // 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. return None; } 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 "); + } }