diff --git a/daemon/src/main.rs b/daemon/src/main.rs index c49ec33..e68c170 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -285,15 +285,17 @@ 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. - // 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. + // Flush characters: commit the current word and type the flush char. + // Only backspace + retype when auto-restore actually CHANGES the word + // (English / invalid Vietnamese). For a normal composed word it is + // already correctly on screen, so retyping it would eat the spacing and + // shift the finished word left. if is_flush_char(ch) { - if !self.screen_output.is_empty() { + let to_commit = self.word_to_commit(); + if !self.screen_output.is_empty() && to_commit != self.screen_output { let backspaces = self.screen_output.chars().count(); commands.push(OutputCommand::Backspace(backspaces)); - commands.push(OutputCommand::Type(self.word_to_commit())); + commands.push(OutputCommand::Type(to_commit)); } // Type the flush character itself commands.push(OutputCommand::Type(ch.to_string())); @@ -317,16 +319,15 @@ 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 - if !self.screen_output.is_empty() { + // Engine flushed a word. Only backspace + retype when auto-restore + // actually CHANGES the word; otherwise the composed word is already + // correct on screen and retyping it eats spacing and shifts the + // finished word left. + let to_commit = self.word_to_commit(); + if !self.screen_output.is_empty() && to_commit != self.screen_output { let backspaces = self.screen_output.chars().count(); commands.push(OutputCommand::Backspace(backspaces)); - commands.push(OutputCommand::Type(self.word_to_commit())); + commands.push(OutputCommand::Type(to_commit)); } self.keystroke_history.clear(); self.screen_output.clear(); diff --git a/engine/src/engine.rs b/engine/src/engine.rs index 9739614..c4be946 100644 --- a/engine/src/engine.rs +++ b/engine/src/engine.rs @@ -213,25 +213,21 @@ 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. + // Vietnamese, revert to the raw keystrokes the user typed. This + // genuinely changes the on-screen word, so a Replace is needed. if self.auto_restore && Engine::should_restore_word(&previous, &raw) { return Some(EngineEvent::Replace { backspaces: prev_len, insert: raw, }); } - // Don't include flush char in insert — daemon forwards it separately - return Some(EngineEvent::Replace { - backspaces: prev_len, - insert: previous, - }); + // Normal case: the composed word is already correctly on screen. + // Re-typing it would trigger a redundant backspace + retype that + // races against the separately-forwarded flush char, eating + // spaces and merging words. Finalize and let the flush char + // through untouched. } return None; } diff --git a/engine/tests/auto_restore.rs b/engine/tests/auto_restore.rs index 64ee2de..be8f8f8 100644 --- a/engine/tests/auto_restore.rs +++ b/engine/tests/auto_restore.rs @@ -91,11 +91,15 @@ fn auto_restore_can_be_disabled() { for ch in "cargo".chars() { engine.process_key(ch); } + // With auto-restore off the Vietnamese composition is kept on screen + // (no restore back to the raw English keystrokes). + assert_eq!(engine.buffer(), "cảgo"); + // The composed word is already correct on screen, so flushing emits no + // Replace — re-typing it would race with the forwarded flush char and eat + // the spacing. (Contrast with auto-restore on, which emits Replace→"cargo".) let event = engine.process_key(' '); - match event { - Some(vietc_engine::EngineEvent::Replace { insert, .. }) => { - assert_eq!(insert, "cảgo", "with auto-restore off the VN form is kept"); - } - other => panic!("expected Replace to 'cảgo', got {other:?}"), - } + assert!( + event.is_none(), + "with auto-restore off the composed VN word stays untouched on flush, got {event:?}" + ); }