Merge pull request #4 from vndangkhoa/devin/1782470733-fix-flush-spacing-v2

The auto-restore merge reintroduced the spacing bug: on every flush char
the engine re-emitted a Replace and the daemon backspaced+retyped the
already-on-screen composed word, racing against the separately-forwarded
flush char and eating spaces (mất sự->mấtsự, đầu ngã xuống->đầungãxuống).

Now flush only backspaces+retypes when auto-restore actually changes the
word (English/invalid Vietnamese -> raw keystrokes). For a normal composed
word the engine returns None and the daemon types only the flush char,
leaving the correct word untouched on screen.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: vndangkhoa <vonguyendangkhoa@gmail.com>
This commit is contained in:
vndangkhoa 2026-06-26 17:48:39 +07:00 committed by GitHub
commit 575de7a5a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 32 additions and 31 deletions

View file

@ -285,15 +285,17 @@ impl Daemon {
fn replay_and_inject(&mut self, ch: char) -> Vec<OutputCommand> { fn replay_and_inject(&mut self, ch: char) -> Vec<OutputCommand> {
let mut commands = Vec::new(); let mut commands = Vec::new();
// Flush characters: commit current word, type the character, clear state. // Flush characters: commit the current word and type the flush char.
// The composed word is already correctly on screen, so we must NOT // Only backspace + retype when auto-restore actually CHANGES the word
// backspace and retype it — doing so eats the spacing and shifts the // (English / invalid Vietnamese). For a normal composed word it is
// finished word left. Just type the flush char and clear state. // already correctly on screen, so retyping it would eat the spacing and
// shift the finished word left.
if is_flush_char(ch) { 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(); let backspaces = self.screen_output.chars().count();
commands.push(OutputCommand::Backspace(backspaces)); commands.push(OutputCommand::Backspace(backspaces));
commands.push(OutputCommand::Type(self.word_to_commit())); commands.push(OutputCommand::Type(to_commit));
} }
// Type the flush character itself // Type the flush character itself
commands.push(OutputCommand::Type(ch.to_string())); commands.push(OutputCommand::Type(ch.to_string()));
@ -317,16 +319,15 @@ impl Daemon {
); );
if did_flush { if did_flush {
// Engine flushed a word — it is already correctly on screen, so // Engine flushed a word. Only backspace + retype when auto-restore
// just clear state without backspacing/retyping it (retyping eats // actually CHANGES the word; otherwise the composed word is already
// spacing and shifts the finished word left). // correct on screen and retyping it eats spacing and shifts the
// Engine flushed a word — commit it and clear state // finished word left.
// The flush char (space/period/etc) was NOT in history, so we need to let to_commit = self.word_to_commit();
// type whatever was on screen + the flush char if !self.screen_output.is_empty() && to_commit != self.screen_output {
if !self.screen_output.is_empty() {
let backspaces = self.screen_output.chars().count(); let backspaces = self.screen_output.chars().count();
commands.push(OutputCommand::Backspace(backspaces)); commands.push(OutputCommand::Backspace(backspaces));
commands.push(OutputCommand::Type(self.word_to_commit())); commands.push(OutputCommand::Type(to_commit));
} }
self.keystroke_history.clear(); self.keystroke_history.clear();
self.screen_output.clear(); self.screen_output.clear();

View file

@ -213,25 +213,21 @@ impl Engine {
let raw = self.raw_buffer.clone(); let raw = self.raw_buffer.clone();
self.reset(); 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 { if prev_len > 0 {
// Auto-restore: if the committed word is English / not valid // 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) { if self.auto_restore && Engine::should_restore_word(&previous, &raw) {
return Some(EngineEvent::Replace { return Some(EngineEvent::Replace {
backspaces: prev_len, backspaces: prev_len,
insert: raw, insert: raw,
}); });
} }
// Don't include flush char in insert — daemon forwards it separately // Normal case: the composed word is already correctly on screen.
return Some(EngineEvent::Replace { // Re-typing it would trigger a redundant backspace + retype that
backspaces: prev_len, // races against the separately-forwarded flush char, eating
insert: previous, // spaces and merging words. Finalize and let the flush char
}); // through untouched.
} }
return None; return None;
} }

View file

@ -91,11 +91,15 @@ fn auto_restore_can_be_disabled() {
for ch in "cargo".chars() { for ch in "cargo".chars() {
engine.process_key(ch); 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(' '); let event = engine.process_key(' ');
match event { assert!(
Some(vietc_engine::EngineEvent::Replace { insert, .. }) => { event.is_none(),
assert_eq!(insert, "cảgo", "with auto-restore off the VN form is kept"); "with auto-restore off the composed VN word stays untouched on flush, got {event:?}"
} );
other => panic!("expected Replace to 'cảgo', got {other:?}"),
}
} }